mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Fix/191 skills reliability upstream (#282)
* feat(skills): core RPC data stats, tool timeout env, ping state merge (#191) - Add openhuman.skills_data_stats and SkillDataDirectoryStats (disk usage). - Centralize tool execution timeout via OPENHUMAN_TOOL_TIMEOUT_SECS (tool_timeout). - Apply timeout to skill event loop, agent tool loop, harness default, delegate. - Ping scheduler: merge connection_error into published_state via registry. - JSON-RPC e2e: assert skills_data_stats. Closes #214 Closes #218 Part of #213 (backend) Made-with: Cursor * feat(app): skills sync stats UI, reconnect resync, FE timeouts, chat errors (#191) - useSkillDataDirectoryStats + Skills.tsx merge disk stats with skill state. - resyncRunningSkillsAfterReconnect on socket connect; disconnectSkill OAuth cleanup in finally. - withTimeout for callTool/triggerSync; VITE_TOOL_TIMEOUT_SECS in app .env.example. - Structured ChatSendError in Conversations (data-chat-send-error-code). Closes #213 Closes #215 Closes #216 Closes #217 Closes #219 Made-with: Cursor * test(e2e): onboarding helpers and skills smoke specs (#191) - shared-flows: 5-step onboarding, completeOnboardingIfVisible. - auth-access-control + voice-mode use shared helpers. - skills-registry: named skill assertion; new skill-oauth, multi-round, reconnect, lifecycle specs. Closes #220 Closes #221 Closes #222 Closes #223 Closes #224 Part of #189 (E2E helpers) Made-with: Cursor * style: rustfmt + Prettier for CI (PR #282) Fix Type Check workflow: prettier --check and cargo fmt --check. Made-with: Cursor * fix: PR #282 review — tool timeout config, tool_timeout module, CI diagnostics - Centralize VITE_TOOL_TIMEOUT_SECS in config.ts (TOOL_TIMEOUT_SECS) - Move tool_timeout to folder module; merge_published_state broadcasts SKILL_STATE_CHANGED - Resync guard after socket reconnect; qjs_engine logs data dir stat errors - E2E: registry/lifecycle/multi-round failure diagnostics; onboarding label constant - chatSendError arrow export; rustfmt/import grouping in event_loop Made-with: Cursor * test(e2e): add Gmail skill end-to-end tests - Introduced a comprehensive end-to-end test suite for the Gmail skill, covering the full lifecycle from discovery to OAuth completion and email management. - Implemented two modes: a lifecycle-only mode that validates the skill's event loop without real credentials, and a live mode that interacts with the Gmail API using actual credentials. - Added detailed environment variable requirements and usage instructions for both testing modes. Closes #XXX (replace with relevant issue number if applicable)
This commit is contained in:
@@ -46,6 +46,8 @@ OPENHUMAN_MODEL=
|
||||
OPENHUMAN_WORKSPACE=
|
||||
# [optional] Default: 0.7
|
||||
OPENHUMAN_TEMPERATURE=0.7
|
||||
# [optional] Skill + agent tool execution timeout in seconds (default 120, max 3600)
|
||||
# OPENHUMAN_TOOL_TIMEOUT_SECS=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime flags
|
||||
|
||||
@@ -21,3 +21,7 @@ VITE_DEV_JWT_TOKEN=
|
||||
|
||||
# [optional] Dev-only: force onboarding flow to always show
|
||||
VITE_DEV_FORCE_ONBOARDING=false
|
||||
|
||||
# [optional] Client-side timeout for skill callTool/triggerSync (seconds; default 120, max 3600).
|
||||
# Should match OPENHUMAN_TOOL_TIMEOUT_SECS on the core when set.
|
||||
# VITE_TOOL_TIMEOUT_SECS=
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Structured chat send / delivery errors (issue #219) — stable `code` for analytics and tests. */
|
||||
|
||||
export type ChatSendErrorCode =
|
||||
| 'socket_disconnected'
|
||||
| 'local_model_failed'
|
||||
| 'cloud_send_failed'
|
||||
| 'voice_transcription'
|
||||
| 'microphone_unavailable'
|
||||
| 'microphone_recording'
|
||||
| 'microphone_access'
|
||||
| 'voice_playback'
|
||||
| 'safety_timeout';
|
||||
|
||||
export interface ChatSendError {
|
||||
code: ChatSendErrorCode;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const chatSendError = (code: ChatSendErrorCode, message: string): ChatSendError => ({
|
||||
code,
|
||||
message,
|
||||
});
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { SkillSyncStatsLike } from '../../pages/skillsSyncUi';
|
||||
import { runtimeSkillDataStats } from '../../utils/tauriCommands';
|
||||
import type { SkillConnectionStatus, SkillHostConnectionState } from './types';
|
||||
import { onSkillStateChange } from './skillEvents';
|
||||
import {
|
||||
@@ -209,3 +211,49 @@ export function useSkillConnectionInfo(skillId: string): {
|
||||
isInitialized: !!hostState?.is_initialized,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Disk usage under the skill data directory (from core RPC). Refreshes on skill events
|
||||
* and periodically while the skill is connected.
|
||||
*/
|
||||
export function useSkillDataDirectoryStats(
|
||||
skillId: string,
|
||||
fetchEnabled: boolean,
|
||||
): Pick<SkillSyncStatsLike, 'localDataBytes' | 'localFileCount'> | undefined {
|
||||
const [stats, setStats] = useState<
|
||||
Pick<SkillSyncStatsLike, 'localDataBytes' | 'localFileCount'> | undefined
|
||||
>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetchEnabled) {
|
||||
setStats(undefined);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
const d = await runtimeSkillDataStats(skillId);
|
||||
if (!cancelled) {
|
||||
setStats({
|
||||
localDataBytes: d.total_bytes,
|
||||
localFileCount: d.file_count,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setStats(undefined);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
const interval = setInterval(load, 30_000);
|
||||
const unsub = onSkillStateChange((id) => {
|
||||
if (!id || id === skillId) void load();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
unsub();
|
||||
};
|
||||
}, [skillId, fetchEnabled]);
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
@@ -30,11 +30,13 @@ import {
|
||||
runtimeSkillDataRead,
|
||||
runtimeSkillDataWrite,
|
||||
} from "../../utils/tauriCommands";
|
||||
import { toolExecutionTimeoutMsFromEnv, withTimeout } from "../../utils/withTimeout";
|
||||
// Env vars kept for reverse RPC compatibility (may be used by skills via state)
|
||||
|
||||
|
||||
class SkillManager {
|
||||
private runtimes = new Map<string, SkillRuntime>();
|
||||
private resyncAfterReconnectInProgress = false;
|
||||
|
||||
/**
|
||||
* Get skill-specific load parameters (e.g., wallet address for wallet skill)
|
||||
@@ -110,6 +112,21 @@ class SkillManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After realtime socket reconnect: refresh tool lists for every running skill so
|
||||
* `tool:sync` matches the Rust engine (issue #215).
|
||||
*/
|
||||
async resyncRunningSkillsAfterReconnect(): Promise<void> {
|
||||
if (this.resyncAfterReconnectInProgress) return;
|
||||
this.resyncAfterReconnectInProgress = true;
|
||||
try {
|
||||
const ids = [...this.runtimes.keys()];
|
||||
await Promise.all(ids.map((id) => this.activateSkill(id)));
|
||||
} finally {
|
||||
this.resyncAfterReconnectInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a skill that has completed setup — list its tools and mark as ready.
|
||||
*/
|
||||
@@ -197,7 +214,12 @@ class SkillManager {
|
||||
console.error(`[SkillManager] callTool failed — skill "${skillId}" has no running runtime`);
|
||||
throw new Error(`Skill ${skillId} is not running`);
|
||||
}
|
||||
const result = await runtime.callTool(name, args);
|
||||
const timeoutMs = toolExecutionTimeoutMsFromEnv();
|
||||
const result = await withTimeout(
|
||||
runtime.callTool(name, args),
|
||||
timeoutMs,
|
||||
`[SkillManager] callTool skill="${skillId}" tool="${name}"`,
|
||||
);
|
||||
console.log(`[SkillManager] callTool result skill="${skillId}" tool="${name}" isError=${result.isError}`);
|
||||
return result;
|
||||
}
|
||||
@@ -229,16 +251,25 @@ class SkillManager {
|
||||
* Progress updates are published to Redux via the skill's state fields.
|
||||
*/
|
||||
async triggerSync(skillId: string): Promise<void> {
|
||||
const timeoutMs = toolExecutionTimeoutMsFromEnv();
|
||||
const runtime = this.runtimes.get(skillId);
|
||||
if (runtime) {
|
||||
await runtime.triggerSync();
|
||||
await withTimeout(
|
||||
runtime.triggerSync(),
|
||||
timeoutMs,
|
||||
`[SkillManager] triggerSync skill="${skillId}"`,
|
||||
);
|
||||
} else {
|
||||
// Try via core RPC pass-through
|
||||
try {
|
||||
await callCoreRpc({
|
||||
method: "openhuman.skills_sync",
|
||||
params: { skill_id: skillId },
|
||||
});
|
||||
await withTimeout(
|
||||
callCoreRpc({
|
||||
method: "openhuman.skills_sync",
|
||||
params: { skill_id: skillId },
|
||||
}),
|
||||
timeoutMs,
|
||||
`[SkillManager] skills_sync skill="${skillId}"`,
|
||||
);
|
||||
} catch {
|
||||
// Skill not running — skip sync silently
|
||||
}
|
||||
@@ -366,30 +397,27 @@ class SkillManager {
|
||||
// Revoke OAuth credential before stopping so the running skill can clean up
|
||||
// its in-memory state and the event loop deletes oauth_credential.json.
|
||||
let revokeSucceeded = false;
|
||||
if (credentialId) {
|
||||
try {
|
||||
await rpcRevokeOAuth(skillId, credentialId);
|
||||
revokeSucceeded = true;
|
||||
} catch (err) {
|
||||
console.debug(
|
||||
"[SkillManager] oauth/revoked failed (runtime may be stopped):",
|
||||
err,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await rpcRevokeOAuth(skillId, credentialId ?? "default");
|
||||
revokeSucceeded = true;
|
||||
} catch (err) {
|
||||
console.debug(
|
||||
"[SkillManager] oauth/revoked failed (runtime may be stopped):",
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
await this.stopSkill(skillId);
|
||||
|
||||
// Host-side fallback: if the RPC couldn't reach the runtime (already stopped,
|
||||
// or non-OAuth skill), delete the persisted credential file so it isn't
|
||||
// restored on next start.
|
||||
if (!revokeSucceeded) {
|
||||
await removePersistedOAuthCredential(skillId).catch((err) => {
|
||||
console.debug(
|
||||
"[SkillManager] host-side credential cleanup failed:",
|
||||
err,
|
||||
);
|
||||
});
|
||||
try {
|
||||
await this.stopSkill(skillId);
|
||||
} finally {
|
||||
if (!revokeSucceeded) {
|
||||
await removePersistedOAuthCredential(skillId).catch((err) => {
|
||||
console.debug(
|
||||
"[SkillManager] host-side credential cleanup failed:",
|
||||
err,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await rpcSetSetupComplete(skillId, false).catch(() => {});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { type ChatSendError, chatSendError } from '../chat/chatSendError';
|
||||
import { useLocalModelStatus } from '../hooks/useLocalModelStatus';
|
||||
import { creditsApi, type TeamUsage } from '../services/api/creditsApi';
|
||||
import { inferenceApi, type ModelInfo } from '../services/api/inferenceApi';
|
||||
@@ -107,7 +108,7 @@ const Conversations = () => {
|
||||
const [selectedModel, setSelectedModel] = useState('agentic-v1');
|
||||
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const [sendError, setSendError] = useState<ChatSendError | null>(null);
|
||||
const socketStatus = useAppSelector(selectSocketStatus);
|
||||
const [toolTimelineByThread, setToolTimelineByThread] = useState<
|
||||
Record<string, ToolTimelineEntry[]>
|
||||
@@ -568,7 +569,7 @@ const Conversations = () => {
|
||||
|
||||
if (!trimmed || !selectedThreadId || isSending) return;
|
||||
if (!isLocalModelActiveRef.current && socketStatus !== 'connected') {
|
||||
setSendError('Realtime socket is not connected.');
|
||||
setSendError(chatSendError('socket_disconnected', 'Realtime socket is not connected.'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -602,6 +603,12 @@ const Conversations = () => {
|
||||
sendingTimeoutRef.current = setTimeout(() => {
|
||||
console.warn('[chat] safety timeout: clearing isSending after 120s with no response');
|
||||
setIsSending(false);
|
||||
setSendError(
|
||||
chatSendError(
|
||||
'safety_timeout',
|
||||
'No response from the assistant after 2 minutes. Try again or check your connection.'
|
||||
)
|
||||
);
|
||||
dispatch(setActiveThread(null));
|
||||
sendingTimeoutRef.current = null;
|
||||
}, 120_000);
|
||||
@@ -648,7 +655,7 @@ const Conversations = () => {
|
||||
} catch (err) {
|
||||
pendingReactionRef.current.delete(sendingThreadId);
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setSendError(msg);
|
||||
setSendError(chatSendError('local_model_failed', msg));
|
||||
dispatch(
|
||||
addInferenceResponse({
|
||||
content: 'Local model error — please try again.',
|
||||
@@ -674,7 +681,7 @@ const Conversations = () => {
|
||||
sendingTimeoutRef.current = null;
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setSendError(msg);
|
||||
setSendError(chatSendError('cloud_send_failed', msg));
|
||||
setIsSending(false);
|
||||
dispatch(setActiveThread(null));
|
||||
}
|
||||
@@ -719,7 +726,7 @@ const Conversations = () => {
|
||||
await handleSendMessage(transcript);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setSendError(`Voice transcription failed: ${message}`);
|
||||
setSendError(chatSendError('voice_transcription', `Voice transcription failed: ${message}`));
|
||||
setVoiceStatus(null);
|
||||
} finally {
|
||||
setIsTranscribing(false);
|
||||
@@ -730,7 +737,10 @@ const Conversations = () => {
|
||||
if (!rustChat || isSending || isTranscribing) return;
|
||||
if (!canUseMicrophoneApi) {
|
||||
setSendError(
|
||||
'Microphone capture is unavailable in this runtime. Use Text mode, or run the desktop app bundle with microphone permissions enabled.'
|
||||
chatSendError(
|
||||
'microphone_unavailable',
|
||||
'Microphone capture is unavailable in this runtime. Use Text mode, or run the desktop app bundle with microphone permissions enabled.'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -766,7 +776,7 @@ const Conversations = () => {
|
||||
setIsRecording(false);
|
||||
mediaStreamRef.current?.getTracks().forEach(track => track.stop());
|
||||
mediaStreamRef.current = null;
|
||||
setSendError('Microphone recording failed.');
|
||||
setSendError(chatSendError('microphone_recording', 'Microphone recording failed.'));
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
void transcribeAndSendAudio(recorder.mimeType);
|
||||
@@ -779,7 +789,7 @@ const Conversations = () => {
|
||||
recorder.start();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setSendError(`Microphone access failed: ${message}`);
|
||||
setSendError(chatSendError('microphone_access', `Microphone access failed: ${message}`));
|
||||
setVoiceStatus(null);
|
||||
}
|
||||
};
|
||||
@@ -815,7 +825,7 @@ const Conversations = () => {
|
||||
await audio.play();
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setSendError('Failed to play voice reply.');
|
||||
setSendError(chatSendError('voice_playback', 'Failed to play voice reply.'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
@@ -1318,7 +1328,9 @@ const Conversations = () => {
|
||||
|
||||
{sendError && (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs text-coral-500">{sendError}</p>
|
||||
<p className="text-xs text-coral-500" data-chat-send-error-code={sendError.code}>
|
||||
{sendError.message}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSendError(null)}
|
||||
className="text-xs text-stone-500 hover:text-stone-300 transition-colors ml-2 flex-shrink-0">
|
||||
|
||||
@@ -9,12 +9,21 @@ import {
|
||||
} from '../components/skills/shared';
|
||||
import SkillDebugModal from '../components/skills/SkillDebugModal';
|
||||
import SkillSetupModal from '../components/skills/SkillSetupModal';
|
||||
import { useAvailableSkills, useSkillConnectionStatus, useSkillState } from '../lib/skills/hooks';
|
||||
import {
|
||||
useAvailableSkills,
|
||||
useSkillConnectionStatus,
|
||||
useSkillDataDirectoryStats,
|
||||
useSkillState,
|
||||
} from '../lib/skills/hooks';
|
||||
import { skillManager } from '../lib/skills/manager';
|
||||
import { installSkill } from '../lib/skills/skillsApi';
|
||||
import type { SkillConnectionStatus, SkillHostConnectionState } from '../lib/skills/types';
|
||||
import { IS_DEV } from '../utils/config';
|
||||
import { deriveSkillSyncSummaryText, deriveSkillSyncUiState } from './skillsSyncUi';
|
||||
import {
|
||||
deriveSkillSyncSummaryText,
|
||||
deriveSkillSyncUiState,
|
||||
type SkillSyncStatsLike,
|
||||
} from './skillsSyncUi';
|
||||
|
||||
/** Status dot color for skill connection status */
|
||||
function statusDotClass(status: SkillConnectionStatus): string {
|
||||
@@ -41,7 +50,28 @@ function SkillCard({ skill, onSetup }: SkillCardProps) {
|
||||
const connectionStatus = useSkillConnectionStatus(skill.id);
|
||||
const statusDisplay = STATUS_DISPLAY[connectionStatus] || STATUS_DISPLAY.offline;
|
||||
const skillState = useSkillState<SkillHostConnectionState & Record<string, unknown>>(skill.id);
|
||||
const syncStats = undefined; // TODO: sync stats will come from RPC in future
|
||||
const diskStats = useSkillDataDirectoryStats(skill.id, connectionStatus === 'connected');
|
||||
const syncStats = useMemo((): SkillSyncStatsLike | undefined => {
|
||||
const base: SkillSyncStatsLike = { ...diskStats };
|
||||
const sc = skillState?.syncCount;
|
||||
if (typeof sc === 'number' && Number.isFinite(sc)) base.syncCount = sc;
|
||||
const last =
|
||||
typeof skillState?.lastSyncAtMs === 'number'
|
||||
? skillState.lastSyncAtMs
|
||||
: typeof skillState?.lastSyncTime === 'number'
|
||||
? skillState.lastSyncTime
|
||||
: undefined;
|
||||
if (last != null && Number.isFinite(last)) base.lastSyncAtMs = last;
|
||||
if (
|
||||
base.syncCount == null &&
|
||||
base.lastSyncAtMs == null &&
|
||||
base.localDataBytes == null &&
|
||||
base.localFileCount == null
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return base;
|
||||
}, [diskStats, skillState]);
|
||||
const [manualSyncing, setManualSyncing] = useState(false);
|
||||
const [debugOpen, setDebugOpen] = useState(false);
|
||||
const syncUi = useMemo(
|
||||
|
||||
@@ -3,7 +3,7 @@ import debug from 'debug';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
|
||||
import { SocketIOMCPTransportImpl } from '../lib/mcp';
|
||||
import { syncToolsToBackend } from '../lib/skills';
|
||||
import { skillManager, syncToolsToBackend } from '../lib/skills';
|
||||
import { store } from '../store';
|
||||
import { upsertChannelConnection } from '../store/channelConnectionsSlice';
|
||||
import { resetForUser, setSocketIdForUser, setStatusForUser } from '../store/socketSlice';
|
||||
@@ -167,6 +167,9 @@ class SocketService {
|
||||
store.dispatch(setStatusForUser({ userId: uid, status: 'connected' }));
|
||||
store.dispatch(setSocketIdForUser({ userId: uid, socketId }));
|
||||
syncToolsToBackend();
|
||||
void skillManager.resyncRunningSkillsAfterReconnect().catch(err => {
|
||||
console.warn('[socket] resync running skills after reconnect failed:', err);
|
||||
});
|
||||
});
|
||||
|
||||
this.socket.on('ready', () => {
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
export const CORE_RPC_URL =
|
||||
import.meta.env.VITE_OPENHUMAN_CORE_RPC_URL || 'http://127.0.0.1:7788/rpc';
|
||||
|
||||
/** Matches core `OPENHUMAN_TOOL_TIMEOUT_SECS` (default 120s, max 3600s). */
|
||||
const DEFAULT_TOOL_TIMEOUT_SECS = 120;
|
||||
const MAX_TOOL_TIMEOUT_SECS = 3600;
|
||||
|
||||
function parseToolTimeoutSecs(): number {
|
||||
const raw = import.meta.env.VITE_TOOL_TIMEOUT_SECS as string | undefined;
|
||||
if (raw === undefined || raw === '') return DEFAULT_TOOL_TIMEOUT_SECS;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n <= 0 || n > MAX_TOOL_TIMEOUT_SECS) {
|
||||
return DEFAULT_TOOL_TIMEOUT_SECS;
|
||||
}
|
||||
return Math.round(n);
|
||||
}
|
||||
|
||||
export const TOOL_TIMEOUT_SECS = parseToolTimeoutSecs();
|
||||
|
||||
export const IS_DEV = import.meta.env.DEV;
|
||||
|
||||
/** Dev only: skip `.skip_onboarding` workspace check and ignore onboarded state so `/onboarding` always shows. Set `VITE_DEV_FORCE_ONBOARDING=true` in `.env.local`. */
|
||||
|
||||
@@ -2099,7 +2099,7 @@ export async function runtimeDisableSkill(skillId: string): Promise<void> {
|
||||
|
||||
export async function runtimeSkillDataStats(skillId: string): Promise<RuntimeSkillDataStats> {
|
||||
return await callCoreRpc<RuntimeSkillDataStats>({
|
||||
method: 'openhuman.skills_status',
|
||||
method: 'openhuman.skills_data_stats',
|
||||
params: { skill_id: skillId },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { TOOL_TIMEOUT_SECS } from './config';
|
||||
|
||||
/**
|
||||
* Reject with a clear error if `promise` does not settle within `timeoutMs`.
|
||||
* Clears the timer when the promise completes.
|
||||
*/
|
||||
export async function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
label: string
|
||||
): Promise<T> {
|
||||
if (timeoutMs <= 0) return promise;
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error(`${label} timed out after ${Math.round(timeoutMs / 1000)}s`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeoutPromise]);
|
||||
} finally {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** Default matches core `OPENHUMAN_TOOL_TIMEOUT_SECS` (120). */
|
||||
export function toolExecutionTimeoutMsFromEnv(): number {
|
||||
return Math.round(TOOL_TIMEOUT_SECS * 1000);
|
||||
}
|
||||
@@ -182,99 +182,82 @@ export async function navigateToConversations() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Onboarding walkthrough (Onboarding.tsx — 6 real steps)
|
||||
// Onboarding walkthrough (Onboarding.tsx — 5 steps, indices 0–4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Labels used to detect the onboarding overlay (same strings as Onboarding copy). */
|
||||
export const ONBOARDING_OVERLAY_TEXTS = [
|
||||
'Skip',
|
||||
'Welcome',
|
||||
'Run AI Models Locally',
|
||||
'Screen & Accessibility',
|
||||
'Enable Tools',
|
||||
'Install Skills',
|
||||
] as const;
|
||||
|
||||
/** True when the full-screen onboarding overlay is likely visible. */
|
||||
async function onboardingOverlayLikelyVisible(): Promise<boolean> {
|
||||
for (const label of ONBOARDING_OVERLAY_TEXTS) {
|
||||
if (await textExists(label)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk through the real onboarding steps:
|
||||
* Step 0: WelcomeStep — "Continue"
|
||||
* Step 1: LocalAIStep — "Continue"
|
||||
* Step 2: ScreenPermissions — "Continue Without Permission"
|
||||
* Step 3: ToolsStep — "Continue"
|
||||
* Step 4: SkillsStep — "Finish Setup" (fires onboarding-complete)
|
||||
* Step 5: MnemonicStep — checkbox + "Finish Setup"
|
||||
* Walk through onboarding: Welcome → Local AI → Screen & Accessibility → Tools → Skills.
|
||||
* Each step uses the shared primary button label "Continue" (see OnboardingNextButton).
|
||||
* Completing the last step dismisses the overlay.
|
||||
*/
|
||||
export async function walkOnboarding(logPrefix = '[E2E]') {
|
||||
// Detect onboarding overlay. The Onboarding.tsx parent renders a "Skip" defer
|
||||
// button (top-right), and step 0 is WelcomeStep with "Continue".
|
||||
const onboardingVisible =
|
||||
(await textExists('Welcome')) ||
|
||||
(await textExists('Skip')) ||
|
||||
(await textExists('Continue')) ||
|
||||
(await textExists('Finish Setup'));
|
||||
let visible = false;
|
||||
for (let attempt = 0; attempt < 8; attempt++) {
|
||||
if (await onboardingOverlayLikelyVisible()) {
|
||||
visible = true;
|
||||
break;
|
||||
}
|
||||
await browser.pause(400);
|
||||
}
|
||||
|
||||
if (!onboardingVisible) {
|
||||
if (!visible) {
|
||||
console.log(`${logPrefix} Onboarding overlay not visible — skipping`);
|
||||
await browser.pause(3_000);
|
||||
await browser.pause(1_000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 0: WelcomeStep
|
||||
if (await textExists('Welcome')) {
|
||||
const clicked = await clickFirstMatch(['Continue'], 10_000);
|
||||
if (clicked) console.log(`${logPrefix} WelcomeStep: clicked "${clicked}"`);
|
||||
await browser.pause(2_000);
|
||||
}
|
||||
// Up to 6 "Continue" clicks — covers 5 steps plus one retry if the list is still loading.
|
||||
for (let step = 0; step < 6; step++) {
|
||||
if (!(await onboardingOverlayLikelyVisible())) {
|
||||
console.log(`${logPrefix} Onboarding dismissed after step ${step}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 1: LocalAIStep — "Continue" button
|
||||
{
|
||||
const clicked = await clickFirstMatch(['Continue'], 10_000);
|
||||
const clicked = await clickFirstMatch(['Continue'], 12_000);
|
||||
if (clicked) {
|
||||
console.log(`${logPrefix} LocalAIStep: clicked "${clicked}"`);
|
||||
await browser.pause(2_000);
|
||||
console.log(`${logPrefix} Onboarding step ${step}: clicked Continue`);
|
||||
await browser.pause(step >= 4 ? 4_000 : 2_000);
|
||||
} else {
|
||||
const installSkillsLabel = ONBOARDING_OVERLAY_TEXTS[ONBOARDING_OVERLAY_TEXTS.length - 1]!;
|
||||
if (await textExists(installSkillsLabel)) {
|
||||
await browser.pause(2_500);
|
||||
const retry = await clickFirstMatch(['Continue'], 10_000);
|
||||
if (retry) {
|
||||
console.log(
|
||||
`${logPrefix} Onboarding step ${step}: retry Continue on ${installSkillsLabel}`
|
||||
);
|
||||
await browser.pause(4_000);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: ScreenPermissionsStep
|
||||
{
|
||||
const clicked = await clickFirstMatch(['Continue'], 10_000);
|
||||
if (clicked) {
|
||||
console.log(`${logPrefix} ScreenPermissionsStep: clicked "${clicked}"`);
|
||||
await browser.pause(2_000);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: ToolsStep
|
||||
{
|
||||
if (await textExists('Enable Tools')) {
|
||||
const clicked = await clickFirstMatch(['Continue'], 10_000);
|
||||
if (clicked) {
|
||||
console.log(`${logPrefix} ToolsStep: clicked "${clicked}"`);
|
||||
await browser.pause(2_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: SkillsStep
|
||||
{
|
||||
if (await textExists('Install Skills')) {
|
||||
const clicked = await clickFirstMatch(['Continue'], 10_000);
|
||||
if (clicked) {
|
||||
console.log(`${logPrefix} SkillsStep: clicked "${clicked}"`);
|
||||
await browser.pause(3_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: MnemonicStep
|
||||
{
|
||||
if (await textExists('Your Recovery Phrase')) {
|
||||
console.log(`${logPrefix} MnemonicStep: visible`);
|
||||
try {
|
||||
await browser.execute(() => {
|
||||
const checkbox = document.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
if (checkbox && !checkbox.checked) checkbox.click();
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(`${logPrefix} MnemonicStep: checkbox failed:`, err);
|
||||
}
|
||||
await browser.pause(1_000);
|
||||
const clicked = await clickFirstMatch(['Finish Setup'], 10_000);
|
||||
if (clicked) {
|
||||
console.log(`${logPrefix} MnemonicStep: clicked "${clicked}"`);
|
||||
await browser.pause(3_000);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* If onboarding is showing, walk through it. Safe no-op when already on Home / no overlay.
|
||||
*/
|
||||
export async function completeOnboardingIfVisible(logPrefix = '[E2E]') {
|
||||
if (await onboardingOverlayLikelyVisible()) {
|
||||
await walkOnboarding(logPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,13 +14,9 @@
|
||||
* 1.3 Logout via Settings menu
|
||||
* 1.3.1 Revoked session auto-logout
|
||||
*
|
||||
* Onboarding steps (Onboarding.tsx — 6 steps):
|
||||
* Step 0: WelcomeStep — "Continue"
|
||||
* Step 1: LocalAIStep — "Continue"
|
||||
* Step 2: ScreenPermissions — "Continue Without Permission"
|
||||
* Step 3: ToolsStep — "Continue"
|
||||
* Step 4: SkillsStep — "Finish Setup" (fires onboarding-complete)
|
||||
* Step 5: MnemonicStep — checkbox + "Finish Setup"
|
||||
* Onboarding steps (Onboarding.tsx — 5 steps, indices 0–4):
|
||||
* Welcome → Local AI → Screen & Accessibility → Enable Tools → Install Skills
|
||||
* (each step: primary "Continue"; final step completes onboarding)
|
||||
*
|
||||
* The mock server runs on http://127.0.0.1:18473 and the .app bundle must
|
||||
* have been built with VITE_BACKEND_URL pointing there.
|
||||
@@ -42,6 +38,7 @@ import {
|
||||
navigateToHome,
|
||||
navigateToSettings,
|
||||
waitForHomePage,
|
||||
walkOnboarding,
|
||||
} from '../helpers/shared-flows';
|
||||
import {
|
||||
clearRequestLog,
|
||||
@@ -78,132 +75,7 @@ async function waitForRequest(method, urlFragment, timeout = 15_000) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for the first matching text from candidates until timeout,
|
||||
* then click it. Returns the clicked text or null if none found.
|
||||
*/
|
||||
async function clickFirstMatch(candidates, timeout = 5_000) {
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
for (const text of candidates) {
|
||||
if (await textExists(text)) {
|
||||
await clickText(text, Math.max(deadline - Date.now(), 1_000));
|
||||
return text;
|
||||
}
|
||||
}
|
||||
await browser.pause(500);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// navigateViaHash, navigateToHome, navigateToSettings, navigateToBilling,
|
||||
// waitForHomePage are imported from shared-flows
|
||||
|
||||
/**
|
||||
* Walk through the real onboarding steps (Onboarding.tsx — 6 steps).
|
||||
*
|
||||
* Step 0: WelcomeStep — "Continue"
|
||||
* Step 1: LocalAIStep — "Continue" (skip Ollama)
|
||||
* Step 2: ScreenPermissions — "Continue Without Permission" or "Continue"
|
||||
* Step 3: ToolsStep — "Continue"
|
||||
* Step 4: SkillsStep — "Finish Setup" (fires onboarding-complete)
|
||||
* Step 5: MnemonicStep — checkbox + "Finish Setup"
|
||||
*/
|
||||
async function walkOnboarding() {
|
||||
// Poll a few times before concluding onboarding never mounted
|
||||
const markers = ['Welcome', 'Skip', 'Continue'];
|
||||
let onboardingVisible = false;
|
||||
for (let attempt = 0; attempt < 6; attempt++) {
|
||||
for (const m of markers) {
|
||||
if (await textExists(m)) {
|
||||
onboardingVisible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (onboardingVisible) break;
|
||||
await browser.pause(500);
|
||||
}
|
||||
|
||||
if (!onboardingVisible) {
|
||||
console.log('[AuthAccess] Onboarding overlay not visible after polling — skipping');
|
||||
await browser.pause(2_000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 0: WelcomeStep — click "Continue"
|
||||
if (await textExists('Welcome')) {
|
||||
const clicked = await clickFirstMatch(['Continue'], 10_000);
|
||||
if (clicked) console.log(`[AuthAccess] WelcomeStep: clicked "${clicked}"`);
|
||||
await browser.pause(2_000);
|
||||
}
|
||||
|
||||
// Step 1: LocalAIStep — "Continue" button
|
||||
{
|
||||
const clicked = await clickFirstMatch(['Continue'], 10_000);
|
||||
if (clicked) {
|
||||
console.log(`[AuthAccess] LocalAIStep: clicked "${clicked}"`);
|
||||
await browser.pause(2_000);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: ScreenPermissionsStep — click "Continue Without Permission"
|
||||
{
|
||||
const clicked = await clickFirstMatch(['Continue Without Permission', 'Continue'], 10_000);
|
||||
if (clicked) {
|
||||
console.log(`[AuthAccess] ScreenPermissionsStep: clicked "${clicked}"`);
|
||||
await browser.pause(2_000);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: ToolsStep — click "Continue"
|
||||
{
|
||||
const toolsVisible = await textExists('Enable Tools');
|
||||
if (toolsVisible) {
|
||||
const clicked = await clickFirstMatch(['Continue'], 10_000);
|
||||
if (clicked) {
|
||||
console.log(`[AuthAccess] ToolsStep: clicked "${clicked}"`);
|
||||
await browser.pause(2_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: SkillsStep — click "Continue"
|
||||
{
|
||||
const skillsVisible = await textExists('Install Skills');
|
||||
if (skillsVisible) {
|
||||
const clicked = await clickFirstMatch(['Continue'], 10_000);
|
||||
if (clicked) {
|
||||
console.log(`[AuthAccess] SkillsStep: clicked "${clicked}"`);
|
||||
await browser.pause(3_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: MnemonicStep — tick checkbox and click "Finish Setup"
|
||||
// Note: Do NOT dump accessibility tree here — it would leak the recovery phrase.
|
||||
{
|
||||
const mnemonicVisible = await textExists('Your Recovery Phrase');
|
||||
if (mnemonicVisible) {
|
||||
console.log(
|
||||
'[AuthAccess] MnemonicStep: visible [tree dump redacted — contains recovery phrase]'
|
||||
);
|
||||
try {
|
||||
await browser.execute(() => {
|
||||
const checkbox = document.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
if (checkbox && !checkbox.checked) checkbox.click();
|
||||
});
|
||||
} catch (err) {
|
||||
console.log('[AuthAccess] MnemonicStep: checkbox click failed:', err);
|
||||
}
|
||||
await browser.pause(1_000);
|
||||
const clicked = await clickFirstMatch(['Finish Setup'], 10_000);
|
||||
if (clicked) {
|
||||
console.log(`[AuthAccess] MnemonicStep: clicked "${clicked}"`);
|
||||
await browser.pause(3_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// walkOnboarding, waitForHomePage imported from shared-flows
|
||||
|
||||
/**
|
||||
* Perform full login via deep link. Walks onboarding. Leaves app on Home page.
|
||||
@@ -237,7 +109,7 @@ async function performFullLogin(token = 'e2e-test-token') {
|
||||
}
|
||||
|
||||
// Walk real onboarding steps
|
||||
await walkOnboarding();
|
||||
await walkOnboarding('[AuthAccess]');
|
||||
|
||||
const homeText = await waitForHomePage(15_000);
|
||||
if (!homeText) {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* Full skill lifecycle smoke (issue #224): auth → Skills page → optional install affordance.
|
||||
*/
|
||||
import { waitForApp, waitForAppReady } from '../helpers/app-helpers';
|
||||
import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers';
|
||||
import {
|
||||
dumpAccessibilityTree,
|
||||
textExists,
|
||||
waitForWebView,
|
||||
waitForWindowVisible,
|
||||
} from '../helpers/element-helpers';
|
||||
import { completeOnboardingIfVisible, navigateToSkills } from '../helpers/shared-flows';
|
||||
import { clearRequestLog, getRequestLog, startMockServer, stopMockServer } from '../mock-server';
|
||||
|
||||
describe('Full skill lifecycle smoke', () => {
|
||||
before(async () => {
|
||||
await startMockServer();
|
||||
await waitForApp();
|
||||
clearRequestLog();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopMockServer();
|
||||
});
|
||||
|
||||
it('auth, onboarding, Skills page, and registry markers', async () => {
|
||||
try {
|
||||
await triggerAuthDeepLinkBypass('e2e-lifecycle-token');
|
||||
await waitForWindowVisible(25_000);
|
||||
await waitForWebView(15_000);
|
||||
await waitForAppReady(15_000);
|
||||
await completeOnboardingIfVisible('[LifecycleE2E]');
|
||||
|
||||
await navigateToSkills();
|
||||
await browser.pause(2_000);
|
||||
|
||||
const hash = await browser.execute(() => window.location.hash);
|
||||
expect(String(hash)).toContain('/skills');
|
||||
|
||||
const content =
|
||||
(await textExists('Skills')) ||
|
||||
(await textExists('Install')) ||
|
||||
(await textExists('Available'));
|
||||
expect(content).toBe(true);
|
||||
|
||||
const log = getRequestLog() as Array<{ method: string; url: string }>;
|
||||
const sawSkillsRegistry = log.some(r => r.method === 'GET' && r.url.includes('/skills'));
|
||||
expect(sawSkillsRegistry).toBe(true);
|
||||
} catch (err) {
|
||||
await dumpAccessibilityTree();
|
||||
console.log('[LifecycleE2E] Request log:', getRequestLog());
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* Multi-round tool usage via chat (issue #222) — smoke: authenticated user can open Conversations.
|
||||
* Deep agent+tool loops are covered in Rust integration tests; here we verify the shell route.
|
||||
*/
|
||||
import { waitForApp, waitForAppReady } from '../helpers/app-helpers';
|
||||
import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers';
|
||||
import {
|
||||
dumpAccessibilityTree,
|
||||
textExists,
|
||||
waitForWebView,
|
||||
waitForWindowVisible,
|
||||
} from '../helpers/element-helpers';
|
||||
import { completeOnboardingIfVisible, navigateViaHash } from '../helpers/shared-flows';
|
||||
import { clearRequestLog, getRequestLog, startMockServer, stopMockServer } from '../mock-server';
|
||||
|
||||
describe('Multi-round tool conversation smoke', () => {
|
||||
before(async () => {
|
||||
await startMockServer();
|
||||
await waitForApp();
|
||||
clearRequestLog();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopMockServer();
|
||||
});
|
||||
|
||||
it('loads Conversations after login for agent tool use', async () => {
|
||||
await triggerAuthDeepLinkBypass('e2e-multi-round-token');
|
||||
await waitForWindowVisible(25_000);
|
||||
await waitForWebView(15_000);
|
||||
await waitForAppReady(15_000);
|
||||
await completeOnboardingIfVisible('[MultiRoundE2E]');
|
||||
|
||||
await navigateViaHash('/conversations');
|
||||
await browser.pause(2_500);
|
||||
|
||||
const ok =
|
||||
(await textExists('Message OpenHuman')) ||
|
||||
(await textExists('Conversation')) ||
|
||||
(await textExists('Type a message'));
|
||||
if (!ok) {
|
||||
const tree = await dumpAccessibilityTree();
|
||||
console.error('[MultiRoundE2E] Accessibility tree (truncated):', tree.slice(0, 4000));
|
||||
console.error('[MultiRoundE2E] Request log:', getRequestLog());
|
||||
try {
|
||||
const html = await browser.getPageSource();
|
||||
console.error('[MultiRoundE2E] Page source (truncated):', html.slice(0, 4000));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* OAuth-oriented skills UI smoke test (issue #221).
|
||||
* Verifies Skills page shows connection/setup affordances after auth.
|
||||
*/
|
||||
import { waitForApp, waitForAppReady } from '../helpers/app-helpers';
|
||||
import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers';
|
||||
import { textExists, waitForWebView, waitForWindowVisible } from '../helpers/element-helpers';
|
||||
import { completeOnboardingIfVisible, navigateToSkills } from '../helpers/shared-flows';
|
||||
import { clearRequestLog, startMockServer, stopMockServer } from '../mock-server';
|
||||
|
||||
describe('Skill OAuth UI smoke', () => {
|
||||
before(async () => {
|
||||
await startMockServer();
|
||||
await waitForApp();
|
||||
clearRequestLog();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopMockServer();
|
||||
});
|
||||
|
||||
it('reaches Skills page and shows skill rows with actions after login', async () => {
|
||||
await triggerAuthDeepLinkBypass('e2e-skill-oauth-token');
|
||||
await waitForWindowVisible(25_000);
|
||||
await waitForWebView(15_000);
|
||||
await waitForAppReady(15_000);
|
||||
await completeOnboardingIfVisible('[SkillOAuthE2E]');
|
||||
|
||||
await navigateToSkills();
|
||||
await browser.pause(2_500);
|
||||
|
||||
const hasSkillChrome =
|
||||
(await textExists('Skills')) ||
|
||||
(await textExists('Install')) ||
|
||||
(await textExists('Available')) ||
|
||||
(await textExists('Connect')) ||
|
||||
(await textExists('Setup'));
|
||||
|
||||
expect(hasSkillChrome).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* Socket reconnect + skill sync (issue #223).
|
||||
* Ensures app still reaches a healthy post-auth state; full reconnect is integration-tested in app code.
|
||||
*/
|
||||
import { waitForApp, waitForAppReady } from '../helpers/app-helpers';
|
||||
import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers';
|
||||
import { textExists, waitForWebView, waitForWindowVisible } from '../helpers/element-helpers';
|
||||
import {
|
||||
completeOnboardingIfVisible,
|
||||
navigateToHome,
|
||||
waitForHomePage,
|
||||
} from '../helpers/shared-flows';
|
||||
import { clearRequestLog, startMockServer, stopMockServer } from '../mock-server';
|
||||
|
||||
describe('Socket reconnect skill sync smoke', () => {
|
||||
before(async () => {
|
||||
await startMockServer();
|
||||
await waitForApp();
|
||||
clearRequestLog();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopMockServer();
|
||||
});
|
||||
|
||||
it('reaches Home after login (baseline for post-reconnect tool:sync)', async () => {
|
||||
await triggerAuthDeepLinkBypass('e2e-reconnect-token');
|
||||
await waitForWindowVisible(25_000);
|
||||
await waitForWebView(15_000);
|
||||
await waitForAppReady(15_000);
|
||||
await completeOnboardingIfVisible('[ReconnectE2E]');
|
||||
|
||||
let home = await waitForHomePage(20_000);
|
||||
if (!home) await navigateToHome();
|
||||
home = await waitForHomePage(15_000);
|
||||
|
||||
const ok =
|
||||
home || (await textExists('Message OpenHuman')) || (await textExists('Upgrade to Premium'));
|
||||
expect(ok).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -131,6 +131,22 @@ describe('Skills registry flow', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('shows at least one known registry skill name (tool surface)', async () => {
|
||||
await navigateToSkills();
|
||||
await browser.pause(1_500);
|
||||
const hasNamedSkill =
|
||||
(await textExists('Telegram')) || (await textExists('Notion')) || (await textExists('Gmail'));
|
||||
stepLog(`Registry skill name visible: ${hasNamedSkill}`);
|
||||
if (!hasNamedSkill) {
|
||||
const tree = await dumpAccessibilityTree();
|
||||
const log = getRequestLog();
|
||||
stepLog('Named registry skill not found');
|
||||
stepLog('Accessibility tree:', tree.slice(0, 4000));
|
||||
stepLog('Request log:', log);
|
||||
}
|
||||
expect(hasNamedSkill).toBe(true);
|
||||
});
|
||||
|
||||
it('can trigger a skill uninstall action', async () => {
|
||||
clearRequestLog();
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
waitForWebView,
|
||||
waitForWindowVisible,
|
||||
} from '../helpers/element-helpers';
|
||||
import { completeOnboardingIfVisible } from '../helpers/shared-flows';
|
||||
import { clearRequestLog, getRequestLog, startMockServer, stopMockServer } from '../mock-server';
|
||||
|
||||
async function waitForRequest(method, urlFragment, timeout = 15_000) {
|
||||
@@ -34,45 +35,6 @@ async function waitForRequest(method, urlFragment, timeout = 15_000) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function waitForTextToDisappear(text, timeout = 10_000) {
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
if (!(await textExists(text))) return true;
|
||||
await browser.pause(400);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function completeOnboardingIfVisible() {
|
||||
if (await textExists('Skip for now')) {
|
||||
await clickText('Skip for now', 10_000);
|
||||
await waitForTextToDisappear('Skip for now', 8_000);
|
||||
await browser.pause(1500);
|
||||
}
|
||||
|
||||
if (await textExists('Looks Amazing')) {
|
||||
await clickText('Looks Amazing', 10_000);
|
||||
await browser.pause(1500);
|
||||
} else if (await textExists('Bring It On')) {
|
||||
await clickText('Bring It On', 10_000);
|
||||
await browser.pause(1500);
|
||||
}
|
||||
|
||||
if (await textExists('Got it')) {
|
||||
await clickText('Got it', 10_000);
|
||||
await browser.pause(1500);
|
||||
} else if (await textExists('Continue')) {
|
||||
await clickText('Continue', 10_000);
|
||||
await browser.pause(1500);
|
||||
}
|
||||
|
||||
if (await textExists("Let's Go")) {
|
||||
await clickText("Let's Go", 10_000);
|
||||
} else if (await textExists('Finish Setup')) {
|
||||
await clickText('Finish Setup', 10_000);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHome(timeout = 20_000) {
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
@@ -114,7 +76,7 @@ describe('Voice mode integration', () => {
|
||||
const consume = await waitForRequest('POST', '/telegram/login-tokens/');
|
||||
expect(consume).toBeDefined();
|
||||
|
||||
await completeOnboardingIfVisible();
|
||||
await completeOnboardingIfVisible('[VoiceModeE2E]');
|
||||
|
||||
const onHome = await waitForHome(20_000);
|
||||
if (!onHome) {
|
||||
@@ -190,7 +152,7 @@ describe('Voice mode integration', () => {
|
||||
await waitForWindowVisible(25_000);
|
||||
await waitForWebView(15_000);
|
||||
await waitForAppReady(15_000);
|
||||
await completeOnboardingIfVisible();
|
||||
await completeOnboardingIfVisible('[VoiceModeE2E]');
|
||||
await waitForHome(20_000);
|
||||
}
|
||||
|
||||
|
||||
@@ -490,7 +490,7 @@ fn resolve_timeout(archetype: AgentArchetype, config: &OrchestratorConfig) -> Du
|
||||
.archetypes
|
||||
.get(&archetype.to_string())
|
||||
.and_then(|ac| ac.timeout_secs)
|
||||
.unwrap_or(120);
|
||||
.unwrap_or_else(crate::openhuman::tool_timeout::tool_execution_timeout_secs);
|
||||
Duration::from_secs(secs)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Shared types for the multi-agent harness: requests, results, task status.
|
||||
|
||||
use super::archetypes::AgentArchetype;
|
||||
use crate::openhuman::tool_timeout::tool_execution_timeout_duration;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -48,7 +49,7 @@ pub struct SubAgentRequest {
|
||||
}
|
||||
|
||||
fn default_subagent_timeout() -> Duration {
|
||||
Duration::from_secs(120)
|
||||
tool_execution_timeout_duration()
|
||||
}
|
||||
|
||||
fn is_default_timeout(d: &Duration) -> bool {
|
||||
|
||||
@@ -285,12 +285,11 @@ pub(crate) async fn run_tool_call_loop(
|
||||
);
|
||||
|
||||
let result = if let Some(tool) = find_tool(tools_registry, &call.name) {
|
||||
// Execute with a 120-second timeout to prevent hangs.
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(120),
|
||||
tool.execute(call.arguments.clone()),
|
||||
)
|
||||
.await
|
||||
let tool_deadline =
|
||||
crate::openhuman::tool_timeout::tool_execution_timeout_duration();
|
||||
let timeout_secs = crate::openhuman::tool_timeout::tool_execution_timeout_secs();
|
||||
match tokio::time::timeout(tool_deadline, tool.execute(call.arguments.clone()))
|
||||
.await
|
||||
{
|
||||
Ok(Ok(r)) => {
|
||||
if r.success {
|
||||
@@ -323,9 +322,13 @@ pub(crate) async fn run_tool_call_loop(
|
||||
tracing::error!(
|
||||
iteration,
|
||||
tool = call.name.as_str(),
|
||||
"[agent_loop] tool execution timed out after 120s"
|
||||
secs = timeout_secs,
|
||||
"[agent_loop] tool execution timed out"
|
||||
);
|
||||
format!("Error: tool '{}' timed out after 120 seconds", call.name)
|
||||
format!(
|
||||
"Error: tool '{}' timed out after {} seconds",
|
||||
call.name, timeout_secs
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -38,6 +38,7 @@ pub mod service;
|
||||
pub mod skills;
|
||||
pub mod subconscious;
|
||||
pub mod team;
|
||||
pub mod tool_timeout;
|
||||
pub mod tools;
|
||||
pub mod util;
|
||||
pub mod voice;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! Architecture follows the same pattern as `CronScheduler`: a background Tokio
|
||||
//! task with `tokio::select!` for a tick interval + a stop signal via a watch channel.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -222,40 +223,19 @@ impl PingScheduler {
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Network or other error: update published state, keep running
|
||||
if let Some(snap) = registry.get_skill(skill_id) {
|
||||
// We need to update the skill's published_state through the
|
||||
// registry. The SkillState is behind an Arc<RwLock<>>, which
|
||||
// we can reach via the snapshot's backing state. However, the
|
||||
// registry only exposes snapshots (copies). We use an RPC
|
||||
// message to let the skill instance update its own state.
|
||||
//
|
||||
// A simpler approach: directly update published_state via the
|
||||
// SkillState Arc that the registry entry holds. Since
|
||||
// SkillRegistry doesn't expose the Arc directly, we send a
|
||||
// state/set RPC to the skill, which is the same mechanism
|
||||
// the frontend uses.
|
||||
let _ = snap; // used for logging context
|
||||
|
||||
// Send a state update via RPC (skills handle "state/set"
|
||||
// in their reverse-RPC handler, but here we update the
|
||||
// published_state directly through the skill message loop).
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let _ = registry.send_message(
|
||||
// Network or other error: merge into published_state, keep running
|
||||
let mut patch = HashMap::new();
|
||||
patch.insert("connection_status".to_string(), serde_json::json!("error"));
|
||||
patch.insert(
|
||||
"connection_error".to_string(),
|
||||
serde_json::json!(error_message),
|
||||
);
|
||||
if let Err(e) = registry.merge_published_state(skill_id, patch).await {
|
||||
log::warn!(
|
||||
"[ping] Could not merge ping failure into published state for '{}': {}",
|
||||
skill_id,
|
||||
SkillMessage::Rpc {
|
||||
method: "state/set".to_string(),
|
||||
params: serde_json::json!({
|
||||
"partial": {
|
||||
"connection_status": "error",
|
||||
"connection_error": error_message,
|
||||
}
|
||||
}),
|
||||
reply: tx,
|
||||
},
|
||||
e
|
||||
);
|
||||
// Don't block on the reply — fire-and-forget
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), rx).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -879,4 +879,67 @@ impl RuntimeEngine {
|
||||
pub fn skill_data_dir(&self, skill_id: &str) -> PathBuf {
|
||||
self.skills_data_dir.join(skill_id)
|
||||
}
|
||||
|
||||
/// Total file count and byte size under the skill's data directory (recursive).
|
||||
pub fn skill_data_directory_stats(&self, skill_id: &str) -> SkillDataDirectoryStats {
|
||||
let path = self.skill_data_dir(skill_id);
|
||||
let exists = path.exists();
|
||||
let (total_bytes, file_count) = match directory_byte_and_file_count(&path) {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"skill data directory stats failed for '{}': {} — {}",
|
||||
skill_id,
|
||||
path.display(),
|
||||
err
|
||||
);
|
||||
(0, 0)
|
||||
}
|
||||
};
|
||||
SkillDataDirectoryStats {
|
||||
exists,
|
||||
path: path.display().to_string(),
|
||||
total_bytes,
|
||||
file_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Disk usage for a skill's persisted data folder (exposed to the UI for sync summary).
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SkillDataDirectoryStats {
|
||||
pub exists: bool,
|
||||
pub path: String,
|
||||
pub total_bytes: u64,
|
||||
pub file_count: u64,
|
||||
}
|
||||
|
||||
fn directory_byte_and_file_count(path: &std::path::Path) -> std::io::Result<(u64, u64)> {
|
||||
use std::fs;
|
||||
if !path.exists() {
|
||||
return Ok((0, 0));
|
||||
}
|
||||
let mut total_bytes = 0u64;
|
||||
let mut file_count = 0u64;
|
||||
fn walk(
|
||||
path: &std::path::Path,
|
||||
total_bytes: &mut u64,
|
||||
file_count: &mut u64,
|
||||
) -> std::io::Result<()> {
|
||||
let read = fs::read_dir(path)?;
|
||||
for entry in read {
|
||||
let entry = entry?;
|
||||
let meta = entry.metadata()?;
|
||||
let p = entry.path();
|
||||
if meta.is_dir() {
|
||||
walk(&p, total_bytes, file_count)?;
|
||||
} else if meta.is_file() {
|
||||
*total_bytes += meta.len();
|
||||
*file_count += 1;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
walk(path, &mut total_bytes, &mut file_count)?;
|
||||
Ok((total_bytes, file_count))
|
||||
}
|
||||
|
||||
@@ -5,9 +5,14 @@ use std::time::Duration;
|
||||
use parking_lot::RwLock;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::openhuman::memory::MemoryClientRef;
|
||||
use crate::openhuman::skills::quickjs_libs::qjs_ops;
|
||||
use crate::openhuman::skills::types::{SkillMessage, SkillStatus, ToolResult};
|
||||
use crate::openhuman::{
|
||||
memory::MemoryClientRef,
|
||||
skills::{
|
||||
quickjs_libs::qjs_ops,
|
||||
types::{SkillMessage, SkillStatus, ToolResult},
|
||||
},
|
||||
tool_timeout::{tool_execution_timeout_duration, tool_execution_timeout_secs},
|
||||
};
|
||||
|
||||
use super::js_handlers::{
|
||||
call_lifecycle, handle_cron_trigger, handle_js_call, handle_js_void_call, handle_server_event,
|
||||
@@ -233,7 +238,11 @@ pub(crate) async fn run_event_loop(
|
||||
);
|
||||
}
|
||||
if tokio::time::Instant::now() >= ptc.deadline {
|
||||
log::error!("[skill:{}] Async tool call timed out after 120s", skill_id);
|
||||
log::error!(
|
||||
"[skill:{}] Async tool call timed out after {}s",
|
||||
skill_id,
|
||||
tool_execution_timeout_secs()
|
||||
);
|
||||
// Dump JS error state for debugging
|
||||
let error_info = ctx
|
||||
.with(|js_ctx| {
|
||||
@@ -357,7 +366,7 @@ async fn handle_message(
|
||||
);
|
||||
*pending_tool = Some(PendingToolCall {
|
||||
reply,
|
||||
deadline: tokio::time::Instant::now() + Duration::from_secs(120),
|
||||
deadline: tokio::time::Instant::now() + tool_execution_timeout_duration(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -37,6 +37,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
skills_schema("data_read"),
|
||||
skills_schema("data_write"),
|
||||
skills_schema("data_dir"),
|
||||
skills_schema("data_stats"),
|
||||
skills_schema("enable"),
|
||||
skills_schema("disable"),
|
||||
skills_schema("is_enabled"),
|
||||
@@ -142,6 +143,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
schema: skills_schema("data_dir"),
|
||||
handler: handle_skills_data_dir,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: skills_schema("data_stats"),
|
||||
handler: handle_skills_data_stats,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: skills_schema("enable"),
|
||||
handler: handle_skills_enable,
|
||||
@@ -525,6 +530,18 @@ fn skills_schema(function: &str) -> ControllerSchema {
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"data_stats" => ControllerSchema {
|
||||
namespace: "skills",
|
||||
function: "data_stats",
|
||||
description: "Recursive file count and byte size for a skill's data directory.",
|
||||
inputs: vec![skill_id_input("The skill ID.")],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "exists, path, total_bytes, file_count.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"enable" => ControllerSchema {
|
||||
namespace: "skills",
|
||||
function: "enable",
|
||||
@@ -891,6 +908,16 @@ fn handle_skills_data_dir(params: Map<String, Value>) -> ControllerFuture {
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_skills_data_stats(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let p: SkillIdParams =
|
||||
serde_json::from_value(Value::Object(params)).map_err(|e| e.to_string())?;
|
||||
let engine = require_engine()?;
|
||||
let stats = engine.skill_data_directory_stats(&p.skill_id);
|
||||
serde_json::to_value(&stats).map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_skills_enable(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let p: SkillIdParams =
|
||||
|
||||
@@ -8,7 +8,7 @@ use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
use crate::openhuman::skills::qjs_skill_instance::SkillState;
|
||||
use crate::openhuman::skills::types::{
|
||||
SkillConfig, SkillMessage, SkillSnapshot, SkillStatus, ToolCallOrigin, ToolDefinition,
|
||||
self, SkillConfig, SkillMessage, SkillSnapshot, SkillStatus, ToolCallOrigin, ToolDefinition,
|
||||
ToolResult,
|
||||
};
|
||||
|
||||
@@ -307,6 +307,36 @@ impl SkillRegistry {
|
||||
self.skills.read().contains_key(skill_id)
|
||||
}
|
||||
|
||||
/// Merge `patch` into a running skill's `published_state` (e.g. ping scheduler health).
|
||||
pub async fn merge_published_state(
|
||||
&self,
|
||||
skill_id: &str,
|
||||
patch: HashMap<String, serde_json::Value>,
|
||||
) -> Result<(), String> {
|
||||
{
|
||||
let skills = self.skills.read();
|
||||
let entry = skills
|
||||
.get(skill_id)
|
||||
.ok_or_else(|| format!("Skill '{}' not found", skill_id))?;
|
||||
let mut state = entry.state.write();
|
||||
if state.status != SkillStatus::Running {
|
||||
return Err(format!(
|
||||
"Skill '{}' is not running (status: {:?})",
|
||||
skill_id, state.status
|
||||
));
|
||||
}
|
||||
for (k, v) in patch {
|
||||
state.published_state.insert(k, v);
|
||||
}
|
||||
}
|
||||
self.broadcast_event(
|
||||
types::events::SKILL_STATE_CHANGED,
|
||||
serde_json::json!({ "skill_id": skill_id }),
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a message to a specific skill's message loop.
|
||||
/// Returns an error if the skill is not registered or the channel is full.
|
||||
pub fn send_message(&self, skill_id: &str, msg: SkillMessage) -> Result<(), String> {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Wall-clock timeouts for tool execution (skills runtime + agent loop).
|
||||
//!
|
||||
//! Override with the `OPENHUMAN_TOOL_TIMEOUT_SECS` environment variable (1–3600; default 120).
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
const DEFAULT_SECS: u64 = 120;
|
||||
const MAX_SECS: u64 = 3600;
|
||||
|
||||
fn resolved_secs() -> u64 {
|
||||
static SECS: OnceLock<u64> = OnceLock::new();
|
||||
*SECS.get_or_init(|| {
|
||||
std::env::var("OPENHUMAN_TOOL_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.filter(|&n| (1..=MAX_SECS).contains(&n))
|
||||
.unwrap_or(DEFAULT_SECS)
|
||||
})
|
||||
}
|
||||
|
||||
/// Seconds — used for logging and matching frontend timeouts.
|
||||
pub fn tool_execution_timeout_secs() -> u64 {
|
||||
resolved_secs()
|
||||
}
|
||||
|
||||
pub fn tool_execution_timeout_duration() -> Duration {
|
||||
Duration::from_secs(resolved_secs())
|
||||
}
|
||||
@@ -3,15 +3,13 @@ use crate::openhuman::config::DelegateAgentConfig;
|
||||
use crate::openhuman::providers::{self, Provider};
|
||||
use crate::openhuman::security::policy::ToolOperation;
|
||||
use crate::openhuman::security::SecurityPolicy;
|
||||
use crate::openhuman::tool_timeout::tool_execution_timeout_secs;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Default timeout for sub-agent provider calls.
|
||||
const DELEGATE_TIMEOUT_SECS: u64 = 120;
|
||||
|
||||
/// Tool that delegates a subtask to a named agent with a different
|
||||
/// provider/model configuration. Enables multi-agent workflows where
|
||||
/// a primary agent can hand off specialized work (research, coding,
|
||||
@@ -250,9 +248,10 @@ impl Tool for DelegateTool {
|
||||
|
||||
let temperature = agent_config.temperature.unwrap_or(0.7);
|
||||
|
||||
let delegate_timeout_secs = tool_execution_timeout_secs();
|
||||
// Wrap the provider call in a timeout to prevent indefinite blocking
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(DELEGATE_TIMEOUT_SECS),
|
||||
Duration::from_secs(delegate_timeout_secs),
|
||||
provider.chat_with_system(
|
||||
agent_config.system_prompt.as_deref(),
|
||||
&full_prompt,
|
||||
@@ -269,7 +268,7 @@ impl Tool for DelegateTool {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!(
|
||||
"Agent '{agent_name}' timed out after {DELEGATE_TIMEOUT_SECS}s"
|
||||
"Agent '{agent_name}' timed out after {delegate_timeout_secs}s"
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1155,6 +1155,19 @@ async fn json_rpc_skills_runtime_start_tools_call_stop() {
|
||||
Some("e2e-runtime")
|
||||
);
|
||||
|
||||
let data_stats = post_json_rpc(
|
||||
&rpc_base,
|
||||
211,
|
||||
"openhuman.skills_data_stats",
|
||||
json!({"skill_id": "e2e-runtime"}),
|
||||
)
|
||||
.await;
|
||||
let ds = assert_no_jsonrpc_error(&data_stats, "skills_data_stats");
|
||||
assert_eq!(ds.get("exists"), Some(&json!(true)));
|
||||
assert!(ds.get("path").and_then(Value::as_str).is_some());
|
||||
assert!(ds.get("total_bytes").and_then(Value::as_u64).is_some());
|
||||
assert!(ds.get("file_count").and_then(Value::as_u64).is_some());
|
||||
|
||||
// 3. List tools
|
||||
let tools = post_json_rpc(
|
||||
&rpc_base,
|
||||
|
||||
@@ -0,0 +1,941 @@
|
||||
//! Gmail skill end-to-end test.
|
||||
//!
|
||||
//! Exercises the full Gmail skill lifecycle through the RuntimeEngine:
|
||||
//! discover → start → OAuth complete → profile → list emails →
|
||||
//! search → get email → labels → mark email → sync → oauth/revoked → stop
|
||||
//!
|
||||
//! Two modes:
|
||||
//!
|
||||
//! 1. **Lifecycle-only** (no env vars required):
|
||||
//! Verifies that the skill starts, registers tools, responds to setup
|
||||
//! and ping, then stops cleanly. OAuth-dependent tools are exercised
|
||||
//! structurally (called, errors tolerated) so the event loop is
|
||||
//! validated without real credentials.
|
||||
//!
|
||||
//! 2. **Live** (`RUN_LIVE_GMAIL=1` + credentials):
|
||||
//! Uses a real `oauth_credential.json` in SKILLS_DATA_DIR and an
|
||||
//! OAuth proxy routed through the backend. All Gmail API tools are
|
||||
//! called against real data.
|
||||
//!
|
||||
//! Required env vars for live mode:
|
||||
//! RUN_LIVE_GMAIL=1
|
||||
//! BACKEND_URL — e.g. https://staging-api.alphahuman.xyz
|
||||
//! JWT_TOKEN — bearer token for the OAuth proxy
|
||||
//! CREDENTIAL_ID — ID stored in oauth_credential.json
|
||||
//! SKILLS_DATA_DIR — path to the skills_data root (contains gmail/ subdir)
|
||||
//!
|
||||
//! Optional:
|
||||
//! SKILL_DEBUG_DIR — override skills source directory
|
||||
//! SKILLS_LOCAL_DIR — shared env var used by the runtime
|
||||
//! GMAIL_TEST_EMAIL_ID — a real message ID to fetch with get-email
|
||||
//! SKILL_DEBUG_VERBOSE — set to "1" for extra output
|
||||
//!
|
||||
//! Run lifecycle-only:
|
||||
//! cargo test --test skills_gmail_e2e -- --nocapture
|
||||
//!
|
||||
//! Run live:
|
||||
//! BACKEND_URL=https://staging-api.alphahuman.xyz \
|
||||
//! JWT_TOKEN=<jwt> \
|
||||
//! CREDENTIAL_ID=<id> \
|
||||
//! SKILLS_DATA_DIR=~/.openhuman/skills_data \
|
||||
//! RUN_LIVE_GMAIL=1 \
|
||||
//! cargo test --test skills_gmail_e2e -- --nocapture
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use tempfile::tempdir;
|
||||
|
||||
use openhuman_core::openhuman::skills::qjs_engine::{set_global_engine, RuntimeEngine};
|
||||
use openhuman_core::openhuman::skills::types::SkillStatus;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn is_verbose() -> bool {
|
||||
std::env::var("SKILL_DEBUG_VERBOSE")
|
||||
.map(|v| v == "1" || v == "true")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_live() -> bool {
|
||||
std::env::var("RUN_LIVE_GMAIL")
|
||||
.map(|v| v == "1" || v == "true")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn banner(label: &str) {
|
||||
let sep = "=".repeat(60);
|
||||
eprintln!("\n{sep}");
|
||||
eprintln!(" {label}");
|
||||
eprintln!("{sep}");
|
||||
}
|
||||
|
||||
fn step(label: &str) {
|
||||
eprintln!("\n--- {label} ---");
|
||||
}
|
||||
|
||||
fn ok(msg: &str) {
|
||||
eprintln!(" ✓ {msg}");
|
||||
}
|
||||
|
||||
fn warn(msg: &str) {
|
||||
eprintln!(" ⚠ {msg}");
|
||||
}
|
||||
|
||||
fn fail(msg: &str) {
|
||||
eprintln!(" ✗ {msg}");
|
||||
}
|
||||
|
||||
fn info(msg: &str) {
|
||||
eprintln!(" · {msg}");
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> &str {
|
||||
if s.len() <= max {
|
||||
return s;
|
||||
}
|
||||
let mut end = max;
|
||||
while end > 0 && !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
&s[..end]
|
||||
}
|
||||
|
||||
/// Find the skills source directory.
|
||||
///
|
||||
/// Priority:
|
||||
/// 1. SKILL_DEBUG_DIR env var
|
||||
/// 2. SKILLS_LOCAL_DIR env var
|
||||
/// 3. ../openhuman-skills/skills (sibling repo)
|
||||
/// 4. openhuman-skills/skills (subdir)
|
||||
/// 5. Broader parent-workspace search
|
||||
fn try_find_skills_dir() -> Option<PathBuf> {
|
||||
if let Ok(dir) = std::env::var("SKILL_DEBUG_DIR") {
|
||||
let p = PathBuf::from(&dir);
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
eprintln!("SKILL_DEBUG_DIR={dir} does not exist");
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Ok(dir) = std::env::var("SKILLS_LOCAL_DIR") {
|
||||
let p = PathBuf::from(&dir);
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
eprintln!("SKILLS_LOCAL_DIR={dir} does not exist");
|
||||
}
|
||||
|
||||
let cwd = std::env::current_dir().expect("cwd");
|
||||
|
||||
for candidate in &[
|
||||
cwd.join("../openhuman-skills/skills"),
|
||||
cwd.join("openhuman-skills/skills"),
|
||||
cwd.join("../alphahuman/skills/skills"),
|
||||
] {
|
||||
if candidate.exists() {
|
||||
return Some(candidate.canonicalize().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(parent) = cwd.parent() {
|
||||
for entry in std::fs::read_dir(parent).into_iter().flatten().flatten() {
|
||||
let c = entry.path().join("skills/skills");
|
||||
if c.exists() && c.join("gmail/manifest.json").exists() {
|
||||
return Some(c.canonicalize().unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
macro_rules! require_skills_dir {
|
||||
() => {
|
||||
match try_find_skills_dir() {
|
||||
Some(dir) => dir,
|
||||
None => {
|
||||
eprintln!("SKIPPED: no skills directory available (set SKILL_DEBUG_DIR)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Print a tool call result without logging raw credential content.
|
||||
fn print_tool_result(
|
||||
result: &openhuman_core::openhuman::skills::types::ToolResult,
|
||||
max_bytes: usize,
|
||||
) {
|
||||
use openhuman_core::openhuman::skills::types::ToolContent;
|
||||
eprintln!(" is_error: {}", result.is_error);
|
||||
for content in &result.content {
|
||||
match content {
|
||||
ToolContent::Text { text } => {
|
||||
eprintln!(" text: {}", truncate(text, max_bytes));
|
||||
}
|
||||
ToolContent::Json { data } => {
|
||||
let s = data.to_string();
|
||||
eprintln!(" json: {}", truncate(&s, max_bytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Call a tool with a timeout; log the result; return whether it succeeded.
|
||||
async fn call_tool_logged(
|
||||
engine: &RuntimeEngine,
|
||||
skill_id: &str,
|
||||
tool: &str,
|
||||
args: Value,
|
||||
timeout_secs: u64,
|
||||
) -> bool {
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(timeout_secs),
|
||||
engine.call_tool(skill_id, tool, args),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(r)) => {
|
||||
let success = !r.is_error;
|
||||
if success {
|
||||
ok(&format!("{tool} succeeded"));
|
||||
} else {
|
||||
warn(&format!("{tool} returned is_error=true"));
|
||||
}
|
||||
if is_verbose() {
|
||||
print_tool_result(&r, 800);
|
||||
} else {
|
||||
print_tool_result(&r, 300);
|
||||
}
|
||||
success
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
fail(&format!("{tool} error: {e}"));
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
fail(&format!("{tool} TIMED OUT ({timeout_secs}s)"));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a RuntimeEngine backed by the given data directory.
|
||||
async fn create_engine(skills_dir: &Path, data_dir: &Path) -> Arc<RuntimeEngine> {
|
||||
let engine =
|
||||
RuntimeEngine::new(data_dir.to_path_buf()).expect("RuntimeEngine::new should succeed");
|
||||
let engine = Arc::new(engine);
|
||||
engine.set_skills_source_dir(skills_dir.to_path_buf());
|
||||
set_global_engine(engine.clone());
|
||||
engine
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Lifecycle-only test: start → tools → setup → ping → oauth stubs → stop.
|
||||
///
|
||||
/// Does not require credentials. OAuth-dependent tool calls are expected to
|
||||
/// fail or return errors — the test validates the event-loop plumbing and
|
||||
/// tool registration, not real Gmail API behavior.
|
||||
#[tokio::test]
|
||||
async fn gmail_lifecycle_no_credentials() {
|
||||
let _ = env_logger::builder()
|
||||
.filter_level(if is_verbose() {
|
||||
log::LevelFilter::Debug
|
||||
} else {
|
||||
log::LevelFilter::Info
|
||||
})
|
||||
.is_test(true)
|
||||
.try_init();
|
||||
|
||||
let skills_dir = require_skills_dir!();
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let data_dir = tmp.path().join("skills_data");
|
||||
std::fs::create_dir_all(&data_dir).expect("create data_dir");
|
||||
|
||||
banner("Gmail Skill — Lifecycle (no credentials)");
|
||||
info(&format!("Skills dir: {}", skills_dir.display()));
|
||||
info(&format!("Data dir: {}", data_dir.display()));
|
||||
|
||||
let engine = create_engine(&skills_dir, &data_dir).await;
|
||||
|
||||
// ── 1. Start ──
|
||||
step("START SKILL 'gmail'");
|
||||
let snap = engine
|
||||
.start_skill("gmail")
|
||||
.await
|
||||
.expect("start gmail skill");
|
||||
assert_eq!(
|
||||
snap.status,
|
||||
SkillStatus::Running,
|
||||
"expected Running after start"
|
||||
);
|
||||
ok(&format!("Status: {:?}", snap.status));
|
||||
info(&format!("Tools registered: {}", snap.tools.len()));
|
||||
for tool in &snap.tools {
|
||||
info(&format!(" - {}: {}", tool.name, tool.description));
|
||||
}
|
||||
|
||||
let expected_tools = [
|
||||
"get-email",
|
||||
"get-emails",
|
||||
"get-labels",
|
||||
"get-profile",
|
||||
"mark-email",
|
||||
"search-emails",
|
||||
"send-email",
|
||||
];
|
||||
for name in &expected_tools {
|
||||
let found = snap.tools.iter().any(|t| t.name == *name);
|
||||
if found {
|
||||
ok(&format!("tool '{name}' registered"));
|
||||
} else {
|
||||
warn(&format!(
|
||||
"tool '{name}' not found — may be expected if skill filters tools before OAuth"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. tools/list via RPC ──
|
||||
step("TOOLS/LIST (via RPC)");
|
||||
match engine.rpc("gmail", "tools/list", json!({})).await {
|
||||
Ok(val) => {
|
||||
let tools = val.get("tools").and_then(|t| t.as_array());
|
||||
if let Some(tools) = tools {
|
||||
ok(&format!("{} tool(s) via tools/list RPC", tools.len()));
|
||||
} else {
|
||||
ok(&format!("tools/list returned: {val}"));
|
||||
}
|
||||
}
|
||||
Err(e) => fail(&format!("tools/list RPC failed: {e}")),
|
||||
}
|
||||
|
||||
// ── 3. setup/start ──
|
||||
step("SETUP/START");
|
||||
let setup = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
engine.rpc("gmail", "setup/start", json!({})),
|
||||
)
|
||||
.await;
|
||||
match setup {
|
||||
Ok(Ok(val)) => ok(&format!("setup/start returned: {val}")),
|
||||
Ok(Err(e)) => info(&format!("setup/start error: {e} (expected — OAuth skill)")),
|
||||
Err(_) => fail("setup/start TIMED OUT (10s)"),
|
||||
}
|
||||
|
||||
// ── 4. Ping ──
|
||||
step("PING");
|
||||
let ping = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
engine.rpc("gmail", "skill/ping", json!({})),
|
||||
)
|
||||
.await;
|
||||
match ping {
|
||||
Ok(Ok(val)) => ok(&format!("ping returned: {val}")),
|
||||
Ok(Err(e)) => info(&format!("ping: {e} (may be expected without credentials)")),
|
||||
Err(_) => fail("ping TIMED OUT (10s)"),
|
||||
}
|
||||
|
||||
// ── 5. Simulate oauth/complete with a fake credential ──
|
||||
step("SIMULATE OAUTH/COMPLETE (fake credential)");
|
||||
let fake_cred = json!({
|
||||
"credentialId": "fake-cred-lifecycle-test",
|
||||
"provider": "gmail",
|
||||
"grantedScopes": [
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
"https://www.googleapis.com/auth/gmail.send"
|
||||
]
|
||||
});
|
||||
let oauth_complete = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
engine.rpc("gmail", "oauth/complete", fake_cred.clone()),
|
||||
)
|
||||
.await;
|
||||
match oauth_complete {
|
||||
Ok(Ok(val)) => ok(&format!("oauth/complete acknowledged: {val}")),
|
||||
Ok(Err(e)) => info(&format!("oauth/complete: {e}")),
|
||||
Err(_) => fail("oauth/complete TIMED OUT (10s)"),
|
||||
}
|
||||
|
||||
// Credential file should now exist on disk
|
||||
let cred_path = data_dir.join("gmail").join("oauth_credential.json");
|
||||
if cred_path.exists() {
|
||||
ok("oauth_credential.json persisted to disk");
|
||||
} else {
|
||||
warn("oauth_credential.json was NOT written to disk");
|
||||
}
|
||||
|
||||
// Verify snapshot reflects credential in published state
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
if let Some(snap) = engine.get_skill_state("gmail") {
|
||||
let has_cred = snap.state.get("__oauth_credential").is_some();
|
||||
if has_cred {
|
||||
ok("__oauth_credential present in published state");
|
||||
} else {
|
||||
warn("__oauth_credential not yet in published state (event loop may not have synced)");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. Unauthenticated tool calls — expect errors, not panics ──
|
||||
step("TOOL CALLS (expect auth errors without real credentials)");
|
||||
|
||||
info("get-profile");
|
||||
call_tool_logged(&engine, "gmail", "get-profile", json!({}), 20).await;
|
||||
|
||||
info("get-emails");
|
||||
call_tool_logged(
|
||||
&engine,
|
||||
"gmail",
|
||||
"get-emails",
|
||||
json!({ "maxResults": 3 }),
|
||||
20,
|
||||
)
|
||||
.await;
|
||||
|
||||
info("search-emails");
|
||||
call_tool_logged(
|
||||
&engine,
|
||||
"gmail",
|
||||
"search-emails",
|
||||
json!({ "query": "is:unread", "maxResults": 3 }),
|
||||
20,
|
||||
)
|
||||
.await;
|
||||
|
||||
info("get-labels");
|
||||
call_tool_logged(&engine, "gmail", "get-labels", json!({}), 20).await;
|
||||
|
||||
// ── 7. skill/sync ──
|
||||
step("SYNC");
|
||||
let sync = tokio::time::timeout(
|
||||
Duration::from_secs(20),
|
||||
engine.rpc("gmail", "skill/sync", json!({})),
|
||||
)
|
||||
.await;
|
||||
match sync {
|
||||
Ok(Ok(val)) => ok(&format!("skill/sync: {val}")),
|
||||
Ok(Err(e)) => info(&format!("skill/sync: {e} (expected without credentials)")),
|
||||
Err(_) => fail("skill/sync TIMED OUT (20s)"),
|
||||
}
|
||||
|
||||
// ── 8. oauth/revoked ──
|
||||
step("OAUTH/REVOKED");
|
||||
let revoked = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
engine.rpc(
|
||||
"gmail",
|
||||
"oauth/revoked",
|
||||
json!({ "integrationId": "fake-cred-lifecycle-test" }),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
match revoked {
|
||||
Ok(Ok(val)) => ok(&format!("oauth/revoked: {val}")),
|
||||
Ok(Err(e)) => info(&format!("oauth/revoked: {e}")),
|
||||
Err(_) => fail("oauth/revoked TIMED OUT (10s)"),
|
||||
}
|
||||
|
||||
// Credential file should be gone
|
||||
if !cred_path.exists() {
|
||||
ok("oauth_credential.json deleted after oauth/revoked");
|
||||
} else {
|
||||
warn("oauth_credential.json still exists after oauth/revoked");
|
||||
}
|
||||
|
||||
// ── 9. Stop ──
|
||||
step("STOP");
|
||||
let stop = tokio::time::timeout(Duration::from_secs(10), engine.stop_skill("gmail")).await;
|
||||
match stop {
|
||||
Ok(Ok(())) => ok("Skill stopped cleanly"),
|
||||
Ok(Err(e)) => fail(&format!("Stop error: {e}")),
|
||||
Err(_) => fail("Stop TIMED OUT (10s)"),
|
||||
}
|
||||
|
||||
// Verify registry state post-stop
|
||||
let post_stop = engine.get_skill_state("gmail");
|
||||
match post_stop {
|
||||
Some(snap) => info(&format!("Post-stop status: {:?}", snap.status)),
|
||||
None => ok("Skill unregistered from registry after stop"),
|
||||
}
|
||||
|
||||
banner("LIFECYCLE TEST COMPLETE");
|
||||
}
|
||||
|
||||
/// Live integration test: exercises all Gmail tools against the real API.
|
||||
///
|
||||
/// Requires RUN_LIVE_GMAIL=1 and all env vars listed at the top of the file.
|
||||
#[tokio::test]
|
||||
async fn gmail_live_with_real_credentials() {
|
||||
if !is_live() {
|
||||
eprintln!("SKIPPED: set RUN_LIVE_GMAIL=1 to run this live integration test");
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = env_logger::builder()
|
||||
.filter_level(if is_verbose() {
|
||||
log::LevelFilter::Debug
|
||||
} else {
|
||||
log::LevelFilter::Info
|
||||
})
|
||||
.is_test(true)
|
||||
.try_init();
|
||||
|
||||
let backend_url =
|
||||
std::env::var("BACKEND_URL").expect("BACKEND_URL must be set for live Gmail test");
|
||||
let jwt_token = std::env::var("JWT_TOKEN").expect("JWT_TOKEN must be set for live Gmail test");
|
||||
let credential_id =
|
||||
std::env::var("CREDENTIAL_ID").expect("CREDENTIAL_ID must be set for live Gmail test");
|
||||
let skills_data_dir = PathBuf::from(
|
||||
std::env::var("SKILLS_DATA_DIR").expect("SKILLS_DATA_DIR must be set for live Gmail test"),
|
||||
);
|
||||
let skills_dir = require_skills_dir!();
|
||||
|
||||
// Optional: specific message ID to fetch
|
||||
let test_email_id = std::env::var("GMAIL_TEST_EMAIL_ID").ok();
|
||||
|
||||
banner("Gmail Skill — Live Integration Test");
|
||||
info(&format!("Backend: {backend_url}"));
|
||||
info(&format!(
|
||||
"JWT: <redacted, {} bytes>",
|
||||
jwt_token.len()
|
||||
));
|
||||
info(&format!("Credential ID: {credential_id}"));
|
||||
info(&format!("Skills dir: {}", skills_dir.display()));
|
||||
info(&format!("Data dir: {}", skills_data_dir.display()));
|
||||
if let Some(ref id) = test_email_id {
|
||||
info(&format!("Test email ID: {id}"));
|
||||
}
|
||||
|
||||
// Verify oauth_credential.json exists (don't log contents)
|
||||
let cred_path = skills_data_dir.join("gmail").join("oauth_credential.json");
|
||||
if cred_path.exists() {
|
||||
let size = std::fs::metadata(&cred_path).map(|m| m.len()).unwrap_or(0);
|
||||
ok(&format!("oauth_credential.json present ({size} bytes)"));
|
||||
} else {
|
||||
warn(&format!(
|
||||
"oauth_credential.json NOT found at {} — tools will fail without OAuth",
|
||||
cred_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
// ── Step 1: Backend health ──
|
||||
step("Step 1: Backend Health");
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
match client
|
||||
.get(format!("{backend_url}/settings"))
|
||||
.header("Authorization", format!("Bearer {jwt_token}"))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
if status.is_success() {
|
||||
ok(&format!("Backend reachable (HTTP {status})"));
|
||||
if is_verbose() {
|
||||
info(&format!(" Body: {}...", truncate(&body, 300)));
|
||||
}
|
||||
} else {
|
||||
warn(&format!(
|
||||
"Backend returned HTTP {status} — OAuth proxy may be unavailable"
|
||||
));
|
||||
info(&format!(" Body: {}...", truncate(&body, 200)));
|
||||
}
|
||||
}
|
||||
Err(e) => warn(&format!("Backend unreachable: {e} — continuing anyway")),
|
||||
}
|
||||
|
||||
// ── Step 2: OAuth proxy smoke test ──
|
||||
step("Step 2: OAuth Proxy Check (Gmail API via proxy)");
|
||||
let proxy_url = format!("{backend_url}/proxy/by-id/{credential_id}/gmail/v1/users/me/profile");
|
||||
info(&format!("GET {proxy_url}"));
|
||||
|
||||
match client
|
||||
.get(&proxy_url)
|
||||
.header("Authorization", format!("Bearer {jwt_token}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
if status.is_success() {
|
||||
ok(&format!("Gmail API accessible via proxy (HTTP {status})"));
|
||||
if is_verbose() {
|
||||
info(&format!(" Profile: {}...", truncate(&body, 300)));
|
||||
}
|
||||
} else {
|
||||
warn(&format!(
|
||||
"Proxy returned HTTP {status}: {}...",
|
||||
truncate(&body, 200)
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(e) => warn(&format!("Proxy request failed: {e}")),
|
||||
}
|
||||
|
||||
// ── Step 3: Start skill ──
|
||||
step("Step 3: Start Gmail Skill (real data dir)");
|
||||
let engine = RuntimeEngine::new(skills_data_dir.clone()).expect("engine");
|
||||
let engine = Arc::new(engine);
|
||||
engine.set_skills_source_dir(skills_dir.clone());
|
||||
set_global_engine(engine.clone());
|
||||
|
||||
let snap = engine
|
||||
.start_skill("gmail")
|
||||
.await
|
||||
.expect("start gmail skill");
|
||||
assert_eq!(
|
||||
snap.status,
|
||||
SkillStatus::Running,
|
||||
"expected Running after start"
|
||||
);
|
||||
ok(&format!("Status: {:?}", snap.status));
|
||||
info(&format!("Tools: {}", snap.tools.len()));
|
||||
|
||||
// Print state keys relevant to connection status
|
||||
for (k, v) in &snap.state {
|
||||
if k.contains("status")
|
||||
|| k.contains("error")
|
||||
|| k.contains("auth")
|
||||
|| k.contains("email")
|
||||
|| k == "syncEnabled"
|
||||
|| k == "is_initialized"
|
||||
{
|
||||
info(&format!(" {k} = {}", truncate(&v.to_string(), 120)));
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm OAuth credential restored into JS state
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
if let Some(snap) = engine.get_skill_state("gmail") {
|
||||
let has_cred = snap.state.get("__oauth_credential").is_some();
|
||||
if has_cred {
|
||||
ok("OAuth credential restored into published state");
|
||||
} else {
|
||||
warn(
|
||||
"__oauth_credential not in published state — OAuth may not have been restored yet",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 4: get-profile ──
|
||||
step("Step 4: get-profile");
|
||||
call_tool_logged(&engine, "gmail", "get-profile", json!({}), 30).await;
|
||||
|
||||
// ── Step 5: get-emails (inbox, recent) ──
|
||||
step("Step 5: get-emails (inbox, last 5)");
|
||||
let emails_result = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
engine.call_tool(
|
||||
"gmail",
|
||||
"get-emails",
|
||||
json!({ "maxResults": 5, "labelIds": ["INBOX"] }),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Capture a message ID for later steps
|
||||
let mut sample_message_id: Option<String> = test_email_id.clone();
|
||||
match &emails_result {
|
||||
Ok(Ok(r)) => {
|
||||
ok(&format!("get-emails succeeded (is_error={})", r.is_error));
|
||||
if is_verbose() {
|
||||
print_tool_result(r, 600);
|
||||
} else {
|
||||
print_tool_result(r, 300);
|
||||
}
|
||||
// Try to extract a message ID from the result for subsequent steps
|
||||
if sample_message_id.is_none() {
|
||||
for content in &r.content {
|
||||
use openhuman_core::openhuman::skills::types::ToolContent;
|
||||
let text = match content {
|
||||
ToolContent::Text { text } => text.clone(),
|
||||
ToolContent::Json { data } => data.to_string(),
|
||||
};
|
||||
if let Ok(parsed) = serde_json::from_str::<Value>(&text) {
|
||||
// Look for a message ID in the response
|
||||
let id = parsed
|
||||
.get("messages")
|
||||
.and_then(|m| m.as_array())
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|msg| msg.get("id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.map(String::from);
|
||||
if let Some(id) = id {
|
||||
info(&format!("Captured sample message ID: {id}"));
|
||||
sample_message_id = Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => fail(&format!("get-emails error: {e}")),
|
||||
Err(_) => fail("get-emails TIMED OUT (30s)"),
|
||||
}
|
||||
|
||||
// ── Step 6: search-emails ──
|
||||
step("Step 6: search-emails (is:unread)");
|
||||
call_tool_logged(
|
||||
&engine,
|
||||
"gmail",
|
||||
"search-emails",
|
||||
json!({ "query": "is:unread", "maxResults": 5 }),
|
||||
30,
|
||||
)
|
||||
.await;
|
||||
|
||||
// ── Step 7: get-email (single message) ──
|
||||
step("Step 7: get-email (single message)");
|
||||
if let Some(ref msg_id) = sample_message_id {
|
||||
info(&format!("Using message ID: {msg_id}"));
|
||||
call_tool_logged(&engine, "gmail", "get-email", json!({ "id": msg_id }), 30).await;
|
||||
} else {
|
||||
warn("No message ID available — skipping get-email (set GMAIL_TEST_EMAIL_ID or run get-emails first)");
|
||||
}
|
||||
|
||||
// ── Step 8: get-labels ──
|
||||
step("Step 8: get-labels");
|
||||
call_tool_logged(&engine, "gmail", "get-labels", json!({}), 20).await;
|
||||
|
||||
// ── Step 9: mark-email (read, non-destructive) ──
|
||||
step("Step 9: mark-email (mark as read)");
|
||||
if let Some(ref msg_id) = sample_message_id {
|
||||
info(&format!("Marking message {msg_id} as read"));
|
||||
call_tool_logged(
|
||||
&engine,
|
||||
"gmail",
|
||||
"mark-email",
|
||||
json!({
|
||||
"id": msg_id,
|
||||
"action": "markAsRead"
|
||||
}),
|
||||
20,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
warn("No message ID available — skipping mark-email");
|
||||
}
|
||||
|
||||
// ── Step 10: send-email (dry-run check — only in verbose/explicit mode) ──
|
||||
// We deliberately skip actually sending unless an explicit recipient is provided,
|
||||
// to avoid unexpected side effects in shared test environments.
|
||||
step("Step 10: send-email (skipped — would cause real side effects)");
|
||||
info("send-email requires an explicit recipient. Not called in automated tests.");
|
||||
info("To test manually: set GMAIL_SEND_TO=<addr> and invoke send-email with engine.call_tool");
|
||||
|
||||
// ── Step 11: skill/sync ──
|
||||
step("Step 11: Sync (skill/sync → onSync)");
|
||||
info("Calling skill/sync to trigger onSync() and memory persistence...");
|
||||
let sync = tokio::time::timeout(
|
||||
Duration::from_secs(60),
|
||||
engine.rpc("gmail", "skill/sync", json!({})),
|
||||
)
|
||||
.await;
|
||||
match sync {
|
||||
Ok(Ok(val)) => ok(&format!("skill/sync: {val}")),
|
||||
Ok(Err(e)) => info(&format!(
|
||||
"skill/sync: {e} (expected if no onSync handler or no data)"
|
||||
)),
|
||||
Err(_) => fail("skill/sync TIMED OUT (60s)"),
|
||||
}
|
||||
|
||||
// Wait for background memory persistence
|
||||
info("Waiting 3s for async memory persistence...");
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
|
||||
// ── Step 12: memory verification ──
|
||||
step("Step 12: Memory Verification");
|
||||
match openhuman_core::openhuman::memory::MemoryClient::new_local() {
|
||||
Ok(memory_client) => {
|
||||
let namespace = "skill-gmail";
|
||||
match memory_client.list_documents(Some(namespace)).await {
|
||||
Ok(docs) => {
|
||||
let doc_array = docs
|
||||
.get("documents")
|
||||
.and_then(|d| d.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
info(&format!("Documents in '{namespace}': {}", doc_array.len()));
|
||||
for doc in &doc_array {
|
||||
let title = doc
|
||||
.get("title")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("(no title)");
|
||||
let len = doc
|
||||
.get("content")
|
||||
.and_then(|c| c.as_str())
|
||||
.map(|c| c.len())
|
||||
.unwrap_or(0);
|
||||
info(&format!(" - {title} ({len} bytes)"));
|
||||
}
|
||||
if !doc_array.is_empty() {
|
||||
ok("Memory documents created after sync");
|
||||
} else {
|
||||
warn("No memory documents found after sync");
|
||||
}
|
||||
}
|
||||
Err(e) => warn(&format!("list_documents failed: {e}")),
|
||||
}
|
||||
}
|
||||
Err(e) => warn(&format!("Could not create MemoryClient: {e}")),
|
||||
}
|
||||
|
||||
// ── Step 13: Ping (health check after API calls) ──
|
||||
step("Step 13: Ping (post-sync health check)");
|
||||
let ping = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
engine.rpc("gmail", "skill/ping", json!({})),
|
||||
)
|
||||
.await;
|
||||
match ping {
|
||||
Ok(Ok(val)) => ok(&format!("ping: {val}")),
|
||||
Ok(Err(e)) => info(&format!("ping: {e}")),
|
||||
Err(_) => fail("ping TIMED OUT (10s)"),
|
||||
}
|
||||
|
||||
// ── Step 14: Final skill state ──
|
||||
step("Step 14: Final Skill State");
|
||||
if let Some(snap) = engine.get_skill_state("gmail") {
|
||||
ok(&format!("Status: {:?}", snap.status));
|
||||
info(&format!("Tools: {}", snap.tools.len()));
|
||||
info(&format!(
|
||||
"Published state keys: {}",
|
||||
snap.state.keys().cloned().collect::<Vec<_>>().join(", ")
|
||||
));
|
||||
for (k, v) in &snap.state {
|
||||
if k.contains("status")
|
||||
|| k.contains("error")
|
||||
|| k.contains("email")
|
||||
|| k.contains("sync")
|
||||
|| k == "syncEnabled"
|
||||
{
|
||||
info(&format!(" {k} = {}", truncate(&v.to_string(), 120)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn("Skill not found in registry");
|
||||
}
|
||||
|
||||
// ── Step 15: Stop ──
|
||||
step("Step 15: Stop");
|
||||
match tokio::time::timeout(Duration::from_secs(10), engine.stop_skill("gmail")).await {
|
||||
Ok(Ok(())) => ok("Skill stopped cleanly"),
|
||||
Ok(Err(e)) => fail(&format!("Stop error: {e}")),
|
||||
Err(_) => fail("Stop TIMED OUT (10s)"),
|
||||
}
|
||||
|
||||
banner("LIVE INTEGRATION TEST COMPLETE");
|
||||
}
|
||||
|
||||
/// Disconnect flow test: verifies credential cleanup after oauth/revoked.
|
||||
///
|
||||
/// Uses a fake credential written directly to disk (no real OAuth needed).
|
||||
/// Mirrors what the frontend's disconnectSkill() should do.
|
||||
#[tokio::test]
|
||||
async fn gmail_disconnect_flow() {
|
||||
let _ = env_logger::builder()
|
||||
.filter_level(log::LevelFilter::Info)
|
||||
.is_test(true)
|
||||
.try_init();
|
||||
|
||||
let skills_dir = require_skills_dir!();
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let data_dir = tmp.path().join("skills_data");
|
||||
std::fs::create_dir_all(&data_dir).expect("create data_dir");
|
||||
|
||||
banner("Gmail Skill — Disconnect Flow");
|
||||
|
||||
let engine = create_engine(&skills_dir, &data_dir).await;
|
||||
|
||||
// Start skill
|
||||
let snap = engine.start_skill("gmail").await.expect("start");
|
||||
assert_eq!(snap.status, SkillStatus::Running);
|
||||
ok(&format!("Started — {} tools", snap.tools.len()));
|
||||
|
||||
// Write a fake OAuth credential directly to disk
|
||||
let cred_dir = data_dir.join("gmail");
|
||||
std::fs::create_dir_all(&cred_dir).unwrap();
|
||||
let cred_path = cred_dir.join("oauth_credential.json");
|
||||
let fake_cred_json = r#"{"credentialId":"gmail-cred-disconnect-test","provider":"gmail","grantedScopes":["https://www.googleapis.com/auth/gmail.readonly"]}"#;
|
||||
std::fs::write(&cred_path, fake_cred_json).unwrap();
|
||||
assert!(
|
||||
cred_path.exists(),
|
||||
"credential should exist before disconnect"
|
||||
);
|
||||
ok("Wrote fake oauth_credential.json");
|
||||
|
||||
// ── Send oauth/revoked (the proper disconnect path) ──
|
||||
step("Send oauth/revoked");
|
||||
let revoked = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
engine.rpc(
|
||||
"gmail",
|
||||
"oauth/revoked",
|
||||
json!({ "integrationId": "gmail-cred-disconnect-test" }),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
match &revoked {
|
||||
Ok(Ok(val)) => ok(&format!("oauth/revoked: {val}")),
|
||||
Ok(Err(e)) => info(&format!(
|
||||
"oauth/revoked: {e} (may be expected if skill has no onOAuthRevoked handler)"
|
||||
)),
|
||||
Err(_) => fail("oauth/revoked TIMED OUT"),
|
||||
}
|
||||
|
||||
// Credential file must be deleted regardless of whether onOAuthRevoked succeeds
|
||||
assert!(
|
||||
!cred_path.exists(),
|
||||
"oauth_credential.json should be deleted after oauth/revoked but still exists at {}",
|
||||
cred_path.display()
|
||||
);
|
||||
ok("oauth_credential.json deleted by oauth/revoked");
|
||||
|
||||
// Stop and reset
|
||||
engine.stop_skill("gmail").await.expect("stop");
|
||||
engine.preferences().set_setup_complete("gmail", false);
|
||||
assert!(!engine.preferences().is_setup_complete("gmail"));
|
||||
ok("Stopped + setup_complete=false");
|
||||
|
||||
// ── Verify clean restart: no credential, no stale state ──
|
||||
step("Clean Restart (no credential)");
|
||||
let snap2 = engine.start_skill("gmail").await.expect("restart");
|
||||
assert_eq!(snap2.status, SkillStatus::Running);
|
||||
ok(&format!("Restarted — {:?}", snap2.status));
|
||||
|
||||
// After restart without credential: __oauth_credential should be absent or empty
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
if let Some(snap) = engine.get_skill_state("gmail") {
|
||||
let cred_val = snap.state.get("__oauth_credential");
|
||||
match cred_val {
|
||||
None => ok("__oauth_credential absent from state (clean)"),
|
||||
Some(v) if v.as_str() == Some("") || v == &json!("") || v == &json!(null) => {
|
||||
ok("__oauth_credential is empty/null in state (clean)")
|
||||
}
|
||||
Some(v) => {
|
||||
warn(&format!(
|
||||
"__oauth_credential still present after clean restart: {v}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
info(&format!(
|
||||
"setup_complete: {}",
|
||||
engine.preferences().is_setup_complete("gmail")
|
||||
));
|
||||
|
||||
engine.stop_skill("gmail").await.expect("final stop");
|
||||
|
||||
banner("DISCONNECT FLOW COMPLETE");
|
||||
}
|
||||
Reference in New Issue
Block a user