Feat/chat issue (#441)

* feat: display app version in settings panel

* fix(onboarding): auto-refresh accessibility state after grant (#351)

* style(onboarding): apply formatter for issue #351 fix

* fix(onboarding): ESLint + typed mock for ScreenPermissionsStep; clear flag in handler

Consolidate tauriCommands imports and drop redundant mock cast.

Handle granted accessibility in focus/visibility callback instead of a follow-up effect.

Made-with: Cursor

* feat(env): add support for custom dotenv path and update dependencies

- Introduced an optional environment variable `OPENHUMAN_DOTENV_PATH` to specify a custom path for dotenv files, enhancing configuration flexibility.
- Updated `Cargo.toml` to include the `dotenvy` dependency for improved dotenv file handling.
- Enhanced the `.env.example` file with a new comment for the custom dotenv path.
- Added data-testid attributes and button types in `SkillDebugModal` and `Skills` components for better testability.
- Created new tests for Gmail and Notion third-party skills to ensure proper functionality of sync and debug tools.
- Added documentation for memory sync functions to clarify usage patterns and function details.

* fix: address CodeRabbit review on PR #441

- Dotenv: treat empty OPENHUMAN_DOTENV_PATH as unset; propagate from_path errors
- Document OPENHUMAN_DOTENV_PATH parent-env requirement in .env.example
- Memory docs: MD040 fence language; clarify skill namespace vs integration id
- QuickJS bootstrap: modern helpers, generic platform.notify log, template URLs
- Skills UI: type=button on close/settings; async waitFor in sync tests
- Gmail OAuth e2e: workspace env matches MemoryClient; env/engine drop guards;
  redact secrets from logs
- Add replace_global_engine for test teardown

Made-with: Cursor
This commit is contained in:
Mega Mind
2026-04-09 01:51:30 +05:30
committed by GitHub
parent 320e31f4b3
commit 371bcd34ea
13 changed files with 1103 additions and 24 deletions
+4
View File
@@ -34,6 +34,10 @@ OPENHUMAN_CORE_RPC_URL=http://127.0.0.1:7788/rpc
OPENHUMAN_CORE_RUN_MODE=child
# [optional] Override path to openhuman core binary (leave blank for auto-detection)
OPENHUMAN_CORE_BIN=
# [optional] Explicit .env path for `openhuman serve` / `openhuman run` (loaded before the server starts).
# Must be set in the parent environment (exported in your shell or service manager). It is read before
# any dotenv file is loaded, so defining OPENHUMAN_DOTENV_PATH inside a .env file cannot select that file.
# OPENHUMAN_DOTENV_PATH=
# ---------------------------------------------------------------------------
# Config overrides (override config.toml values at runtime)
Generated
+7
View File
@@ -1874,6 +1874,12 @@ dependencies = [
"litrs",
]
[[package]]
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "dunce"
version = "1.0.5"
@@ -5135,6 +5141,7 @@ dependencies = [
"dialoguer",
"directories",
"dirs 5.0.1",
"dotenvy",
"enigo",
"env_logger",
"fantoccini",
+1
View File
@@ -60,6 +60,7 @@ prost = { version = "0.14", default-features = false }
postgres = { version = "0.19", features = ["with-chrono-0_4"] }
chrono-tz = "0.10"
dialoguer = { version = "0.12", features = ["fuzzy-select"] }
dotenvy = "0.15"
console = "0.16"
glob = "0.3"
regex = "1.10"
@@ -129,6 +129,7 @@ export default function SkillDebugModal({ skillId, skillName, onClose }: SkillDe
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-1 text-stone-400 hover:text-stone-900 transition-colors rounded-lg hover:bg-stone-100">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -142,6 +143,8 @@ export default function SkillDebugModal({ skillId, skillName, onClose }: SkillDe
{tabs.map(tab => (
<button
key={tab.id}
type="button"
data-testid={tab.id === 'tools' ? 'skill-debug-tab-tools' : 'skill-debug-tab-state'}
onClick={() => setActiveTab(tab.id)}
className={`flex-1 py-2 text-xs font-medium transition-colors ${
activeTab === tab.id
@@ -273,6 +276,8 @@ function ToolsTab({
<div key={tool.name} className="border border-stone-200 rounded-xl overflow-hidden">
{/* Tool header */}
<button
type="button"
data-testid={`skill-debug-tool-header-${tool.name}`}
onClick={() => setExpandedTool(isExpanded ? null : tool.name)}
className="w-full flex items-center justify-between p-3 text-left hover:bg-stone-50 transition-colors">
<div className="min-w-0">
@@ -309,6 +314,7 @@ function ToolsTab({
Arguments (JSON)
</label>
<textarea
data-testid={`skill-debug-tool-args-${tool.name}`}
value={toolArgs[tool.name] ?? '{}'}
onChange={e => setToolArgs({ ...toolArgs, [tool.name]: e.target.value })}
placeholder="{}"
@@ -319,6 +325,8 @@ function ToolsTab({
{/* Call button */}
<button
type="button"
data-testid={`skill-debug-execute-${tool.name}`}
onClick={() => onCallTool(tool.name)}
disabled={isLoading}
className="w-full py-2 text-xs font-medium bg-primary-50 text-primary-500 border border-primary-500/30 rounded-lg hover:bg-primary-100 disabled:opacity-50 transition-colors flex items-center justify-center gap-2">
+7 -1
View File
@@ -282,10 +282,13 @@ function SkillCard({ skill, onSetup }: SkillCardProps) {
<>
{/* Sync */}
<button
type="button"
onClick={isSyncing ? undefined : handleSync}
disabled={isSyncing}
className="w-7 h-7 flex items-center justify-center rounded-lg text-stone-400 hover:text-stone-700 hover:bg-stone-100 transition-colors disabled:opacity-40"
title="Sync">
title="Sync"
aria-label={`Sync ${skill.name}`}
data-testid={`skill-sync-button-${skill.id}`}>
<svg
className={`w-3.5 h-3.5 ${isSyncing ? 'animate-spin' : ''}`}
fill="none"
@@ -301,6 +304,7 @@ function SkillCard({ skill, onSetup }: SkillCardProps) {
</button>
{/* Settings */}
<button
type="button"
onClick={e => {
e.stopPropagation();
onSetup();
@@ -324,6 +328,8 @@ function SkillCard({ skill, onSetup }: SkillCardProps) {
</button>
{/* Debug */}
<button
type="button"
data-testid={`skill-debug-button-${skill.id}`}
onClick={e => {
e.stopPropagation();
setDebugOpen(true);
@@ -0,0 +1,76 @@
/**
* Skills page — 3rd Party Skills: manual sync triggers core `openhuman.skills_sync`
* via `skillManager.triggerSync` (see `lib/skills/manager.ts`).
*/
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../test/test-utils';
import Skills from '../Skills';
const gmailRegistryEntry = {
id: 'gmail',
name: 'Gmail',
version: '1.1.0',
description: 'Read and send email via Gmail.',
runtime: 'quickjs',
entry: 'index.js',
auto_start: false,
platforms: ['macos', 'linux', 'windows'],
setup: { required: true, label: 'Connect Gmail' },
};
const mocks = vi.hoisted(() => ({
triggerSync: vi.fn().mockResolvedValue(undefined),
startSkill: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../../hooks/useChannelDefinitions', () => ({
useChannelDefinitions: () => ({ definitions: [], loading: false, error: null }),
}));
vi.mock('../../lib/skills/manager', () => ({
skillManager: { triggerSync: mocks.triggerSync, startSkill: mocks.startSkill },
}));
vi.mock('../../lib/skills/skillsApi', () => ({
installSkill: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../../lib/skills/hooks', () => ({
useAvailableSkills: () => ({ skills: [gmailRegistryEntry], loading: false, refresh: vi.fn() }),
useSkillConnectionStatus: (skillId: string) => (skillId === 'gmail' ? 'connected' : 'offline'),
useSkillState: () => ({
connection_status: 'connected',
auth_status: 'authenticated',
syncInProgress: false,
syncProgress: 0,
syncProgressMessage: '',
}),
useSkillDataDirectoryStats: () => undefined,
}));
describe('Skills page — 3rd Party Gmail sync', () => {
beforeEach(() => {
mocks.triggerSync.mockClear();
mocks.startSkill.mockClear();
});
it('renders 3rd Party Skills and Gmail with a Sync control when connected', async () => {
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
expect(screen.getByRole('heading', { name: '3rd Party Skills' })).toBeInTheDocument();
expect(screen.getByText('Gmail')).toBeInTheDocument();
const syncBtn = screen.getByTestId('skill-sync-button-gmail');
expect(syncBtn).toBeInTheDocument();
expect(syncBtn).toHaveAttribute('aria-label', 'Sync Gmail');
fireEvent.click(syncBtn);
await waitFor(() => {
expect(mocks.triggerSync).toHaveBeenCalledTimes(1);
});
expect(mocks.triggerSync).toHaveBeenCalledWith('gmail');
});
});
@@ -0,0 +1,170 @@
/**
* Skills page — 3rd Party Notion: debug modal tool execution matches
* `openhuman-skills/src/core/notion/live-test.ts` tool exercises (sections 5b + 7).
* UI path: Skills → Debug → Tools tab → expand tool → Execute → `openhuman.skills_call_tool`.
*/
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../test/test-utils';
import Skills from '../Skills';
const notionRegistryEntry = {
id: 'notion',
name: 'Notion',
version: '1.2.0',
description: 'Notion workspace integration.',
runtime: 'quickjs',
entry: 'index.js',
auto_start: false,
platforms: ['macos', 'linux', 'windows'],
setup: { required: true, label: 'Connect Notion' },
};
const notionSnapshot = {
skill_id: 'notion',
name: 'Notion',
status: 'running',
tools: [
{ name: 'sync-status', description: 'Sync status', inputSchema: { type: 'object' } },
{ name: 'list-users', description: 'List users', inputSchema: { type: 'object' } },
{ name: 'search', description: 'Search', inputSchema: { type: 'object' } },
{ name: 'list-pages', description: 'List pages', inputSchema: { type: 'object' } },
{ name: 'list-databases', description: 'List databases', inputSchema: { type: 'object' } },
],
error: null as string | null,
state: { connection_status: 'connected', auth_status: 'authenticated', is_initialized: true },
setup_complete: true,
connection_status: 'connected',
};
const mocks = vi.hoisted(() => ({
triggerSync: vi.fn().mockResolvedValue(undefined),
startSkill: vi.fn().mockResolvedValue(undefined),
callCoreRpc: vi
.fn()
.mockResolvedValue({ content: [{ type: 'text', text: '{"ok":true}' }], is_error: false }),
}));
vi.mock('../../hooks/useChannelDefinitions', () => ({
useChannelDefinitions: () => ({ definitions: [], loading: false, error: null }),
}));
vi.mock('../../lib/skills/manager', () => ({
skillManager: { triggerSync: mocks.triggerSync, startSkill: mocks.startSkill },
}));
vi.mock('../../lib/skills/skillsApi', () => ({
installSkill: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: mocks.callCoreRpc }));
vi.mock('../../lib/skills/hooks', () => ({
useAvailableSkills: () => ({ skills: [notionRegistryEntry], loading: false, refresh: vi.fn() }),
useSkillConnectionStatus: (skillId: string) => (skillId === 'notion' ? 'connected' : 'offline'),
useSkillState: () => ({
connection_status: 'connected',
auth_status: 'authenticated',
syncInProgress: false,
syncProgress: 0,
syncProgressMessage: '',
}),
useSkillDataDirectoryStats: () => undefined,
useSkillSnapshot: (skillId: string | undefined) => (skillId === 'notion' ? notionSnapshot : null),
}));
function expectToolCallArgs(toolName: string, args: Record<string, unknown>) {
expect(mocks.callCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.skills_call_tool',
params: { skill_id: 'notion', tool_name: toolName, arguments: args },
});
}
describe('Skills page — Notion debug tools (live-test parity)', () => {
beforeEach(() => {
mocks.triggerSync.mockClear();
mocks.startSkill.mockClear();
mocks.callCoreRpc.mockClear();
});
it('opens debug modal and executes tools via openhuman.skills_call_tool', async () => {
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
expect(screen.getByRole('heading', { name: '3rd Party Skills' })).toBeInTheDocument();
expect(screen.getByText('Notion')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('skill-debug-button-notion'));
await waitFor(() => {
expect(screen.getByRole('heading', { name: /Debug: Notion/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('skill-debug-tab-tools'));
// --- Section 5b / 7: same tools as live-test.ts (defaults + search query) ---
const steps: Array<{ tool: string; argsJson?: string }> = [
{ tool: 'sync-status' },
{ tool: 'list-users' },
{ tool: 'search', argsJson: '{"query":"test"}' },
{ tool: 'list-pages', argsJson: '{"page_size":10}' },
{ tool: 'list-databases' },
];
for (const step of steps) {
mocks.callCoreRpc.mockClear();
fireEvent.click(screen.getByTestId(`skill-debug-tool-header-${step.tool}`));
if (step.argsJson) {
fireEvent.change(screen.getByTestId(`skill-debug-tool-args-${step.tool}`), {
target: { value: step.argsJson },
});
}
fireEvent.click(screen.getByTestId(`skill-debug-execute-${step.tool}`));
await waitFor(() => {
expect(mocks.callCoreRpc).toHaveBeenCalled();
});
const parsed =
step.argsJson !== undefined ? JSON.parse(step.argsJson) : ({} as Record<string, unknown>);
expectToolCallArgs(step.tool, parsed);
}
// --- live-test: repeat list-* with tryCache:false (same tool rows, new args) ---
mocks.callCoreRpc.mockClear();
fireEvent.click(screen.getByTestId('skill-debug-tool-header-list-pages'));
fireEvent.change(screen.getByTestId('skill-debug-tool-args-list-pages'), {
target: { value: '{"page_size":10,"tryCache":false}' },
});
fireEvent.click(screen.getByTestId('skill-debug-execute-list-pages'));
await waitFor(() => expect(mocks.callCoreRpc).toHaveBeenCalled());
expectToolCallArgs('list-pages', { page_size: 10, tryCache: false });
mocks.callCoreRpc.mockClear();
fireEvent.click(screen.getByTestId('skill-debug-tool-header-list-databases'));
fireEvent.change(screen.getByTestId('skill-debug-tool-args-list-databases'), {
target: { value: '{"tryCache":false}' },
});
fireEvent.click(screen.getByTestId('skill-debug-execute-list-databases'));
await waitFor(() => expect(mocks.callCoreRpc).toHaveBeenCalled());
expectToolCallArgs('list-databases', { tryCache: false });
mocks.callCoreRpc.mockClear();
fireEvent.click(screen.getByTestId('skill-debug-tool-header-list-users'));
fireEvent.change(screen.getByTestId('skill-debug-tool-args-list-users'), {
target: { value: '{"tryCache":false}' },
});
fireEvent.click(screen.getByTestId('skill-debug-execute-list-users'));
await waitFor(() => expect(mocks.callCoreRpc).toHaveBeenCalled());
expectToolCallArgs('list-users', { tryCache: false });
});
it('Sync control calls skillManager.triggerSync(notion)', async () => {
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
fireEvent.click(screen.getByTestId('skill-sync-button-notion'));
await waitFor(() => {
expect(mocks.triggerSync).toHaveBeenCalledTimes(1);
});
expect(mocks.triggerSync).toHaveBeenCalledWith('notion');
});
});
+357
View File
@@ -0,0 +1,357 @@
# Memory Module — Consumer Reference
How to use the memory layer from services outside `src/openhuman/memory/`.
**Rule**: Always use `MemoryClient` (`src/openhuman/memory/store/client.rs`). Never call `UnifiedMemory` directly — it's internal to the memory module.
---
## Which Function to Use
### Decision Tree
```text
Need to store data?
|
+-- Structured key-value pair (config, state, counters)?
| -> kv_set()
|
+-- Full document (text, sync payload, user content)?
| |
| +-- Ephemeral / high-frequency (screen captures, ticks)?
| | -> put_doc_light() (no embedding, no graph extraction)
| |
| +-- Important content that should be semantically searchable?
| | -> put_doc() (embeds + background graph extraction)
| |
| +-- Rich content needing entity/relation extraction NOW?
| -> ingest_doc() (full synchronous pipeline, slower)
|
+-- Knowledge graph fact (entity-relation-entity)?
-> graph_upsert()
Need to read data?
|
+-- Have a user query / search string?
| -> query_namespace() (returns text for LLM prompt)
| -> query_namespace_context_data() (returns structured data)
|
+-- Need recent context, no specific query?
| -> recall_namespace() (returns text)
| -> recall_namespace_context_data() (returns structured data)
| -> recall_namespace_memories() (returns individual hits)
|
+-- Looking up a specific key?
| -> kv_get()
|
+-- Listing what exists?
| -> list_documents(), list_namespaces(), kv_list_namespace()
|
+-- Querying entity relationships?
-> graph_query()
Need to delete data?
|
+-- Single document? -> delete_document()
+-- Single KV entry? -> kv_delete()
+-- All data for a skill (e.g. on disconnect/revoke)? -> clear_skill_memory()
+-- All data in namespace? -> clear_namespace()
```
---
### Writing Data
#### `put_doc()` — General-purpose document storage
Use when content should be **semantically searchable** later. Embeds the content and enqueues background graph extraction.
```rust
client.put_doc(NamespaceDocumentInput {
namespace: "autocomplete-memory".into(),
key: Some(format!("completion:{timestamp:018}")),
title: "Accepted completion".into(),
content: format!("{context}\n---\n{suggestion}"),
source_type: Some("autocomplete".into()),
metadata: Some(json!({ "app_name": app, "timestamp_ms": ts })),
..Default::default()
}).await?;
```
**Used by**: Skills JS `memory.insert()`, Autocomplete (searchable completions), Subconscious (working-memory docs).
#### `put_doc_light()` — High-frequency / ephemeral storage
Use when data is **written often** and doesn't need embedding or graph extraction. Much faster than `put_doc()`.
```rust
client.put_doc_light(NamespaceDocumentInput {
namespace: "vision".into(),
key: Some(format!("screen_intelligence_{id}")),
title: format!("Screen: {app_name} — {window_title}"),
content: yaml_frontmatter_and_text,
..Default::default()
}).await?;
```
**Used by**: Screen Intelligence (vision summaries every few seconds).
#### `ingest_doc()` — Full synchronous ingestion
Use when you need the complete pipeline (chunking, embedding, entity extraction, relation extraction) to **finish before proceeding**. Blocks until done. Prefer `put_doc()` unless you need synchronous guarantees.
**Used by**: Skills event loop (ingesting sync content into vector graph).
#### `kv_set()` — Structured key-value storage
Use for **small, structured data** you'll look up by exact key. Not semantically searchable.
```rust
client.kv_set(
Some("autocomplete"), // namespace (None = global)
&format!("accepted:{ts:018}"), // key
&json!({ "context": ctx, "suggestion": s }),
).await?;
```
**Used by**: Autocomplete (completion records keyed by timestamp).
#### `graph_upsert()` — Knowledge graph facts
Use to store **entity-relation-entity triples**. You rarely call this directly — `put_doc()` and `ingest_doc()` extract graph relations automatically. Only call `graph_upsert()` when you have an explicit fact without an associated document.
---
### Reading Data
#### `query_namespace()` — Semantic search (text)
Use when you have a **search string** and want relevant content. Returns formatted text ready for LLM prompt injection.
```rust
let context = client.query_namespace(
"autocomplete-memory", // namespace
&last_80_chars, // query
10, // max chunks
).await?;
```
**Used by**: Autocomplete (matching past completions to current context), Frontend (user-initiated search in MemoryWorkspace).
#### `query_namespace_context_data()` — Semantic search (structured)
Same query, but returns `NamespaceRetrievalContext` with individual hits, entities, relations, and score breakdowns. Use when you need to process results programmatically.
#### `recall_namespace()` — Recent context (text)
Use when you need **recent memory** but don't have a query. Returns most recent docs as formatted text.
```rust
if let Ok(Some(text)) = client.recall_namespace(&ns, 3).await {
// top 3 chunks of recent context
}
```
**Used by**: Subconscious (fetching context per namespace for situation report).
#### `recall_namespace_memories()` — Recent context (individual hits)
Returns `Vec<NamespaceMemoryHit>` instead of text. Use when you need to iterate hits and inspect scores.
#### `recall_namespace_context_data()` — Recent context (structured)
Returns `NamespaceRetrievalContext`. Use when you need the full retrieval object (hits + entities + relations).
#### `kv_get()` — Exact key lookup
```rust
let val = client.kv_get(Some("autocomplete"), "accepted:000001719000000").await?;
```
#### `kv_list_namespace()` — List all KV entries
Enumerate all key-value pairs in a namespace. Useful for trimming, history display, or bulk operations.
**Used by**: Autocomplete (trimming beyond 50 entries, settings UI, bulk clear).
#### `list_documents()` — List document metadata
Returns JSON with document metadata (not full content). Useful for delta analysis or trimming.
**Used by**: Subconscious (finding docs updated since last tick), Autocomplete (trimming beyond 200 docs).
#### `graph_query()` — Query knowledge graph
Find entity relationships. Filter by optional namespace, subject, and/or predicate.
```rust
let relations = client.graph_query(None, None, None).await?;
```
**Used by**: Subconscious (relations for situation report), Frontend (knowledge graph display).
---
### Deleting Data
| Function | When to use |
|----------|-------------|
| `delete_document(namespace, id)` | Remove a specific document |
| `kv_delete(namespace, key)` | Remove a specific KV entry |
| `clear_skill_memory(skill_id, integration_id)` | Disconnect / revoke: clears skill-scoped memory in the shared `skill-{skill_id}` namespace. Storage is not isolated per integration—multiple integrations share that namespace; `integration_id` identifies the integration in the API contract (see implementation in `MemoryClient::clear_skill_memory`) |
| `clear_namespace(namespace)` | Wipe an arbitrary namespace |
---
## Common Patterns
**Fire-and-forget writes** — Spawn a background task to avoid blocking:
```rust
let client = memory_client.clone();
tokio::spawn(async move {
if let Err(e) = client.put_doc(input).await {
log::warn!("memory write failed: {e}");
}
});
```
**Trim-after-write** — Cap growth after each insert (e.g., max 50 KV entries, max 200 docs). Use `kv_list_namespace()` or `list_documents()` then delete oldest.
**Init on app startup** — Frontend calls `syncMemoryClientToken()` in `CoreStateProvider.tsx` on mount and token refresh. Memory subsystem must be initialized before queries run.
---
## Namespace Convention
| Namespace | Owner | Description |
|-----------|-------|-------------|
| `skill-{skill_id}` | Skills event loop | Raw state blobs + per-page docs (e.g., `skill-notion`, `skill-gmail`) |
| `global` | Skills working_memory.rs | Extracted user facts (preferences, goals, entities) |
| `conversations` | Agent/inference | Conversation context |
| `autocomplete` | Autocomplete | Accepted completion records (KV) |
| `autocomplete-memory` | Autocomplete | Searchable completion documents |
| `vision` | Screen intelligence | Vision summaries from screen captures |
---
## MemoryClient API Reference
**File**: `src/openhuman/memory/store/client.rs`
### Documents
| Function | Line | Type | Description |
|----------|------|------|-------------|
| `put_doc(NamespaceDocumentInput) -> Result<String>` | 105 | WRITE | Stores document; background graph extraction |
| `put_doc_light(NamespaceDocumentInput) -> Result<String>` | 125 | WRITE | Lightweight insert; no embedding or extraction |
| `ingest_doc(MemoryIngestionRequest) -> Result<MemoryIngestionResult>` | 132 | WRITE | Full synchronous ingestion pipeline |
| `store_skill_sync(...)` | 143 | WRITE | Skill-specific upsert into `skill-{skill_id}` namespace |
| `list_documents(Option<&str>) -> Result<Value>` | 184 | READ | Lists documents, optionally by namespace |
| `delete_document(&str, &str) -> Result<Value>` | 197 | DELETE | Deletes by namespace + document ID |
### Namespaces
| Function | Line | Type | Description |
|----------|------|------|-------------|
| `list_namespaces() -> Result<Vec<String>>` | 192 | READ | Lists all namespaces |
| `clear_namespace(&str) -> Result<()>` | 206 | DELETE | Clears all data in a namespace |
| `clear_skill_memory(&str, &str) -> Result<()>` | 211 | DELETE | Clears documents in the shared `skill-{skill_id}` namespace; second arg is the integration identifier passed from disconnect flows |
### Query / Recall
| Function | Line | Type | Description |
|----------|------|------|-------------|
| `query_namespace(&str, &str, u32) -> Result<String>` | 234 | READ | Semantic query; returns text |
| `query_namespace_context_data(&str, &str, u32) -> Result<NamespaceRetrievalContext>` | 246 | READ | Semantic query; returns structured data |
| `recall_namespace(&str, u32) -> Result<Option<String>>` | 258 | READ | Recent context; returns text |
| `recall_namespace_context_data(&str, u32) -> Result<NamespaceRetrievalContext>` | 269 | READ | Recent context; returns structured data |
| `recall_namespace_memories(&str, u32) -> Result<Vec<NamespaceMemoryHit>>` | 280 | READ | Recent context; returns individual hits |
### Key-Value
| Function | Line | Type | Description |
|----------|------|------|-------------|
| `kv_set(Option<&str>, &str, &Value) -> Result<()>` | 289 | WRITE | Sets KV pair (`None` namespace = global) |
| `kv_get(Option<&str>, &str) -> Result<Option<Value>>` | 302 | READ | Gets KV value |
| `kv_delete(Option<&str>, &str) -> Result<bool>` | 314 | DELETE | Deletes KV pair |
| `kv_list_namespace(&str) -> Result<Vec<Value>>` | 322 | READ | Lists all KV pairs in namespace |
### Knowledge Graph
| Function | Line | Type | Description |
|----------|------|------|-------------|
| `graph_upsert(Option<&str>, &str, &str, &str, &Value) -> Result<()>` | 330 | WRITE | Upserts relation triple (`None` namespace = global) |
| `graph_query(Option<&str>, Option<&str>, Option<&str>) -> Result<Vec<Value>>` | 356 | READ | Queries graph with optional filters |
---
## RPC Method Names
For callers going through JSON-RPC (frontend or external). Each maps 1:1 to a `MemoryClient` method above.
| RPC Method | Type | MemoryClient equivalent |
|------------|------|------------------------|
| `openhuman.memory_init` | INIT | — (initializes subsystem) |
| `openhuman.memory_doc_put` | WRITE | `put_doc()` |
| `openhuman.memory_doc_ingest` | WRITE | `ingest_doc()` |
| `openhuman.memory_doc_list` | READ | `list_documents()` |
| `openhuman.memory_doc_delete` | DELETE | `delete_document()` |
| `openhuman.memory_namespace_list` | READ | `list_namespaces()` |
| `openhuman.memory_list_namespaces` | READ | `list_namespaces()` (structured envelope) |
| `openhuman.memory_list_documents` | READ | `list_documents()` (structured envelope) |
| `openhuman.memory_delete_document` | DELETE | `delete_document()` (structured envelope) |
| `openhuman.memory_clear_namespace` | DELETE | `clear_namespace()` |
| `openhuman.memory_context_query` | READ | `query_namespace()` |
| `openhuman.memory_context_recall` | READ | `recall_namespace()` |
| `openhuman.memory_query_namespace` | READ | `query_namespace_context_data()` |
| `openhuman.memory_recall_context` | READ | `recall_namespace_context_data()` |
| `openhuman.memory_recall_memories` | READ | `recall_namespace_memories()` |
| `openhuman.memory_kv_set` | WRITE | `kv_set()` |
| `openhuman.memory_kv_get` | READ | `kv_get()` |
| `openhuman.memory_kv_delete` | DELETE | `kv_delete()` |
| `openhuman.memory_kv_list_namespace` | READ | `kv_list_namespace()` |
| `openhuman.memory_graph_upsert` | WRITE | `graph_upsert()` |
| `openhuman.memory_graph_query` | READ | `graph_query()` |
| `openhuman.ai_list_memory_files` | READ | — (file I/O, not MemoryClient) |
| `openhuman.ai_read_memory_file` | READ | — (file I/O) |
| `openhuman.ai_write_memory_file` | WRITE | — (file I/O) |
---
## Frontend TypeScript Wrappers
**File**: `app/src/utils/tauriCommands/memory.ts`
Each calls the corresponding RPC method above via `core_rpc_relay`.
| Function | Line | RPC Method |
|----------|------|------------|
| `syncMemoryClientToken(token)` | 82 | `openhuman.memory_init` |
| `memoryListDocuments(namespace?)` | 102 | `openhuman.memory_list_documents` |
| `memoryListNamespaces()` | 117 | `openhuman.memory_list_namespaces` |
| `memoryDeleteDocument(id, ns)` | 132 | `openhuman.memory_delete_document` |
| `memoryClearNamespace(ns)` | 145 | `openhuman.memory_clear_namespace` |
| `memoryQueryNamespace(ns, query, max?)` | 158 | `openhuman.memory_query_namespace` |
| `memoryRecallNamespace(ns, max?)` | 173 | `openhuman.memory_recall_context` |
| `memoryGraphQuery(ns?, subj?, pred?)` | 187 | `openhuman.memory_graph_query` |
| `memoryDocIngest(params)` | 210 | `openhuman.memory_doc_ingest` |
| `aiListMemoryFiles(dir?)` | 229 | `openhuman.ai_list_memory_files` |
| `aiReadMemoryFile(path)` | 246 | `openhuman.ai_read_memory_file` |
| `aiWriteMemoryFile(path, content)` | 261 | `openhuman.ai_write_memory_file` |
---
## Key Data Types
| Type | Description |
|------|-------------|
| `NamespaceDocumentInput` | Document upsert payload (namespace, content, metadata, tags, source_type, priority) |
| `MemoryIngestionRequest` | Full ingestion request with config (chunking, embedding, extraction flags) |
| `MemoryIngestionResult` | Ingestion result (document_id, chunk_count, entities, relations) |
| `NamespaceRetrievalContext` | Structured retrieval (hits, entities, relations, chunks) |
| `NamespaceMemoryHit` | Individual memory item with score breakdown |
| `GraphRelationRecord` | Knowledge graph triple (subject, predicate, object, evidence, namespace) |
| `RetrievalScoreBreakdown` | Score components: graph, vector, keyword, episodic, freshness |
All types defined in `src/openhuman/memory/store/types.rs`.
+20
View File
@@ -69,6 +69,24 @@ pub fn run_from_cli_args(args: &[String]) -> Result<()> {
}
}
/// Loads key/value pairs from a dotenv file into the process environment.
///
/// Precedence: variables already set in the environment are **not** overwritten.
/// Order: `OPENHUMAN_DOTENV_PATH` (if set to a non-empty path), else `.env` in the current working directory.
fn load_dotenv_for_server() -> Result<()> {
match std::env::var("OPENHUMAN_DOTENV_PATH") {
Ok(path) if !path.trim().is_empty() => {
dotenvy::from_path(&path).map_err(|e| {
anyhow::anyhow!("failed to load dotenv from OPENHUMAN_DOTENV_PATH={path}: {e}")
})?;
}
_ => {
let _ = dotenvy::dotenv();
}
}
Ok(())
}
/// Handles the `run` subcommand to start the core HTTP/JSON-RPC server.
///
/// Parses flags for port, host, and optional Socket.IO support.
@@ -77,6 +95,8 @@ pub fn run_from_cli_args(args: &[String]) -> Result<()> {
///
/// * `args` - Command-line arguments for the `run` command.
fn run_server_command(args: &[String]) -> Result<()> {
load_dotenv_for_server()?;
let mut port: Option<u16> = None;
let mut host: Option<String> = None;
let mut socketio_enabled = true;
+1 -1
View File
@@ -26,7 +26,7 @@ pub mod utils;
pub mod working_memory;
pub use ops::*;
pub use qjs_engine::{global_engine, require_engine, set_global_engine};
pub use qjs_engine::{global_engine, replace_global_engine, require_engine, set_global_engine};
pub use schemas::{
all_controller_schemas as all_skills_controller_schemas,
all_registered_controllers as all_skills_registered_controllers,
+5
View File
@@ -19,6 +19,11 @@ pub fn set_global_engine(engine: Arc<RuntimeEngine>) {
*GLOBAL_ENGINE.write() = Some(engine);
}
/// Swap the global engine and return the previous value (for isolated tests / harnesses).
pub fn replace_global_engine(engine: Option<Arc<RuntimeEngine>>) -> Option<Arc<RuntimeEngine>> {
std::mem::replace(&mut *GLOBAL_ENGINE.write(), engine)
}
/// Get a clone of the global RuntimeEngine Arc.
/// Returns `None` if the engine has not been initialized yet.
pub fn global_engine() -> Option<Arc<RuntimeEngine>> {
+42 -22
View File
@@ -699,6 +699,22 @@ globalThis.__platform = {
},
};
/**
* Base URL for OAuth proxy and webhook API calls.
* Uses BACKEND_URL, then VITE_BACKEND_URL, then production default.
* Trailing slashes are stripped.
*/
globalThis.__resolveBackendBaseUrl = () => {
let raw = (__platform.env('BACKEND_URL') || __platform.env('VITE_BACKEND_URL') || '').trim();
if (!raw) {
return 'https://api.tinyhumans.ai';
}
while (raw.length > 0 && raw.charAt(raw.length - 1) === '/') {
raw = raw.slice(0, -1);
}
return raw;
};
// High-level wrappers for skills (QuickJS bridge)
globalThis.db = {
exec: function (sql, params) {
@@ -735,6 +751,13 @@ globalThis.platform = {
env: function (key) {
return __platform.env(key);
},
/**
* Desktop-style notification hook. The QuickJS host has no system tray UI here;
* avoid logging title/body (may contain PII); shim exists so sync flows do not throw "not a function".
*/
notify: function (_title, _body) {
console.log('[platform.notify] notification requested');
},
};
// ============================================================================
@@ -847,18 +870,18 @@ globalThis.data = {
body: JSON.stringify({ error: 'No OAuth credential. Complete OAuth setup first.' }),
};
}
var backendUrl = __platform.env('BACKEND_URL') || 'https://api.tinyhumans.ai';
var jwtToken = __ops.get_session_token() || '';
var cleanPath = path.charAt(0) === '/' ? path.slice(1) : path;
var credentialId = globalThis.__oauthCredential.credentialId;
var clientKey = globalThis.__oauthClientKey || null;
const backendUrl = globalThis.__resolveBackendBaseUrl();
const jwtToken = __ops.get_session_token() || '';
const cleanPath = path.charAt(0) === '/' ? path.slice(1) : path;
const credentialId = globalThis.__oauthCredential.credentialId;
const clientKey = globalThis.__oauthClientKey || null;
// Use encrypted proxy when client key share is available
var proxyUrl;
let proxyUrl;
if (clientKey) {
proxyUrl = backendUrl + '/proxy/encrypted/' + credentialId + '/' + cleanPath;
proxyUrl = `${backendUrl}/proxy/encrypted/${credentialId}/${cleanPath}`;
} else {
proxyUrl = backendUrl + '/proxy/by-id/' + credentialId + '/' + cleanPath;
proxyUrl = `${backendUrl}/proxy/by-id/${credentialId}/${cleanPath}`;
}
var method = (options && options.method) || 'GET';
@@ -906,16 +929,13 @@ globalThis.data = {
revoke: function () {
if (__oauthCredential) {
try {
var backendUrl = __platform.env('BACKEND_URL') || 'https://api.tinyhumans.ai';
var jwtToken = __ops.get_session_token() || '';
var revokeOpts = {
const backendUrl = globalThis.__resolveBackendBaseUrl();
const jwtToken = __ops.get_session_token() || '';
const revokeOpts = {
method: 'DELETE',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwtToken },
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${jwtToken}` },
};
net.fetch(
backendUrl + '/auth/integrations/' + __oauthCredential.credentialId,
revokeOpts
);
net.fetch(`${backendUrl}/auth/integrations/${__oauthCredential.credentialId}`, revokeOpts);
} catch (e) {
/* best effort */
}
@@ -1142,10 +1162,10 @@ globalThis.webhook = {
* @returns {Promise<{id: string, uuid: string, webhookUrl: string}>}
*/
createTunnel: async function (name, description) {
var backendUrl = __platform.env('BACKEND_URL') || 'https://api.tinyhumans.ai';
var jwtToken = __ops.get_session_token() || '';
const backendUrl = globalThis.__resolveBackendBaseUrl();
const jwtToken = __ops.get_session_token() || '';
var result = await net.fetch(backendUrl + '/webhooks/core', {
var result = await net.fetch(`${backendUrl}/webhooks/core`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -1204,10 +1224,10 @@ globalThis.webhook = {
}
// Delete from backend first
var backendUrl = __platform.env('BACKEND_URL') || 'https://api.tinyhumans.ai';
var jwtToken = __ops.get_session_token() || '';
const backendUrl = globalThis.__resolveBackendBaseUrl();
const jwtToken = __ops.get_session_token() || '';
var result = await net.fetch(backendUrl + '/webhooks/core/' + registration.backend_tunnel_id, {
var result = await net.fetch(`${backendUrl}/webhooks/core/${registration.backend_tunnel_id}`, {
method: 'DELETE',
headers: { Authorization: 'Bearer ' + jwtToken },
timeout: 10000,
+405
View File
@@ -0,0 +1,405 @@
//! Gmail OAuth proxy E2E over HTTP JSON-RPC.
//!
//! Verifies the full flow:
//! 1. Start runtime + Gmail skill through HTTP JSON-RPC
//! 2. Send `oauth/complete` with `credentialId` + `clientKeyShare`
//! 3. Call `get-profile` via `openhuman.skills_call_tool`
//! 4. Assert tool call succeeds against the staging backend.
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use axum::Router;
use serde_json::{json, Value};
use tempfile::tempdir;
use openhuman_core::core::jsonrpc::build_core_http_router;
use openhuman_core::openhuman::memory::MemoryClient;
use openhuman_core::openhuman::skills::qjs_engine::{replace_global_engine, RuntimeEngine};
use std::ffi::OsString;
fn try_find_skills_dir() -> Option<PathBuf> {
if let Ok(dir) = std::env::var("SKILL_DEBUG_DIR") {
let p = PathBuf::from(&dir);
return if p.exists() { Some(p) } else { None };
}
if let Ok(dir) = std::env::var("SKILLS_LOCAL_DIR") {
let p = PathBuf::from(&dir);
if p.exists() {
return Some(p);
}
}
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());
}
}
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;
}
}
};
}
async fn serve_on_ephemeral(
app: Router,
) -> (
SocketAddr,
tokio::task::JoinHandle<Result<(), std::io::Error>>,
) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("addr");
let handle = tokio::spawn(async move { axum::serve(listener, app).await });
(addr, handle)
}
async fn rpc_call(base: &str, id: i64, method: &str, params: Value) -> Value {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.build()
.expect("client");
let body = json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
});
let url = format!("{}/rpc", base.trim_end_matches('/'));
let resp = client
.post(&url)
.json(&body)
.send()
.await
.unwrap_or_else(|e| panic!("POST {url}: {e}"));
assert!(
resp.status().is_success(),
"HTTP error {} for {}",
resp.status(),
method
);
resp.json::<Value>()
.await
.unwrap_or_else(|e| panic!("json for {method}: {e}"))
}
fn assert_rpc_ok(resp: &Value, context: &str) -> Value {
if let Some(err) = resp.get("error") {
panic!("{context}: unexpected JSON-RPC error: {err}");
}
resp.get("result")
.cloned()
.unwrap_or_else(|| panic!("{context}: missing result field: {resp}"))
}
/// Restores prior process environment for keys we mutate in this test (runs on panic too).
struct ProcessEnvGuard {
home: Option<OsString>,
backend_url: Option<OsString>,
jwt_token: Option<OsString>,
openhuman_workspace: Option<OsString>,
vite_backend_url: Option<OsString>,
}
impl ProcessEnvGuard {
fn apply(
home: &std::path::Path,
workspace_dir: &std::path::Path,
backend_url: &str,
jwt: &str,
) -> Self {
let guard = Self {
home: std::env::var_os("HOME"),
backend_url: std::env::var_os("BACKEND_URL"),
jwt_token: std::env::var_os("JWT_TOKEN"),
openhuman_workspace: std::env::var_os("OPENHUMAN_WORKSPACE"),
vite_backend_url: std::env::var_os("VITE_BACKEND_URL"),
};
unsafe {
std::env::set_var("HOME", home.as_os_str());
std::env::set_var("BACKEND_URL", backend_url);
std::env::set_var("JWT_TOKEN", jwt);
std::env::set_var("OPENHUMAN_WORKSPACE", workspace_dir.as_os_str());
std::env::remove_var("VITE_BACKEND_URL");
}
guard
}
}
impl Drop for ProcessEnvGuard {
fn drop(&mut self) {
unsafe {
match self.home.take() {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
match self.backend_url.take() {
Some(v) => std::env::set_var("BACKEND_URL", v),
None => std::env::remove_var("BACKEND_URL"),
}
match self.jwt_token.take() {
Some(v) => std::env::set_var("JWT_TOKEN", v),
None => std::env::remove_var("JWT_TOKEN"),
}
match self.openhuman_workspace.take() {
Some(v) => std::env::set_var("OPENHUMAN_WORKSPACE", v),
None => std::env::remove_var("OPENHUMAN_WORKSPACE"),
}
match self.vite_backend_url.take() {
Some(v) => std::env::set_var("VITE_BACKEND_URL", v),
None => std::env::remove_var("VITE_BACKEND_URL"),
}
}
}
}
struct GlobalEngineGuard {
previous: Option<Arc<RuntimeEngine>>,
}
impl GlobalEngineGuard {
fn install(engine: Arc<RuntimeEngine>) -> Self {
let previous = replace_global_engine(Some(engine));
Self { previous }
}
}
impl Drop for GlobalEngineGuard {
fn drop(&mut self) {
let _ = replace_global_engine(self.previous.take());
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn gmail_tool_call_sends_encrypted_oauth_proxy_headers() {
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 home = tmp.path();
let data_dir = home.join("skills_data");
let workspace_dir = home.join("workspace");
std::fs::create_dir_all(&data_dir).expect("create data dir");
std::fs::create_dir_all(&workspace_dir).expect("create workspace dir");
let backend_url = "https://staging-api.alphahuman.xyz";
// Isolated HOME + env for JWT and backend routing used by oauth.fetch.
let openhuman_dir = home.join(".openhuman");
std::fs::create_dir_all(&openhuman_dir).expect("create .openhuman");
std::fs::write(
openhuman_dir.join("config.toml"),
r#"api_url = "http://127.0.0.1:1"
default_model = "test"
[secrets]
encrypt = false
"#,
)
.expect("write config");
let test_jwt = match std::env::var("JWT_TOKEN") {
Ok(v) if !v.trim().is_empty() => v,
_ => {
eprintln!("`SKIPPED`: set JWT_TOKEN to run staging OAuth proxy E2E");
return;
}
};
let credential_id = match std::env::var("CREDENTIAL_ID") {
Ok(v) if !v.trim().is_empty() => v,
_ => {
eprintln!("SKIPPED: set CREDENTIAL_ID to run staging OAuth proxy E2E");
return;
}
};
let client_key_share = match std::env::var("CLIENT_KEY_SHARE") {
Ok(v) if !v.trim().is_empty() => v,
_ => {
eprintln!("SKIPPED: set CLIENT_KEY_SHARE to run staging OAuth proxy E2E");
return;
}
};
eprintln!("[gmail-oauth-proxy-e2e] JWT_TOKEN loaded");
eprintln!("[gmail-oauth-proxy-e2e] CREDENTIAL_ID loaded");
eprintln!("[gmail-oauth-proxy-e2e] CLIENT_KEY_SHARE loaded");
// Quick staging reachability check.
let health = reqwest::Client::new()
.get(format!("{backend_url}/settings"))
.header("Authorization", format!("Bearer {test_jwt}"))
.send()
.await;
match health {
Ok(resp) => eprintln!(
"[gmail-oauth-proxy-e2e] staging /settings -> {}",
resp.status()
),
Err(err) => panic!("failed to reach staging backend {backend_url}: {err}"),
}
// Isolated env + global engine so parallel tests do not leak state.
let _env_guard = ProcessEnvGuard::apply(home, &workspace_dir, backend_url, &test_jwt);
let engine = Arc::new(RuntimeEngine::new(data_dir).expect("engine"));
engine.set_skills_source_dir(skills_dir);
let _engine_guard = GlobalEngineGuard::install(engine.clone());
// Start HTTP JSON-RPC server.
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
tokio::time::sleep(Duration::from_millis(50)).await;
// 1) Start skill
let start = rpc_call(
&rpc_base,
1,
"openhuman.skills_start",
json!({ "skill_id": "gmail" }),
)
.await;
let _ = assert_rpc_ok(&start, "skills_start");
// 2) Inject OAuth credential + client key share
let oauth_complete = rpc_call(
&rpc_base,
2,
"openhuman.skills_rpc",
json!({
"skill_id": "gmail",
"method": "oauth/complete",
"params": {
"credentialId": credential_id,
"provider": "gmail",
"grantedScopes": ["https://www.googleapis.com/auth/gmail.readonly"],
"clientKeyShare": client_key_share
}
}),
)
.await;
let _ = assert_rpc_ok(&oauth_complete, "skills_rpc oauth/complete");
// 3) Call get-profile tool via runtime JSON-RPC
let call_tool = rpc_call(
&rpc_base,
3,
"openhuman.skills_call_tool",
json!({
"skill_id": "gmail",
"tool_name": "get-profile",
"arguments": {}
}),
)
.await;
eprintln!(
"[gmail-oauth-proxy-e2e] skills_call_tool completed (has_error={})",
call_tool.get("error").is_some()
);
let tool_result = assert_rpc_ok(&call_tool, "skills_call_tool get-profile");
eprintln!(
"[gmail-oauth-proxy-e2e] get-profile tool result keys: {:?}",
tool_result
.as_object()
.map(|m| m.keys().collect::<Vec<_>>())
);
assert_eq!(
tool_result.get("is_error").and_then(|v| v.as_bool()),
Some(false),
"expected get-profile tool call to succeed"
);
// 4) Validate tool payload shape from real staging response
let content_text = tool_result
.get("content")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.and_then(|item| item.get("text"))
.and_then(|v| v.as_str())
.unwrap_or("");
assert!(
!content_text.is_empty(),
"expected text payload in tool result content"
);
let parsed: Value = serde_json::from_str(content_text)
.unwrap_or_else(|e| panic!("tool text payload should be JSON: {e}; got {content_text}"));
assert_eq!(parsed.get("success").and_then(|v| v.as_bool()), Some(true));
eprintln!("[gmail-oauth-proxy-e2e] get-profile JSON: success flag present");
// 5) Trigger sync via controller RPC (routes to skill/sync)
let sync = rpc_call(
&rpc_base,
4,
"openhuman.skills_sync",
json!({ "skill_id": "gmail" }),
)
.await;
eprintln!(
"[gmail-oauth-proxy-e2e] skills_sync RPC completed (has_error={})",
sync.get("error").is_some()
);
let sync_result = assert_rpc_ok(&sync, "openhuman.skills_sync");
eprintln!(
"[gmail-oauth-proxy-e2e] skills_sync result keys: {:?}",
sync_result
.as_object()
.map(|m| m.keys().collect::<Vec<_>>())
);
// 6) Verify memory persistence in skill-gmail namespace (async)
let memory_client = MemoryClient::from_workspace_dir(workspace_dir.clone())
.expect("MemoryClient::from_workspace_dir");
let namespace = "skill-gmail";
let mut docs_count = 0usize;
let mut last_docs_payload = json!({});
for _ in 0..10 {
let docs = memory_client
.list_documents(Some(namespace))
.await
.unwrap_or_else(|e| panic!("list_documents({namespace}) failed: {e}"));
docs_count = docs
.get("documents")
.and_then(|d| d.as_array())
.map(|arr| arr.len())
.unwrap_or(0);
last_docs_payload = docs;
if docs_count > 0 {
break;
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
assert!(
docs_count > 0,
"expected memory docs in namespace '{namespace}' after skills_sync; workspace={}, last_payload={}",
workspace_dir.display(),
serde_json::to_string(&last_docs_payload).unwrap_or_else(|_| last_docs_payload.to_string())
);
eprintln!(
"[gmail-oauth-proxy-e2e] memory docs in {}: {}",
namespace, docs_count
);
// Cleanup
let _ = rpc_call(
&rpc_base,
5,
"openhuman.skills_stop",
json!({ "skill_id": "gmail" }),
)
.await;
rpc_join.abort();
}