diff --git a/app/src/components/skills/SkillSetupWizard.tsx b/app/src/components/skills/SkillSetupWizard.tsx index 17bbe589a0..9af036ebde 100644 --- a/app/src/components/skills/SkillSetupWizard.tsx +++ b/app/src/components/skills/SkillSetupWizard.tsx @@ -12,7 +12,7 @@ * as a single "managed" auth mode. */ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { useSkillSnapshot } from "../../lib/skills/hooks.ts"; import { skillManager } from "../../lib/skills/manager.ts"; import { getSkillSnapshot, installSkill, listAvailable, setSetupComplete, startSkill } from "../../lib/skills/skillsApi.ts"; @@ -24,6 +24,25 @@ import SetupFormRenderer from "./SetupFormRenderer.tsx"; import AuthModeSelector from "./AuthModeSelector.tsx"; import { IS_DEV } from "../../utils/config.ts"; +const SKILL_RUNNING_WAIT_MS = 10_000; +const SKILL_RUNNING_POLL_MS = 250; + +/** Poll `skills_status` until the lifecycle reports `running` (or throw on error / timeout). */ +const waitForSkillRunning = async (skillId: string): Promise => { + const deadline = Date.now() + SKILL_RUNNING_WAIT_MS; + while (Date.now() < deadline) { + const snapshot = await getSkillSnapshot(skillId); + if (snapshot.status === "running") return; + if (snapshot.status === "error") { + throw new Error(snapshot.error ? String(snapshot.error) : "Skill failed to start"); + } + await new Promise(resolve => { + setTimeout(resolve, SKILL_RUNNING_POLL_MS); + }); + } + throw new Error("Timed out waiting for skill to start"); +}; + interface SkillSetupWizardProps { skillId: string; onComplete: () => void; @@ -57,6 +76,15 @@ export default function SkillSetupWizard({ onCancel, }: SkillSetupWizardProps) { const [state, setState] = useState({ phase: "loading" }); + /** Ensures managed OAuth auto-advance runs once per browser-login attempt. */ + const managedOAuthAdvancedRef = useRef(false); + /** Ensures legacy OAuth persistence + complete runs once per connected transition. */ + const legacyOAuthAdvancedRef = useRef(false); + + useEffect(() => { + managedOAuthAdvancedRef.current = false; + legacyOAuthAdvancedRef.current = false; + }, [skillId]); // Watch skill snapshot for OAuth/managed completion const snap = useSkillSnapshot(skillId); @@ -66,12 +94,11 @@ export default function SkillSetupWizard({ const transitionToSetup = useCallback(async () => { try { - // Ensure skill runtime is running - try { - await startSkill(skillId); - } catch { - // May already be running - } + // Ensure skill runtime is running. + // Note: if the skill is already running, startSkill returns Ok (not an + // error), so any exception here is a real failure that must surface. + await startSkill(skillId); + await waitForSkillRunning(skillId); const firstStep = await skillManager.startSetup(skillId); if (!firstStep) { @@ -105,6 +132,7 @@ export default function SkillSetupWizard({ return; } await openUrl(data.oauthUrl); + managedOAuthAdvancedRef.current = false; setState({ phase: "auth_managed_waiting", mode }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -138,12 +166,11 @@ export default function SkillSetupWizard({ setState({ phase: "auth_submitting", mode }); try { - // Ensure skill runtime is running - try { - await startSkill(skillId); - } catch { - // May already be running - } + // Ensure skill runtime is running. + // Note: if the skill is already running, startSkill returns Ok (not an + // error), so any exception here is a real failure that must surface. + await startSkill(skillId); + await waitForSkillRunning(skillId); // Build credential payload const credentials = @@ -187,19 +214,61 @@ export default function SkillSetupWizard({ // Detect managed OAuth completion useEffect(() => { if (state.phase === "auth_managed_waiting" && isConnected) { - setTimeout(() => { transitionToSetup(); }, 0); + if (managedOAuthAdvancedRef.current) return; + managedOAuthAdvancedRef.current = true; + void (async () => { + try { + await startSkill(skillId); + await waitForSkillRunning(skillId); + } catch (e) { + console.warn("[SkillSetupWizard] post-OAuth startSkill:", e); + setState({ phase: "error", message: "Failed to start skill after OAuth." }); + return; + } + try { + const firstStep = await skillManager.startSetup(skillId); + if (firstStep) { + setState({ phase: "step", step: firstStep }); + } else { + await setSetupComplete(skillId, true); + setState({ + phase: "complete", + message: "Successfully connected! You can close this window.", + }); + } + } catch (e) { + console.warn("[SkillSetupWizard] post-OAuth startSetup:", e); + setState({ phase: "error", message: "Setup failed" }); + } + })(); + return; } // Legacy OAuth completion if ( (state.phase === "oauth" || state.phase === "oauth_waiting") && isConnected ) { - setSetupComplete(skillId, true).catch(() => {}); - setTimeout(() => { - setState({ phase: "complete", message: "Successfully connected!" }); - }, 0); + if (legacyOAuthAdvancedRef.current) return; + legacyOAuthAdvancedRef.current = true; + void (async () => { + try { + await setSetupComplete(skillId, true); + setState({ + phase: "complete", + message: + "Successfully connected! You can close this window.", + }); + } catch (e) { + console.warn("[SkillSetupWizard] legacy OAuth setSetupComplete:", e); + legacyOAuthAdvancedRef.current = false; + setState({ + phase: "error", + message: "Failed to save setup state.", + }); + } + })(); } - }, [isConnected, state.phase, skillId, transitionToSetup]); + }, [isConnected, state.phase, skillId]); // Start the setup flow on mount useEffect(() => { @@ -259,6 +328,7 @@ export default function SkillSetupWizard({ try { await startSkill(skillId); console.log("[SkillSetupWizard] skill started via RPC", skillId); + await waitForSkillRunning(skillId); } catch (startErr) { console.warn("[SkillSetupWizard] runtime start failed:", startErr); if (!cancelled) { diff --git a/app/src/lib/skills/hooks.ts b/app/src/lib/skills/hooks.ts index c531cb582f..ea28fccb6f 100644 --- a/app/src/lib/skills/hooks.ts +++ b/app/src/lib/skills/hooks.ts @@ -19,6 +19,21 @@ import { type SkillSnapshotRpc, } from './skillsApi'; +/** Snapshot used when `skills_status` fails (skill not loaded in runtime yet, RPC error, etc.). */ +const offlineSkillSnapshot = ( + skillId: string, + previous?: SkillSnapshotRpc | null, +): SkillSnapshotRpc => ({ + skill_id: skillId, + name: previous?.name ?? '', + status: previous?.status ?? 'installed', + tools: previous?.tools ?? [], + state: previous?.state ?? {}, + setup_complete: previous?.setup_complete ?? false, + connection_status: previous?.connection_status ?? 'offline', + error: previous?.error, +}); + // --------------------------------------------------------------------------- // Legacy pure function kept for compatibility (used by sync.ts, skillsSyncUi) // --------------------------------------------------------------------------- @@ -80,7 +95,13 @@ export function useSkillSnapshot(skillId: string | undefined): SkillSnapshotRpc const s = await getSkillSnapshot(skillId); if (mountedRef.current) setSnap(s); } catch { - // Skill may not be running yet — that's OK + // Skill may not be in the runtime yet (e.g. never started) — `skills_status` + // returns an error and would otherwise leave `snap` null forever. UI such as + // SkillSetupModal waits for a non-null snapshot before leaving its loading + // state, so we synthesize an offline snapshot instead of staying stuck. + if (mountedRef.current) { + setSnap(previous => offlineSkillSnapshot(skillId, previous)); + } } }, [skillId]); diff --git a/app/src/lib/skills/manager.ts b/app/src/lib/skills/manager.ts index c4330d9386..4347e33ac6 100644 --- a/app/src/lib/skills/manager.ts +++ b/app/src/lib/skills/manager.ts @@ -385,22 +385,6 @@ class SkillManager { } } - // Kick off an initial sync so the user sees fresh data immediately - // after connecting, rather than waiting for the next cron tick. - // The Rust core no longer auto-triggers sync on oauth/complete - // (removed in commit 840b1d3c), so the frontend drives it here. - // Fire-and-forget: any failure is logged but must not block the - // OAuth completion flow. - try { - console.log(`[SkillManager] kicking initial sync after OAuth for '${skillId}'`); - await this.triggerSync(skillId); - } catch (syncErr) { - console.warn( - `[SkillManager] initial post-OAuth sync failed for '${skillId}':`, - syncErr, - ); - } - emitSkillStateChange(skillId); } diff --git a/app/src/pages/__tests__/Skills.third-party-gmail-sync.test.tsx b/app/src/pages/__tests__/Skills.third-party-gmail-sync.test.tsx index 7063c75f7f..fed24715cf 100644 --- a/app/src/pages/__tests__/Skills.third-party-gmail-sync.test.tsx +++ b/app/src/pages/__tests__/Skills.third-party-gmail-sync.test.tsx @@ -5,6 +5,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import '../../test/mockDefaultSkillStatusHooks'; import { renderWithProviders } from '../../test/test-utils'; import Skills from '../Skills'; diff --git a/app/src/pages/__tests__/Skills.third-party-notion-debug-tools.test.tsx b/app/src/pages/__tests__/Skills.third-party-notion-debug-tools.test.tsx index a992c114ca..4ba49c2821 100644 --- a/app/src/pages/__tests__/Skills.third-party-notion-debug-tools.test.tsx +++ b/app/src/pages/__tests__/Skills.third-party-notion-debug-tools.test.tsx @@ -6,6 +6,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import '../../test/mockDefaultSkillStatusHooks'; import { renderWithProviders } from '../../test/test-utils'; import Skills from '../Skills'; diff --git a/app/src/test/mockDefaultSkillStatusHooks.ts b/app/src/test/mockDefaultSkillStatusHooks.ts new file mode 100644 index 0000000000..a9593896f5 --- /dev/null +++ b/app/src/test/mockDefaultSkillStatusHooks.ts @@ -0,0 +1,36 @@ +/** + * Shared Vitest mocks for screen-intelligence / autocomplete / voice status hooks. + * Import this module first in Skills page tests so `Skills` does not require `CoreStateProvider`. + */ +import { vi } from 'vitest'; + +/** Shared offline-shaped fields for skill status hook mocks (avoid drift across hooks). */ +const offlineStatusBase = { + connectionStatus: 'offline' as const, + statusDot: 'bg-stone-400', + statusLabel: 'Offline', + statusColor: 'text-stone-500', + ctaLabel: 'Enable', + ctaVariant: 'sage' as const, +}; + +vi.mock('../features/screen-intelligence/useScreenIntelligenceSkillStatus', () => ({ + useScreenIntelligenceSkillStatus: () => ({ + ...offlineStatusBase, + allPermissionsGranted: false, + platformUnsupported: false, + }), +})); + +vi.mock('../features/autocomplete/useAutocompleteSkillStatus', () => ({ + useAutocompleteSkillStatus: () => ({ ...offlineStatusBase, platformUnsupported: false }), +})); + +vi.mock('../features/voice/useVoiceSkillStatus', () => ({ + useVoiceSkillStatus: () => ({ + ...offlineStatusBase, + sttModelMissing: false, + voiceStatus: null, + serverStatus: null, + }), +})); diff --git a/app/src/utils/desktopDeepLinkListener.ts b/app/src/utils/desktopDeepLinkListener.ts index 666918c9a7..b88b23ea22 100644 --- a/app/src/utils/desktopDeepLinkListener.ts +++ b/app/src/utils/desktopDeepLinkListener.ts @@ -188,10 +188,8 @@ const handleOAuthDeepLink = async (parsed: URL) => { console.warn(`[DeepLink] Could not start skill '${skillId}' in runtime:`, startErr); } - // 3. Notify the running skill of the OAuth credential, mark setup_complete, - // activate (list tools, sync tools to backend), and kick an initial sync. - // The initial sync is driven by SkillManager.notifyOAuthComplete — the - // Rust core no longer auto-syncs on oauth/complete (removed in 840b1d3c). + // 3. Notify the running skill of the OAuth credential and mark setup_complete. + // Initial data sync is left to the user / cron / skill UI — not auto-run here. try { await skillManager.notifyOAuthComplete(skillId, integrationId, undefined, { clientKeyShare }); console.log(`[DeepLink] OAuth complete sent to skill '${skillId}'`); diff --git a/app/test/e2e/specs/skill-execution-flow.spec.ts b/app/test/e2e/specs/skill-execution-flow.spec.ts index e01cd87eb9..f6e962551a 100644 --- a/app/test/e2e/specs/skill-execution-flow.spec.ts +++ b/app/test/e2e/specs/skill-execution-flow.spec.ts @@ -113,6 +113,27 @@ describe('Skill execution (UI + core RPC)', () => { expect(stop.result?.success === true).toBe(true); }); + it('skills_set_setup_complete + skills_status without start (OAuth persistence path)', async () => { + try { + const set = await callOpenhumanRpc('openhuman.skills_set_setup_complete', { + skill_id: E2E_RUNTIME_SKILL_ID, + complete: true, + }); + expect(set.ok).toBe(true); + + const st = await callOpenhumanRpc('openhuman.skills_status', { + skill_id: E2E_RUNTIME_SKILL_ID, + }); + expect(st.ok).toBe(true); + expect(st.result?.setup_complete === true).toBe(true); + } finally { + await callOpenhumanRpc('openhuman.skills_set_setup_complete', { + skill_id: E2E_RUNTIME_SKILL_ID, + complete: false, + }); + } + }); + it('Skills page loads (UI surface for installed tools)', async () => { await navigateToSkills(); await browser.pause(2_000); diff --git a/app/test/e2e/specs/skill-oauth.spec.ts b/app/test/e2e/specs/skill-oauth.spec.ts index 5531baf234..4c90526f48 100644 --- a/app/test/e2e/specs/skill-oauth.spec.ts +++ b/app/test/e2e/specs/skill-oauth.spec.ts @@ -2,6 +2,12 @@ /** * OAuth-oriented skills UI smoke test (issue #221). * Verifies Skills page shows connection/setup affordances after auth. + * + * JSON-RPC coverage for OAuth + setup persistence lives in Rust integration tests + * (`tests/json_rpc_e2e.rs`: `json_rpc_skills_status_reflects_setup_complete_without_runtime`, + * `json_rpc_skills_oauth_complete_after_start`). The Playwright `skill-execution-flow.spec.ts` + * suite exercises `skills_start` → tools against the seeded `e2e-runtime` skill over the same + * HTTP JSON-RPC path the desktop UI uses. */ import { waitForApp, waitForAppReady } from '../helpers/app-helpers'; import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers'; diff --git a/src/openhuman/skills/schemas.rs b/src/openhuman/skills/schemas.rs index 64986a426d..eaa3f42231 100644 --- a/src/openhuman/skills/schemas.rs +++ b/src/openhuman/skills/schemas.rs @@ -4,6 +4,8 @@ //! skills registry and runtime: skill installation, and runtime control //! (start, stop, tool calls, etc.). +use std::collections::HashMap; + use serde::Deserialize; use serde_json::{Map, Value}; @@ -13,6 +15,7 @@ use crate::openhuman::config::rpc as config_rpc; use super::qjs_engine::require_engine; use super::registry_ops; +use super::types::{derive_connection_status, SkillSnapshot, SkillStatus}; /// Returns all controller schemas defined in this module. pub fn all_controller_schemas() -> Vec { @@ -632,6 +635,20 @@ fn handle_skills_install(params: Map) -> ControllerFuture { let p: SkillIdParams = serde_json::from_value(Value::Object(params)).map_err(|e| e.to_string())?; let config = config_rpc::load_config_with_timeout().await?; + + // Sync the engine's workspace_dir with the current config so that a + // subsequent `skills_start` call finds the files we are about to write. + // This is necessary when the user logged in *after* the core bootstrapped: + // the engine was initialised with the pre-login (unscoped) workspace path, + // but config.workspace_dir now points to the user-scoped directory. + if let Ok(engine) = require_engine() { + log::debug!( + "[skills_install] syncing engine workspace_dir to {:?}", + config.workspace_dir + ); + engine.set_workspace_dir(config.workspace_dir.clone()); + } + registry_ops::skill_install(&config.workspace_dir, &p.skill_id).await?; Ok(serde_json::json!({ "success": true, @@ -692,6 +709,23 @@ fn handle_skills_start(params: Map) -> ControllerFuture { let p: SkillIdParams = serde_json::from_value(Value::Object(params)).map_err(|e| e.to_string())?; let engine = require_engine()?; + + // Refresh the engine's workspace_dir from config every time a skill is + // started. The engine's workspace_dir is set once at bootstrap using + // the paths resolved at that moment. If the user was not yet logged in + // at startup the engine ends up with the unscoped legacy workspace path + // (`~/.openhuman/workspace`), while `skills_install` writes to the + // user-scoped path derived from `config.workspace_dir`. Without this + // sync `start_skill` cannot find the installed manifest and fails with + // "Skill '…' not found (no manifest.json)". + if let Ok(config) = config_rpc::load_config_with_timeout().await { + log::debug!( + "[skills_start] syncing engine workspace_dir to {:?}", + config.workspace_dir + ); + engine.set_workspace_dir(config.workspace_dir); + } + let snapshot = engine.start_skill(&p.skill_id).await?; serde_json::to_value(&snapshot).map_err(|e| e.to_string()) }) @@ -717,9 +751,28 @@ fn handle_skills_status(params: Map) -> ControllerFuture { let p: SkillIdParams = serde_json::from_value(Value::Object(params)).map_err(|e| e.to_string())?; let engine = require_engine()?; - let snapshot = engine - .get_skill_state(&p.skill_id) - .ok_or_else(|| format!("Skill '{}' not found in runtime", p.skill_id))?; + let snapshot = if let Some(snap) = engine.get_skill_state(&p.skill_id) { + snap + } else { + // Not loaded in QuickJS (never started, still starting, or failed to start). + // Still return persisted prefs — especially `setup_complete` after OAuth — + // so the UI is not stuck waiting for a snapshot that would only exist once + // the runtime has the skill registered. + let setup_complete = engine.preferences().is_setup_complete(&p.skill_id); + let status = SkillStatus::Pending; + let state = HashMap::new(); + let connection_status = derive_connection_status(status, setup_complete, &state); + SkillSnapshot { + skill_id: p.skill_id.clone(), + name: p.skill_id.clone(), + status, + tools: vec![], + error: None, + state, + setup_complete, + connection_status, + } + }; serde_json::to_value(&snapshot).map_err(|e| e.to_string()) }) } diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index d68aca4bb9..68e47aba21 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -495,6 +495,33 @@ fn assert_no_jsonrpc_error<'a>(v: &'a Value, context: &str) -> &'a Value { .unwrap_or_else(|| panic!("{context}: missing result: {v}")) } +/// Poll `openhuman.skills_status` until lifecycle `status` is `"running"` or timeout. +async fn wait_for_skill_status_running(rpc_base: &str, skill_id: &str, mut next_id: i64) -> i64 { + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + let status = post_json_rpc( + rpc_base, + next_id, + "openhuman.skills_status", + json!({"skill_id": skill_id}), + ) + .await; + next_id += 1; + let st = assert_no_jsonrpc_error(&status, "skills_status (wait for running)"); + let s = st.get("status").and_then(Value::as_str).unwrap_or(""); + if s == "running" { + return next_id; + } + if s == "error" { + panic!("skill {skill_id} entered error while waiting for running: {st}"); + } + if tokio::time::Instant::now() >= deadline { + panic!("timed out waiting for skill {skill_id} to reach running, last: {st}"); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + fn extract_string_outcome(result: &Value) -> String { if let Some(s) = result.as_str() { return s.to_string(); @@ -1546,6 +1573,11 @@ fn write_test_skill(workspace: &Path, skill_id: &str) { return { status: "ok", synced: true }; } + /** Required by `oauth/complete` — runtime calls `start({ oauth, auth, validate: true })`. */ + function start(_args) { + return { status: "complete" }; + } + init(); "#; std::fs::write(skill_dir.join("index.js"), js).expect("write index.js"); @@ -1772,6 +1804,143 @@ async fn json_rpc_skills_runtime_start_tools_call_stop() { rpc_join.abort(); } +/// After `skills_set_setup_complete`, `skills_status` must succeed even when the skill was never +/// started — the same persisted flag OAuth uses before QuickJS has registered the instance. +#[tokio::test] +async fn json_rpc_skills_status_reflects_setup_complete_without_runtime() { + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let openhuman_home = home.join(".openhuman"); + let workspace = openhuman_home.join("workspace"); + std::fs::create_dir_all(workspace.join("skills")).expect("create skills dir"); + + let _home_guard = EnvVarGuard::set_to_path("HOME", home); + let _workspace_guard = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", &workspace); + let _wm_guard = EnvVarGuard::unset("OPENHUMAN_SKILLS_WORKING_MEMORY_ENABLED"); + + write_test_skill(&workspace, "e2e-runtime"); + + let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await; + let mock_origin = format!("http://{}", mock_addr); + write_min_config(&openhuman_home, &mock_origin); + + let skills_data_dir = workspace.join("skills_data"); + std::fs::create_dir_all(&skills_data_dir).expect("create skills_data dir"); + let engine = + std::sync::Arc::new(RuntimeEngine::new(skills_data_dir).expect("create RuntimeEngine")); + engine.set_workspace_dir(workspace.clone()); + set_global_engine(engine); + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let rpc_base = format!("http://{}", rpc_addr); + tokio::time::sleep(Duration::from_millis(100)).await; + + let set_done = post_json_rpc( + &rpc_base, + 500, + "openhuman.skills_set_setup_complete", + json!({"skill_id": "e2e-runtime", "complete": true}), + ) + .await; + let set_result = assert_no_jsonrpc_error(&set_done, "skills_set_setup_complete"); + assert_eq!(set_result.get("success"), Some(&json!(true))); + + let status = post_json_rpc( + &rpc_base, + 501, + "openhuman.skills_status", + json!({"skill_id": "e2e-runtime"}), + ) + .await; + let st = assert_no_jsonrpc_error(&status, "skills_status"); + assert_eq!( + st.get("setup_complete").and_then(Value::as_bool), + Some(true), + "status should surface persisted setup_complete without skills_start: {st}" + ); + + mock_join.abort(); + rpc_join.abort(); +} + +/// `oauth/complete` is delivered via `openhuman.skills_rpc` after `skills_start` (browser OAuth → +/// deep link / host uses the same RPC in production). +#[tokio::test] +async fn json_rpc_skills_oauth_complete_after_start() { + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let openhuman_home = home.join(".openhuman"); + let workspace = openhuman_home.join("workspace"); + std::fs::create_dir_all(workspace.join("skills")).expect("create skills dir"); + + let _home_guard = EnvVarGuard::set_to_path("HOME", home); + let _workspace_guard = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", &workspace); + let _wm_guard = EnvVarGuard::unset("OPENHUMAN_SKILLS_WORKING_MEMORY_ENABLED"); + + write_test_skill(&workspace, "e2e-runtime"); + + let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await; + let mock_origin = format!("http://{}", mock_addr); + write_min_config(&openhuman_home, &mock_origin); + + let skills_data_dir = workspace.join("skills_data"); + std::fs::create_dir_all(&skills_data_dir).expect("create skills_data dir"); + let engine = + std::sync::Arc::new(RuntimeEngine::new(skills_data_dir).expect("create RuntimeEngine")); + engine.set_workspace_dir(workspace.clone()); + set_global_engine(engine); + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let rpc_base = format!("http://{}", rpc_addr); + tokio::time::sleep(Duration::from_millis(100)).await; + + let start = post_json_rpc( + &rpc_base, + 600, + "openhuman.skills_start", + json!({"skill_id": "e2e-runtime"}), + ) + .await; + let _ = assert_no_jsonrpc_error(&start, "skills_start"); + let next_after_running = wait_for_skill_status_running(&rpc_base, "e2e-runtime", 6001).await; + + let oauth = post_json_rpc( + &rpc_base, + next_after_running, + "openhuman.skills_rpc", + json!({ + "skill_id": "e2e-runtime", + "method": "oauth/complete", + "params": { + "credentialId": "e2e-oauth-integration-id", + "provider": "google", + "grantedScopes": [] + } + }), + ) + .await; + let oauth_result = assert_no_jsonrpc_error(&oauth, "skills_rpc oauth/complete"); + assert_eq!( + oauth_result.get("status").and_then(Value::as_str), + Some("complete"), + "oauth/complete should return start() validation result: {oauth_result}" + ); + + let stop = post_json_rpc( + &rpc_base, + next_after_running + 1, + "openhuman.skills_stop", + json!({"skill_id": "e2e-runtime"}), + ) + .await; + let _ = assert_no_jsonrpc_error(&stop, "skills_stop"); + + mock_join.abort(); + rpc_join.abort(); +} + // --------------------------------------------------------------------------- // Local AI device profile, presets, and apply preset // ---------------------------------------------------------------------------