Files
openhuman/tests/json_rpc_e2e.rs
T
7685e877ee feat(agent): welcome->orchestrator routing + per-agent tool scoping (#525, #526) (#544)
* feat(agent): add subagents + delegate_name fields to AgentDefinition (#525, #526)

Introduces the schema change needed to make agent definitions the single source
of truth for both direct tools and delegation targets:

- `subagents: Vec<SubagentEntry>` — declarative list of agents this agent can
  spawn, expanding at build time into synthesised delegate_* tools on the LLM's
  function-calling surface. Supports two TOML shapes via `#[serde(untagged)]`:
    * Bare string (`"researcher"`) → `SubagentEntry::AgentId`
    * Inline table (`{ skills = "*" }`) → `SubagentEntry::Skills(SkillsWildcard)`
  The `Skills` variant expands dynamically to one delegate_{toolkit} tool per
  connected Composio toolkit at runtime.

- `delegate_name: Option<String>` — optional override for the tool name this
  agent is exposed as when another agent lists it in `subagents`. Defaults to
  `delegate_{id}` when absent, lets the researcher agent be exposed as
  `research`, code_executor as `run_code`, etc.

Schema only — no runtime behavior change yet. Follow-up commits wire the field
into `collect_orchestrator_tools`, the dispatch path, and the debug dump.

TOML placement note: `subagents = [...]` must appear before the `[tools]` table
header in agent TOMLs. Once a table section opens, every subsequent top-level
key is consumed by that table, so placing `subagents` after `[tools]` parses it
as `tools.subagents` and fails deserializing ToolScope. The test doc-comment
records this constraint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(agent): drive orchestrator delegation tools from TOML subagents (#525, #526)

Completes the half-built migration from a hardcoded ARCHETYPE_TOOLS table
+ orphan skill_delegation.rs to a TOML-driven delegation surface where
each agent declares its subagents and the tool list is synthesised from
the registry at agent-build time.

Rewrites `collect_orchestrator_tools` to take the orchestrator's
AgentDefinition, the global AgentDefinitionRegistry, and the slice of
connected Composio integrations, then iterates the definition's
`subagents` field:

  * `SubagentEntry::AgentId` → one ArchetypeDelegationTool. The tool's
    `name()` comes from the target agent's `delegate_name` override (or
    `delegate_{id}` fallback) and its `description()` is the target's
    `when_to_use`. Editing an agent's TOML when_to_use now immediately
    updates the tool schema the orchestrator LLM sees — zero drift, no
    hardcoded description strings to keep in sync.

  * `SubagentEntry::Skills { skills = "*" }` → one SkillDelegationTool
    per connected Composio toolkit. Each routes to the generic
    skills_agent with `skill_filter` pre-populated. Empty integrations
    list (CLI path, or user not yet connected) produces zero tools
    rather than phantom `delegate_*` entries for unconnected toolkits.

Also in this commit:

- Wire the orphan `skill_delegation.rs` into `impl/agent/mod.rs` via
  `mod skill_delegation;` + `pub use SkillDelegationTool;`. The file has
  existed since earlier work but was never declared in the module tree,
  so it compiled as dead code.

- Delete the legacy `MAIN_AGENT_TOOL_ALLOWLIST` constant and the
  `main_agent_tools` filter in `tools/ops.rs`. They were documented as
  "no longer the primary source of truth" since the from_config builder
  switched to `collect_orchestrator_tools`, and grep confirms no
  external callers remain. Clean deletion.

- Delete the hardcoded `ARCHETYPE_TOOLS` const in
  `tools/impl/agent/mod.rs`. The 4-entry table has been replaced by the
  orchestrator TOML's `subagents` list (which covers those 4 plus
  archivist plus the skills wildcard), and the re-export in
  `tools/mod.rs` is removed accordingly.

- Update `agents/orchestrator/agent.toml`: add the `subagents` field
  listing researcher / planner / code_executor / critic / archivist /
  { skills = "*" }. Keep `spawn_subagent` in `[tools] named` as an
  advanced fallback so power users can still spawn custom workspace-
  override agent ids that aren't in the declarative subagents list.

- Add `delegate_name = "..."` to the 5 archetype TOMLs so the
  orchestrator LLM sees natural tool names (`research`, `plan`,
  `run_code`, `review_code`, `archive_session`) rather than the
  `delegate_<agent_id>` fallback.

- Update `agent/harness/session/builder.rs` (line ~461) to call the new
  `collect_orchestrator_tools` signature. Looks up the orchestrator
  definition from the global registry; passes an empty integrations
  slice because the builder is synchronous and cannot await Composio's
  async fetch. The channel-dispatch path will populate integrations in
  a later commit — the CLI/REPL path ships without per-toolkit delegation
  tools, which is acceptable regression since CLI users still reach
  Composio via `composio_execute` and the retained `spawn_subagent`
  fallback.

Tests:

  * 5 new unit tests in `orchestrator_tools.rs` cover the baseline
    AgentId + Skills wildcard expansion, empty-integrations edge case,
    unknown-id graceful skip, non-delegating agent with empty subagents,
    and the slug sanitiser for tool-name-safe Composio toolkit names.
  * Runs clean alongside all existing agent-module tests (323 pass;
    one pre-existing Windows-path failure in `self_healing::tests::
    tool_maker_prompt_includes_command` is unrelated to this PR and
    fails identically on the upstream baseline).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(agent): plumb per-agent tool scoping through bus + tool loop (#525, #526)

Adds the parameter plumbing for agent-aware tool filtering without
changing any runtime behaviour. Every existing call site continues to
pass `None` / empty extras, so the LLM still sees the full unfiltered
registry — the actual routing logic that populates these fields lands
in commit 4b (dispatch.rs onboarding-flag → target_agent_id).

Why two parameters and not one filter:

  Tools in this codebase are `Box<dyn Tool>` — owned trait objects with
  no Clone impl, stored in a shared `Arc<Vec<Box<dyn Tool>>>`. We can't
  cheaply build a per-turn filtered subset of the global registry, and
  we can't mutate the Arc to remove entries. Two new parameters work
  around this without touching the global registry's lifetime model:

  * `visible_tool_names: Option<&HashSet<String>>` — whitelist filter
    applied at the iteration site inside `run_tool_call_loop`. When
    `Some(set)`, only tools whose `name()` is in the set contribute to
    the function-calling schema and are eligible for execution; every
    other tool in the combined registry is hidden from the model and
    rejected if the model emits a call for it. `None` preserves the
    legacy "everything visible" behaviour.

  * `extra_tools: &[Box<dyn Tool>]` — per-turn synthesised tools spliced
    alongside `tools_registry`. The dispatch path will use this to
    surface delegation tools (`research`, `delegate_gmail`, …) that are
    built fresh each turn from the active agent's `subagents` field and
    the current Composio integration list — tools that don't exist in
    the global startup-time registry because they depend on per-user
    runtime state. Empty slice for agents that don't delegate.

  Inside the loop, `tool_specs` is built from
  `tools_registry.iter().chain(extra_tools.iter()).filter(is_visible)`,
  and the tool-execution lookup uses the same chain + filter so the
  function-calling schema and the execution surface stay in sync.

Files touched in this commit:

  src/openhuman/agent/harness/tool_loop.rs
    - Add `visible_tool_names` and `extra_tools` parameters to
      `run_tool_call_loop`. Build `tool_specs` from chained iteration
      with the visibility filter applied. Replace the `find_tool` call
      at the execution site with an inline chain+filter lookup so
      hallucinated calls to filtered-out tools surface as "unknown
      tool" errors. Drop the now-unused `find_tool` import.
    - Update the legacy `agent_turn` wrapper to pass `None, &[]`,
      preserving its existing unfiltered behaviour.
    - Update all 9 in-file test sites to pass `None, &[]`.

  src/openhuman/agent/harness/tests.rs
    - Update all 3 `run_tool_call_loop` test sites to pass `None, &[]`.

  src/openhuman/agent/bus.rs
    - Add `target_agent_id: Option<String>`, `visible_tool_names:
      Option<HashSet<String>>`, and `extra_tools: Vec<Box<dyn Tool>>`
      fields to `AgentTurnRequest`, with rustdoc explaining each.
    - Destructure the new fields in the `agent.run_turn` handler;
      thread `visible_tool_names.as_ref()` and `&extra_tools` through
      to `run_tool_call_loop`. Augment the dispatch trace with
      target_agent / extra_tool_count / visible_tool_count /
      filter_active so production logs show whether scoping is active.
    - Update the in-test `test_request()` helper to populate the new
      fields with safe defaults.

  src/openhuman/agent/triage/evaluator.rs
    - Update the triage `AgentTurnRequest` initializer to set
      `target_agent_id = Some("trigger_triage")` (for tracing) with
      `visible_tool_names: None` + `extra_tools: Vec::new()` because
      the classifier intentionally runs against an empty registry and
      emits a structured JSON decision rather than calling tools.

  src/openhuman/channels/runtime/dispatch.rs
    - Update the channel-message `AgentTurnRequest` initializer to set
      the three new fields to safe defaults (`None` / `None` / empty
      vec). Commit 4b will replace these with the real onboarding-flag
      based routing.

  src/openhuman/tools/impl/agent/mod.rs
    - Bug fix: `dispatch_subagent` previously took `_skill_filter:
      Option<&str>` but discarded the value, hardcoding
      `SubagentRunOptions::skill_filter_override = None`. That meant
      `SkillDelegationTool::execute()` synthesising
      `dispatch_subagent("skills_agent", ..., Some("gmail"))` never
      actually narrowed `skills_agent`'s tool list — so even with the
      orchestrator's view scoped, the spawned `skills_agent` subagent
      would still see the full Composio catalog. Drop the underscore,
      propagate `skill_filter` into `skill_filter_override`, and add a
      tracing log line to make this path observable. This is the
      downstream half of the #526 leak that commit 3's orchestrator-
      side scoping alone wouldn't have caught.

Tests: 8/8 `tool_loop` tests pass, 3/3 harness `tests.rs` cases pass,
323/324 agent module tests pass overall (the one failure is the same
pre-existing Windows-path bug in `self_healing::tool_maker_prompt_
includes_command` that fails identically on the upstream baseline).
No existing test expectations were changed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(dispatch): route channel messages to welcome/orchestrator by onboarding flag (#525)

Wires the per-agent tool scoping plumbing from commit 4a into the
channel-message dispatch path. Each incoming channel message now picks
the active agent — `welcome` pre-onboarding, `orchestrator` post — based
on `Config::onboarding_completed`, loads the matching definition from
the global `AgentDefinitionRegistry`, synthesises any `delegate_*` tools
the agent declares in its `subagents` field, and passes everything
through to `agent.run_turn` on the bus.

This is the half of #525 that makes the welcome agent actually run for
new users — the welcome definition has existed since upstream PR #522
but had no caller; nothing in dispatch consulted the onboarding flag,
so every channel message ran through the same generic tool loop with
the full registry exposed.

Changes:

  src/openhuman/channels/runtime/dispatch.rs
    - New `AgentScoping` struct carrying the three new `AgentTurnRequest`
      fields (`target_agent_id`, `visible_tool_names`, `extra_tools`)
      plus an `unscoped()` constructor for safe-fallback paths.
    - New async `resolve_target_agent(channel)` helper:
      * fresh `Config::load_or_init().await` per turn (no cache — the
        loader reads from disk every call, verified at
        `config/schema/load.rs:409`, so the welcome→orchestrator
        handoff is observed on the next message after
        `complete_onboarding(complete)` flips the flag, with no need
        for an explicit handoff event);
      * picks `"welcome"` or `"orchestrator"` based on the flag and
        emits a structured `[dispatch::routing] selected target agent`
        info trace recording the choice + the flag value, satisfying
        the #525 acceptance criterion `"agent-selection logs clearly
        record why each agent was selected at onboarding boundaries"`;
      * looks up the definition in `AgentDefinitionRegistry::global()`,
        gracefully falling back to `AgentScoping::unscoped()` (= legacy
        behaviour, no filter, no extras) if the registry isn't
        initialised or the definition isn't found, so a routing miss
        never fails the user message;
      * for agents with a non-empty `subagents` field, awaits
        `composio::fetch_connected_integrations(&config)` and runs
        `orchestrator_tools::collect_orchestrator_tools` to materialise
        per-turn delegation tools (`research`, `plan`, `delegate_gmail`,
        …). Agents with empty `subagents` get an empty extras vec.
    - New `build_visible_tool_set(definition, &extra_tools)` helper that
      returns `Some(union)` for `ToolScope::Named` agents (their named
      list ∪ the names of the synthesised delegation tools) and `None`
      for `ToolScope::Wildcard` agents to preserve the unfiltered
      semantics — so agents like `skills_agent` and `morning_briefing`
      that already work via `wildcard + category_filter` keep their
      existing behaviour without this layer interfering.
    - `process_channel_message` calls `resolve_target_agent` once per
      turn, drops the placeholder defaults from commit 4a, and feeds
      the real `target_agent_id`/`visible_tool_names`/`extra_tools`
      into `AgentTurnRequest`.
    - New imports: `AgentDefinition`, `AgentDefinitionRegistry`,
      `ToolScope`, `Config`, `fetch_connected_integrations`,
      `orchestrator_tools`, `Tool`, `HashSet`.

End-to-end behaviour after this commit:

  1. New user, `onboarding_completed=false`: dispatch picks `welcome`,
     loads its 2-tool TOML scope, builds `visible_tool_names =
     {complete_onboarding, memory_recall}`, no extras, hands off to
     the bus. Bus handler applies the filter → welcome's LLM sees
     exactly 2 tools.
  2. Welcome agent guides the user through setup, eventually calls
     `complete_onboarding(action="complete")` → flag persists to disk
     via `config.save()`.
  3. Next user message: dispatch reads the flag fresh, picks
     `orchestrator`, fetches connected Composio integrations, expands
     `subagents = ["researcher", "planner", "code_executor", "critic",
     "archivist", { skills = "*" }]` into delegate_research /
     delegate_plan / delegate_run_code / delegate_review_code /
     delegate_archive_session + one delegate_<toolkit> per connected
     integration. visible_tool_names is the union with the 4 direct
     tools from orchestrator's `[tools] named` list. LLM sees the
     scoped delegation surface, not the full 1000+ Composio catalog.

#526's runtime leak is now fixed end-to-end: the orchestrator's LLM
prompt only contains the tools its TOML allows, and the
SkillDelegationTool path narrows skills_agent to a single toolkit via
the `skill_filter` propagation fix from commit 4a. No agent at any
layer sees more than its definition declares.

Tests: 599/599 channel module tests pass — including
`runtime_dispatch::dispatch_routes_through_agent_run_turn_bus_handler`
and the telegram integration variant, which exercise the full bus
roundtrip with the new fields populated. No existing assertions were
modified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(debug_dump): apply orchestrator definition filter to main dump (#526)

Replaces the explicit `empty_filter: HashSet::new()` in
`render_main_agent_dump` with a real visibility whitelist derived from
the orchestrator's `AgentDefinition`. The "main" dump path now mirrors
the runtime dispatch path from commits 4a/4b — same definition, same
delegation tool synthesis, same filter — so `openhuman agent dump-prompt
main` shows what the LLM actually sees in production instead of the
unfiltered global registry.

Before this commit, `dump-prompt main` always rendered every tool in
the registry regardless of which agent was supposed to run, which is
exactly the symptom #526 reports: "agent prompt tool scopes leak full
GitHub tool catalog". The runtime fix in commit 4b stops the leak in
production but the dump path was the user's primary observability tool
for inspecting prompts, so leaving it unscoped would mask future
regressions and confuse debug sessions.

Changes in `src/openhuman/context/debug_dump.rs`:

  * `dump_agent_prompt` now loads `AgentDefinitionRegistry` once at the
    top (previously only loaded inside the sub-agent branch). When the
    request is for "main" or "orchestrator_main", it looks up the
    "orchestrator" definition from the registry and passes it into
    `render_main_agent_dump` along with the registry itself. Missing
    orchestrator entry → structured error listing known agents
    instead of silently rendering an unfiltered prompt.

  * `render_main_agent_dump` signature gains `registry:
    &AgentDefinitionRegistry` and `orchestrator_def: &AgentDefinition`.
    Inside, it:
      - calls `collect_orchestrator_tools(orchestrator_def, registry,
        connected_integrations)` to synthesise the same per-turn
        delegation tools (`research`, `plan`, `delegate_<toolkit>`,
        …) that dispatch generates;
      - extends `prompt_tools` with the synthesised extras so they
        contribute to the rendered tool catalogue;
      - builds `visible_filter: HashSet<String>` from
        `orchestrator_def.tools` (the `[tools] named` list) ∪ the
        names of the synthesised extras, falling back to an empty
        HashSet when the orchestrator definition uses
        `ToolScope::Wildcard` (which the prompt builder treats as "no
        filter, every tool visible") so dump consumers that supply a
        wildcard orchestrator (custom workspace overrides, tests)
        retain the legacy unscoped behaviour;
      - replaces `visible_tool_names: &empty_filter` in the
        `PromptContext` with `&visible_filter`;
      - filters the returned `tool_names` and `skill_tool_count` by
        the same predicate so the `DumpedPrompt` summary fields
        match what the prompt text actually contains.

Tests:

  * Replaces the previous `render_main_agent_dump_includes_tool_
    instructions_and_skill_count` test with two more focused cases:

    1. `render_main_agent_dump_wildcard_scope_shows_full_tool_set` —
       regression guard for the legacy wildcard path. Builds a
       wildcard-scoped orchestrator definition, asserts every tool
       from `tools_vec` survives, and checks the standard system-
       prompt skeleton (Tools section, Tool Use Protocol, cache
       boundary) still renders.

    2. `render_main_agent_dump_named_scope_filters_to_whitelist` —
       the #526 regression guard. Builds an orchestrator with
       `ToolScope::Named(["query_memory", "ask_user_clarification"])`
       and a `tools_vec` containing `shell`, `query_memory`, and
       `GMAIL_SEND_EMAIL`. Asserts the dump's `tool_names` is exactly
       `["query_memory"]` — `shell` and `GMAIL_SEND_EMAIL` are in the
       global registry but NOT in the whitelist, so they MUST be
       excluded. If a future change reintroduces the unfiltered
       behaviour this test fails immediately.

  * Adds two test helpers: `wildcard_orchestrator_def()` builds a
    minimal orchestrator definition with all `omit_*` flags set and
    `ToolScope::Wildcard`, and `registry_with_orchestrator(orch)`
    wraps it in an `AgentDefinitionRegistry` so the tests can call
    `render_main_agent_dump` without going through the full TOML
    loader.

11/11 debug_dump tests pass. The two new guards plus the 9 existing
sub-agent / filter / composio-stub tests all run clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(dispatch): unit tests for build_visible_tool_set + cargo fmt cleanup (#525, #526)

Adds focused unit tests for the per-agent scoping helper landed in
commit 4b and runs `cargo fmt` across the files touched by this PR.

Why scoping unit tests, not full integration tests:

  `resolve_target_agent` is async and reads `Config::load_or_init().await`
  which does a real disk read every call (no cache, verified at
  `config/schema/load.rs:409`). Mocking that requires either spinning up
  a full workspace under a temp dir with a config.toml containing the
  right `onboarding_completed` value, or adding a test-only injection
  point on the public Config API. Both are tractable but invasive
  enough to belong in their own follow-up PR. The end-to-end dispatch
  path is already covered by the existing channel integration tests
  (`dispatch_routes_through_agent_run_turn_bus_handler` etc.) which
  exercise the full bus roundtrip with the new fields populated, and
  which still pass after the new resolver landed (it gracefully falls
  back to `AgentScoping::unscoped()` when no orchestrator definition
  is registered in the test environment).

  Pure-function unit tests for `build_visible_tool_set` cover the
  branching logic that does the actual scoping work: how the named
  whitelist + extras union is built, how Wildcard scope is preserved,
  how duplicates are de-duplicated, etc. That's the part most likely
  to drift in future changes, so it's the part most worth fencing
  with focused tests.

Tests added (all in `src/openhuman/channels/runtime/dispatch.rs` under
the new `scoping_tests` module):

  * `wildcard_scope_yields_none_filter` — `ToolScope::Wildcard` must
    produce `None` regardless of whether extras are present, so
    skills_agent / morning_briefing keep their full skill-category
    catalogue.

  * `named_scope_without_extras_returns_named_only` — the welcome
    agent's path: 2 named tools, no delegation, exactly 2 entries in
    the visibility whitelist.

  * `named_scope_with_extras_returns_union` — the orchestrator's path:
    3 direct named tools + 3 synthesised extras (research,
    delegate_gmail, delegate_github) → 6 entries.

  * `empty_named_with_extras_returns_extras_only` — guards a future
    "delegation-only" agent layout where the agent has no direct tools
    of its own, just spawns subagents.

  * `empty_named_with_no_extras_returns_empty_set` — guards the
    distinction between `None` (no filter, all visible) and
    `Some(empty)` (filter active, nothing matches). Important because
    the prompt loop's `is_visible` check treats them differently.

  * `duplicate_names_across_named_and_extras_are_deduplicated` — the
    HashSet handles collisions automatically, but the test pins that
    behaviour so a future migration to `Vec<String>` (which would
    silently double-count) gets caught.

  * `agent_scoping_unscoped_has_no_filter_or_extras` — pins the
    safe-fallback constructor's contract. Used when the registry is
    uninitialised or the target agent is missing — every field must
    default to "no scoping" so the channel turn falls back to legacy
    unfiltered behaviour rather than crashing.

Plus `cargo fmt` run across the 6 files modified by this PR. No
behavioural changes.

Final test status across all commits 2-6 in this PR:

  * agent::harness::definition: 10/10  (4 new for Subagents schema)
  * agent::harness::tool_loop: 8/8 
  * agent::harness::tests: 3/3 
  * tools::orchestrator_tools: 5/5  (5 new)
  * channels::*: 599/599  (incl. dispatch integration)
  * channels::runtime::dispatch::scoping_tests: 7/7  (7 new)
  * context::debug_dump: 11/11  (1 replaced + 1 new)
  * Total agent module: 323/324 (one pre-existing Windows path
    failure in `self_healing::tool_maker_prompt_includes_command`
    confirmed identical against upstream/main baseline)

Pre-existing Windows-environment test failures NOT caused by this PR
and out of scope (all confirmed identical on upstream baseline; CI on
Linux is unaffected):

  * self_healing::tool_maker_prompt_includes_command (PathBuf separator)
  * cron::scheduler::run_job_command_success / _failure (Unix shell)
  * composio::trigger_history::archives_triggers_in_daily_jsonl... (path)
  * local_ai::paths::target_paths_preserve_absolute_overrides (path)
  * security::policy::checklist_root_path_blocked (POSIX absolute)
  * security::policy::checklist_workspace_only_blocks_all_absolute (POSIX)
  * tools::implementations::browser::screenshot::screenshot_command_
    contains_output_path (browser binary lookup)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(welcome): upgrade bare-install nudge with concrete integration pitches (#525)

Follow-up to #522 that addresses a rough UX edge in the welcome agent
prompt: users who arrive with only an API key configured (no
channels, no Composio integrations, no web search / browser / local
AI) were getting a "gentle suggestion" to connect something without
any concrete picture of what they'd actually unlock. The welcome
agent would finish onboarding, hand off to orchestrator, and leave
the user staring at a functional-but-empty assistant with no
roadmap for how to make it useful.

This commit rewrites the prompt to handle sparse setups explicitly.
No Rust changes — this is prompt.md only, picked up at build time
via the existing `include_str!` loader in `agents/mod.rs`.

Changes to `src/openhuman/agent/agents/welcome/prompt.md`:

  * Step 2's "point out what's missing" sub-point is rewritten from
    a single "gently suggest" line into a four-case decision tree
    keyed off `check_status` state:
      - No API key → critical, block completion.
      - Integrations yes, channels no → note the Tauri-only reach
        limitation, suggest a messaging platform.
      - Channels yes, integrations no → degraded assistant, nudge
        toward Composio.
      - Nothing beyond the API key → the "bare install" case, gets
        the new Step 2.5 treatment.

  * New **Step 2.5: Handling a bare install** section added after
    Step 2. Spells out what the user DOES have (sandboxed reasoning
    + coding assistant with memory), what they're MISSING (any
    external action), and how to structure the message: state the
    current capability honestly, pitch 2-3 specific integrations
    with concrete example prompts, point to Settings → Integrations
    / Channels, and leave room for the user to opt into the
    coding-only experience if that's what they actually want. For
    bare-install users the word budget stretches to 250-400 words
    (up from 200-350) so the concrete pitches and example prompts
    actually fit without cramming.

  * New **Integration capability reference** section giving the LLM
    a menu it can draw from when pitching integrations. Each entry
    is a one-line "connect X → I can Y" with a concrete example
    prompt the user could send next:
      - Gmail: "Summarise the most important emails that came in
        overnight and flag anything that needs a reply today."
      - Google Calendar: "What's on my calendar tomorrow, and do I
        have a 30-minute gap before 2pm?"
      - GitHub: "List open issues on my main project tagged 'bug'
        and summarise which ones look newest or most urgent."
      - Notion: "Pull up my 'Ideas' Notion database and show me
        the three newest entries."
      - Slack / Discord / Linear / Jira / etc. with similar shapes.

    Plus a sub-section for messaging platforms (Telegram / Discord /
    Slack / iMessage / WhatsApp / Signal / web-fallback) that
    clarifies which each is best for, and a sub-section for the
    other capabilities (web search, browser automation, HTTP
    requests, local AI) that explains what breaks without them.

    The LLM is told NOT to list everything — just pick 2-3 most
    likely to matter, defaulting to Gmail + GitHub + one of
    {Calendar, Notion} as the top-3 pitch when no profile context
    is available.

  * Tone guidelines updated to document the stretched word budget
    for bare installs (200-350 for configured users, 250-400 for
    bare installs).

  * "What NOT to do" list updated:
      - Explicitly allows product-tour-style listing ONLY in the
        bare-install case (Step 2.5), forbids it elsewhere.
      - Clarifies that describing what WOULD unlock with integration
        X is fine and encouraged; claiming a capability the user
        doesn't have is still forbidden.
      - Adds a new "Don't gloss over a bare install" entry that
        pins the rule: API-key-only users get concrete pitches and
        example prompts, not vague suggestions.

Scope note: this commit does NOT change the completion logic.
`complete_onboarding(complete)` still accepts API-key-only as the
minimum bar — that's a separate design question for the maintainer
about whether zero-integration users should be gatekept. This
change improves what the welcome agent SAYS to those users, not
whether they're allowed to proceed.

Tests: all 14 `agent::agents::tests` pass (including
`welcome_has_onboarding_and_memory_tools` which validates the
welcome agent's declarative shape is unchanged). The prompt.md
edit is pure content — no schema changes, no tool additions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(web-channel): route Tauri in-app chat to welcome/orchestrator + Windows fsync fix (#525)

Closes the web-channel-side half of the #525 welcome agent routing.
Also bundles a small pre-existing Windows compatibility fix for
`app_state::ops::sync_parent_dir` that was blocking end-to-end testing
of this branch on Windows.

## Web channel routing (primary change)

Commit 4b (274b50ed) wired the welcome-vs-orchestrator routing into
`channels/runtime/dispatch.rs::process_channel_message` — the handler
for inbound messages on external channels (Telegram, Discord, Slack,
iMessage, etc.). That path reads `config.onboarding_completed` and
selects the right agent via the bus.

End-to-end testing on the Tauri desktop app revealed a gap: the
in-app chat window does NOT route through `process_channel_message`.
It uses its own JSON-RPC method (`openhuman.channel_web_chat` →
`start_chat` → `run_chat_task` → `build_session_agent` →
`Agent::from_config`) which was hardcoded to build a generic
main-agent every turn. So a new user typing in the Tauri app saw the
orchestrator immediately, never the welcome agent.

This commit closes that gap without adding a third code path:

### `src/openhuman/agent/harness/session/builder.rs`

* Split the existing `Agent::from_config` into a thin wrapper + a
  new `Agent::from_config_for_agent(config, agent_id)`. The wrapper
  calls the new function with `agent_id = "orchestrator"`, preserving
  the legacy behaviour byte-identically for all existing callers
  (CLI, REPL, tests, every call site that doesn't care which agent
  they get).
* `from_config_for_agent` looks up `agent_id` in
  `AgentDefinitionRegistry::global()` up front; unknown ids fail
  fast with a clear error. Missing orchestrator is tolerated as a
  legacy / pre-startup fallback so existing tests that run without
  a populated registry continue to work.
* When a target definition is resolved:
    - `prompt_builder` uses `SystemPromptBuilder::for_subagent` with
      the agent's `system_prompt` body (read from the registry,
      which already has it inlined via `include_str!` from
      `agents/*/prompt.md` at crate-build time) and the three
      `omit_*` flags from its TOML.
    - `visible_tool_names` is built from the agent's
      `ToolScope::Named` list, unioned with the names of any
      synthesised delegation tools produced by
      `collect_orchestrator_tools` (for agents that declare
      `subagents = [...]`).
    - `ToolScope::Wildcard` yields an empty filter, matching the
      legacy "no filter, all visible" semantics.
    - `temperature` comes from the agent's TOML (e.g. welcome is
      0.7, orchestrator is 0.4) instead of
      `config.default_temperature`.
* Legacy behaviour for plain `from_config(config)` — which passes
  `agent_id = "orchestrator"` — is preserved: the prompt builder
  continues to use `SystemPromptBuilder::with_defaults()`, temperature
  comes from `config.default_temperature`, and the orchestrator's
  `subagents` list drives delegation tool synthesis exactly as
  before. No test expectations changed.

### `src/openhuman/channels/providers/web.rs`

* `build_session_agent` now reads `config.onboarding_completed` and
  picks `"welcome"` or `"orchestrator"` as the target agent id, then
  calls `Agent::from_config_for_agent`. Structured info log records
  the routing decision + the flag state for observability, matching
  the `[dispatch::routing]` trace pattern from Commit 4b.
* `config.onboarding_completed` is read fresh every turn because
  `run_chat_task` loads the config via
  `config_rpc::load_config_with_timeout`, which reads from disk on
  every call (no in-process cache). Welcome → orchestrator handoff
  therefore happens automatically on the next chat message after
  `complete_onboarding(complete)` flips the flag, with no explicit
  event or notification plumbing.
* `set_event_context` call is unchanged — the event context is a
  (session_id, channel_name) pair for telemetry, not the agent id.

## Windows fsync fix (bundled)

### `src/openhuman/app_state/ops.rs`

`sync_parent_dir` was calling `File::open(parent).and_then(|dir|
dir.sync_all())` on the save path for `app_state.json`. On Windows,
opening a directory as a regular file requires
`FILE_FLAG_BACKUP_SEMANTICS` which `std::fs::File::open` does not
set, so the call fails with "Access is denied. (os error 5)".

Mirrored the existing `#[cfg(unix)]` guard already in
`config/schema/load.rs::sync_directory`. On non-Unix the function is
a no-op and returns `Ok(())`. Durability on Windows is provided by
`NamedTempFile::persist`'s atomic `MoveFileEx`, which is sufficient
for config files.

This is a pre-existing upstream bug, not caused by this PR, but it
blocks end-to-end testing of any code path that touches
`app_state::save_stored_app_state_unlocked` on Windows — which
includes the web channel's agent_server_status RPC. Fixing it here
so the #525 routing can actually be tested on the developer's
machine, and the fix is tiny and self-contained. The `File` import
on line 1 is also gated behind `#[cfg(unix)]` to avoid an
unused-import warning on Windows.

## Tests

All 35 `agent::harness::session` tests pass, including the
transcript round-trip + session queue + runtime tests that exercise
the builder path most heavily. No test expectations were modified;
the refactor is a pure delegation chain (`from_config` → new inner
method → existing builder).

CLI dump-prompt testing from the earlier session still validates:
  * `openhuman agent dump-prompt welcome` → 2 tools, Step 2.5
    bare-install prompt embedded
  * `openhuman agent dump-prompt main` → 6 tools, zero skill leakage
  * `openhuman agent dump-prompt main --stub-composio` → still 6
    tools, composio meta-tools correctly filtered out

End-to-end Tauri testing (next step after this commit) will exercise
the new `build_session_agent` branch live — user types `hi`, web
channel routes to welcome, welcome replies with the updated
bare-install prompt from commit 990a7264.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(config): split chat_onboarding_completed from React UI onboarding flag (#525)

End-to-end testing on the Tauri desktop app revealed that the welcome
agent could never run from the in-app chat pane — even with all of
Commits 4b/8 in place — because the React layer's
`OnboardingOverlay.tsx` renders a full-screen wizard whenever
`onboarding_completed = false` and gates the chat pane behind it. By
the time a user can type a chat message the React wizard has already
flipped `onboarding_completed = true` (via `OnboardingOverlay::handleDone`
or the `Onboarding.tsx` wizard's completion handler), so the welcome
agent's routing condition is never satisfied on the Tauri surface.

This commit fixes the architectural conflict by splitting the single
`onboarding_completed` flag into two orthogonal flags:

* **`onboarding_completed`** — unchanged semantics. Tracks whether the
  React UI wizard has been completed/dismissed. Continues to be set
  by `OnboardingOverlay.tsx::handleDone` and `Onboarding.tsx` via the
  existing `config.set_onboarding_completed` JSON-RPC method. Used
  exclusively to gate whether the React layer renders the wizard.

* **`chat_onboarding_completed`** — NEW. Tracks whether the welcome
  agent's chat-based greeting flow has run. Set exclusively by the
  welcome agent itself via `complete_onboarding(action="complete")`.
  Used by both the Tauri web channel
  (`channels::providers::web::build_session_agent`) and the external
  channel dispatch path (`channels::runtime::dispatch::resolve_target_agent`)
  to decide whether to route to welcome or orchestrator.

The two flags are intentionally orthogonal:

  * A Tauri desktop user completes the React wizard → only
    `onboarding_completed` flips → wizard disappears → user types `hi`
    → welcome agent runs (because `chat_onboarding_completed` is still
    false) → welcome calls `complete_onboarding(complete)` →
    `chat_onboarding_completed` flips → next chat turn routes to
    orchestrator.

  * A Telegram/Discord user (no React wizard exists) sends a message
    → external channel dispatch checks `chat_onboarding_completed` →
    routes to welcome → welcome runs → flips the flag → next inbound
    message routes to orchestrator.

Both paths give every user the chat welcome experience, regardless of
which surface they came in through, and without requiring the React
wizard to be removed or restructured.

## Files changed

`src/openhuman/config/schema/types.rs`
* Add `chat_onboarding_completed: bool` field with `#[serde(default)]`
  for backward compat — existing `config.toml` files that don't have
  the field default to `false`, which means existing users will see
  the welcome agent on their next chat turn (correct behaviour, the
  welcome agent is idempotent).
* Default impl initializes the new field to `false`.
* Extensive rustdoc on both flags explaining the orthogonal split,
  why two flags exist, and which code paths gate on which.

`src/openhuman/tools/impl/agent/complete_onboarding.rs`
* `check_status` now reports BOTH flags side-by-side so the welcome
  agent's LLM can see whether the React wizard has run AND whether
  the chat welcome itself has run. Old single "Onboarding completed"
  line replaced with two lines: "UI onboarding wizard completed" and
  "Chat welcome flow completed".
* `complete()` now flips `chat_onboarding_completed`, NOT
  `onboarding_completed`. The React UI's flag is left untouched —
  that's owned by the React layer. Idempotency guard updated to
  check the chat flag.
* Rustdoc on `complete()` explains the orthogonal-flags rationale
  for future readers.

`src/openhuman/channels/providers/web.rs::build_session_agent`
* Reads `effective.chat_onboarding_completed` instead of
  `effective.onboarding_completed` for the welcome-vs-orchestrator
  decision.
* Log line now includes BOTH flags so observability captures the
  full picture (e.g. `chat_onboarding_completed=false,
  ui_onboarding_completed=true` is the expected steady state for a
  Tauri user who completed the React wizard but hasn't typed yet).

`src/openhuman/channels/runtime/dispatch.rs::resolve_target_agent`
* Same flag swap for the external-channel path.
* `[dispatch::routing] selected target agent` info trace also
  reports both flags.
* Function-level rustdoc updated with the new semantics.

## Backward compatibility

* Existing `config.toml` files: `chat_onboarding_completed` defaults
  to `false` via `#[serde(default)]`. Means existing users get a
  welcome message on their next chat turn — this is the desired
  behaviour, not a regression.
* React layer: untouched. The `config.set_onboarding_completed`
  JSON-RPC method continues to write the same field; the new flag is
  not exposed to React at all.
* External callers of `complete_onboarding(complete)`: they now flip
  a different flag. This may matter for callers who were depending
  on the flag flip side effect; a quick grep shows
  `tools/impl/agent/complete_onboarding.rs` is the only caller and
  the rest of the codebase reads `onboarding_completed` for UI
  purposes (snapshots, app state) and `chat_onboarding_completed`
  for routing — the split is clean.

## Tests

399/400 tools tests pass. The single failure
(`tools::impl::browser::screenshot::screenshot_command_contains_output_path`)
is a pre-existing Windows-environment bug that fails identically on
`upstream/main` baseline and is unrelated to this PR. No test
expectations were modified.

## Next step

Restage sidecar, relaunch Tauri, click through the React wizard once
to dismiss it, then type `hi` in the chat pane. This time
`build_session_agent` should read `chat_onboarding_completed=false`
and route to welcome, even though `onboarding_completed=true` was set
by the React layer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(welcome): force tool-call-first iteration to stop greeting fallback (#525)

End-to-end testing on the Tauri desktop app revealed that the welcome
agent — even with all routing/scoping commits in place and the
correct prompt embedded — was producing 1-line greetings like
"Hey! What's up?" (15 chars, single iteration, zero tool calls)
instead of running its workflow. The user typed `hihi`, the LLM saw
the welcome agent's full ~14KB persona prompt, and chose to ignore
every workflow step in favour of the chatbot fallback behaviour.

Diagnosis: the previous prompt was descriptive ("Call check_status to
get a snapshot...") rather than imperative. Combined with a "Concise"
tone guideline and a low-information user input ("hihi"), the model
under tension between "be warm and concise" and "follow the workflow"
collapsed to "tiny greeting" — which is the chatbot training-data
default for short user messages.

This commit makes the workflow non-negotiable by:

## 1. New "MANDATORY FIRST ACTION" preamble at the top of the prompt

Inserted as a blockquote between the role description and "Your
workflow" so the model encounters it before any workflow steps. The
preamble:

* States explicitly that the **first thing emitted on every turn must
  be a tool call** to `complete_onboarding(check_status)` — before
  any user-facing text, before any greeting, before any thinking out
  loud.
* States the user's input is **irrelevant** to this rule: "hi",
  "hello", emoji, nothing — the first iteration always emits the
  same thing, a check_status tool call.
* Explains *why* the rule exists (without a status snapshot the
  agent has nothing to personalise on, and any blind greeting
  defeats the welcome agent's one job).
* Shows three concrete  wrong examples (generic chitchat reply,
  greeting-then-tool, refusing the tool because the user said hi)
  and one  correct example (first iteration emits ONLY the tool
  call, message comes in iteration 2).
* Closes with a stop-and-correct rule: "If you find yourself about
  to write any text in your first iteration, STOP. Emit the tool
  call instead."

## 2. Strengthened "Your workflow / Step 1" heading

Renamed to "Step 1: Check setup status (ALWAYS — see Mandatory First
Action above)" so the cross-reference is unmissable. Body explicitly
says "In your **first iteration**, ... No text. No greeting. Just
the tool call. ... You will use this report to write the actual
welcome message in your second iteration."

## 3. Length is non-negotiable (rewritten "Concise" tone guideline)

The previous "Concise — but scale with the situation" framing was the
exact phrase the model latched onto when producing 15 chars. Rewritten
to "Length is non-negotiable" and adds:

* "A 1-2 sentence greeting is a failure, not a 'concise' success."
* Explicit "if you ever produce a message under 100 words, stop and
  try again — you've almost certainly skipped a required element."
* A "concise vs. terse" callout distinguishing "no wasted words in a
  message that does its full job" from "skip the job entirely."

## 4. New "What NOT to do" entries

Three new bullets target the exact failure modes observed:

* The existing "Don't skip check_status" entry is upgraded with
  "**This is the single most common failure mode**" and a pointer
  back to the mandatory-first-action preamble.
* New "Don't reply with a 1-line greeting" entry forbids the chatbot
  fallback explicitly and sets a 100-word floor.
* New "Don't treat 'hi' / 'hello' / short greetings as a signal to
  be brief" entry explicitly disconnects user input length from
  agent output length, since "hi" is the most common opening and
  the user typing it needs the FULL welcome experience.

## What this does NOT change

* No Rust code changes. `prompt.md` is `include_str!`'d into the
  binary at crate-build time via `agents/mod.rs`, so the next sidecar
  rebuild picks up the new content automatically.
* No agent definition changes (`agent.toml` untouched). The welcome
  agent still has 2 tools (`complete_onboarding` + `memory_recall`),
  `temperature = 0.7`, `max_iterations = 6`, `omit_*` flags
  unchanged.
* No model hint change. Still `"agentic"`. If the new prompt language
  alone isn't enough to push the model over the workflow-following
  threshold, a follow-up commit can bump to `"reasoning"` for
  stronger instruction-following.
* The integration capability reference, Step 2.5 bare-install
  handling, subscription/referral flow, and handoff sections are
  unchanged from commit 990a7264. Those were never the problem —
  the problem was the model never reaching them because it skipped
  Step 1 entirely.

## Tests

14/14 `agent::agents::tests` pass, including
`welcome_has_onboarding_and_memory_tools` and `every_builtin_has_a_
prompt_body`. The structural shape of the welcome agent definition
is byte-identical; only the embedded prompt body changed.

## Verification plan

After restaging the sidecar and relaunching Tauri, type `hi` in the
chat pane. Expected behaviour:

1. `[web-channel] routing chat turn to 'welcome'` (already validated
   in commit 56d95e2c).
2. `[agent::builder] building session agent id=welcome` (already
   validated).
3. **`[agent_loop] iteration start i=1`** with a `check_status` tool
   call as the first emission — this is the new behaviour we're
   verifying.
4. `[agent_loop] iteration start i=2` after the tool returns, with
   the actual welcome message — should be 100-400 words covering all
   required elements.
5. If everything's in place, `complete_onboarding(complete)` call in
   the same turn or a later iteration → `chat_onboarding_completed`
   flips to `true` → next chat turn routes to orchestrator.

If iteration 1 still produces text instead of a tool call, the next
escalation is bumping the model hint from `"agentic"` to
`"reasoning"` in `agent.toml`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(complete_onboarding): check session JWT in addition to legacy api_key (#525)

Discovered during end-to-end Tauri testing that the welcome agent
refused to call `complete_onboarding(complete)` for a fully
authenticated user because `check_status` reported "API key:
**missing**" — even though the user had completed the desktop OAuth
flow and a valid encrypted JWT was sitting in `auth-profiles.json`.

## Root cause

`check_status` was reading only `config.api_key` (an `Option<String>`
on the Config struct), which is a **legacy free-form provider key
field**. It is not where the desktop OAuth flow stores its
credentials. The actual openhuman backend session JWT lives in
`<openhuman_dir>/auth-profiles.json` under the `app-session:default`
profile, encrypted via the SecretStore using `<openhuman_dir>/.secret_key`,
and is read at every inference RPC via
`crate::api::jwt::get_session_token(config)` (which delegates to
`AuthService::from_config(config).get_profile(APP_SESSION_PROVIDER, None)`).

So a user who:
1. Completed the desktop deep-link OAuth flow (the only supported
   way to get an account), and
2. Has a fully populated `auth-profiles.json` with a valid encrypted
   `app-session:default` token,

was reported by `check_status` as "API key missing" because their
JWT lives in the auth profile store, not in `config.api_key`. The
welcome agent then dutifully refused to call `complete_onboarding(
complete)` per its own prompt rule ("If critical setup is missing,
do not complete onboarding"), and re-ran on every chat turn forever
even though there was nothing to fix.

This is a **pre-existing upstream bug** dating from PR #522 (the
welcome agent's introduction). It was written before, or in parallel
with, the auth-profile refactor that moved session credentials out of
`config.api_key` into the dedicated profile store. The check was
never updated to reflect the new auth model.

## Fix

`check_status` now checks BOTH sources:

```rust
let has_legacy_api_key = config.api_key.as_ref().map_or(false, |k| !k.is_empty());
let has_session_jwt = crate::api::jwt::get_session_token(&config)
    .ok()
    .flatten()
    .is_some_and(|t| !t.is_empty());
let is_authenticated = has_legacy_api_key || has_session_jwt;
```

The status report now shows:
* `Authentication: configured ✓ (session token from desktop login)` —
  when the user has gone through the OAuth flow (the typical case);
* `Authentication: configured ✓ (legacy api_key)` — when the user
  has set the legacy `config.api_key` field directly (CI / dev
  setups);
* `Authentication: **missing** — log in via the desktop app or set
  `api_key` in config to enable inference` — when neither source has
  a credential.

The line label changed from "API key" to "Authentication" because
"API key" was misleading for both states (it isn't really the user's
API key; it's the openhuman backend session JWT).

## What this unblocks

After this fix, a Tauri user who:
1. Logs in once via the desktop OAuth flow → JWT lands in
   `auth-profiles.json`,
2. Types `hi` in the chat pane → welcome agent runs,
3. Welcome calls `check_status` → reports "Authentication: configured ✓",
4. Welcome calls `complete_onboarding(complete)` → flips
   `chat_onboarding_completed` from false to true,
5. Next chat turn → routes to orchestrator.

Without this fix, step 4 never happens because welcome's prompt
explicitly forbids completing without an API key, so the user gets
the welcome message on every single turn forever.

## Files touched

`src/openhuman/tools/impl/agent/complete_onboarding.rs`
* `check_status` reads both `config.api_key` and
  `crate::api::jwt::get_session_token(&config)`.
* Status line renamed from "API key" to "Authentication" with a
  more informative message.
* Long inline comment documenting the two auth sources, why the
  check needs to consult both, and what bug was being fixed.

## Tests

360/361 tools tests pass. Only failure is the pre-existing Windows
browser-screenshot bug (`tools::impl::browser::screenshot::
screenshot_command_contains_output_path`) which fails identically on
upstream/main and is unrelated to this PR. The
`complete_onboarding::tool_metadata` test still passes — it only
checks the tool schema, not the runtime behaviour.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(welcome): force complete_onboarding(complete) in iteration 2 (#525)

Live Tauri test after Commit 11 (auth-aware check_status) showed the
welcome agent completing iteration 1 with the correct check_status
tool call AND iteration 2 with a beautiful 1586-char welcome message
— but NOT calling complete_onboarding(action="complete") to flip
chat_onboarding_completed. So the user gets the welcome message and
then routes to welcome AGAIN on every subsequent chat turn forever,
because the prompt's Step 3 is descriptive rather than imperative.

Same failure pattern as the iteration-1 chitchat fallback that
Commit 10 fixed — the LLM follows the path of least resistance. If
the prompt says "call X to finalize" without making it mandatory,
the model will write the welcome text, see that it's done its job,
and stop without emitting the second tool call.

Fix: rewrite Step 3 with the same "MANDATORY" preamble pattern that
worked for the iteration-1 fix. Specifically:

* Renamed Step 3 to "Complete onboarding — MANDATORY in iteration 2
  (when authenticated)" so the cross-reference is unmissable.
* Added a blockquoted "MANDATORY SECOND TOOL CALL" preamble at the
  top of Step 3 that:
  - States the rule: when check_status reports "Authentication:
    configured ✓", iteration 2 MUST contain BOTH the welcome message
    text AND a complete_onboarding(action="complete") tool call.
  - Explains the consequence: without the tool call, the user is
    routed to welcome forever, which is a hard failure.
  - Defines the iteration 2 output structure as a 2-element list:
    welcome text + tool call.
  - Shows / examples (welcome message without tool call vs. with
    tool call) so the model has a concrete pattern to match.
  - Documents the single exception: only when authentication is
    missing should complete() be skipped, and explicitly says
    "missing channels, missing Composio integrations, missing local
    AI — none of those block completion." Auth is the only blocker.
* Replaced the descriptive bullet list with a stricter "Decision
  rule for iteration 2" that pairs each check_status outcome with
  exactly what the agent must emit.

## What stays the same

* No code changes — the existing complete_onboarding tool already
  handles the action="complete" call correctly (Commit 11 made it
  flip the right flag and check the right auth source).
* No agent.toml changes — welcome still has 2 tools, max_iterations=6,
  temperature=0.7. Plenty of headroom for the 2-iteration flow.
* The decision logic itself is unchanged — auth → complete, no auth
  → no complete. Just the framing.

## Test plan

Restage sidecar, relaunch Tauri, type `hi`. Expected:

```
i=1: complete_onboarding(check_status) → "Authentication: configured ✓"
i=2: 1500-2000 char welcome message + complete_onboarding(action="complete")
       ↓
     [complete_onboarding] chat welcome flow marked complete, proactive agents seeded
       ↓
     chat_onboarding_completed flips false → true
```

Then type a follow-up message → expect:

```
[web-channel] route → orchestrator (chat_onboarding_completed=true)
```

That's the welcome → orchestrator handoff — the actual goal of #525.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web-channel): include target_agent_id in THREAD_SESSIONS cache key (#525)

Final bug discovered during E2E testing: after Commit 12 made the
welcome agent successfully call complete_onboarding(complete) and
flip chat_onboarding_completed to true, the user typed a follow-up
message — but the next turn STILL routed through the welcome agent
instead of orchestrator.

## Root cause

`run_chat_task` caches the built `Agent` in a `THREAD_SESSIONS`
HashMap keyed by `(client_id, thread_id)`. The cache hit predicate
checked `model_override` and `temperature` for invalidation but not
the routing target — so when the routing decision flipped
(chat_onboarding_completed: false → true) between turns, the cache
happily returned the stale welcome agent and `build_session_agent`
was never invoked.

This was a real bug introduced by Commit 8 — when I added
agent-aware routing to `build_session_agent`, I should have
extended the cache key (or hit predicate) with the target agent id.
Without that, the routing fix only worked on the FIRST turn of any
thread; every subsequent turn served the cached agent regardless of
flag changes.

Symptoms in the live test:
* Turn 1: routes to welcome ✓, welcome runs 3 iterations, calls
  complete(), flag flips on disk → cached as welcome agent
* Turn 2: cache hits on (client_id, thread_id), returns cached
  welcome agent. NO `[web-channel]` routing trace, NO
  `[agent::builder]` trace. Welcome runs again with history_len=10.
* Result: orchestrator handoff never happens. User stuck in welcome
  forever.

## Fix

Three small changes in `web.rs`:

### 1. `SessionEntry` gains a `target_agent_id` field

```rust
struct SessionEntry {
    agent: Agent,
    model_override: Option<String>,
    temperature: Option<f64>,
    target_agent_id: String,  // NEW
}
```

Documents which agent definition was used to build the cached
`Agent`, so the next turn's cache lookup can compare against the
current routing decision.

### 2. New `pick_target_agent_id(config)` helper

```rust
fn pick_target_agent_id(config: &Config) -> &'static str {
    if config.chat_onboarding_completed {
        "orchestrator"
    } else {
        "welcome"
    }
}
```

Mirrors the routing decision inside `build_session_agent` so
`run_chat_task` can compute it once up front and use it as a cache
key component. Since `Config::load_or_init` reads from disk every
call, the value reflects the freshly persisted state — meaning the
moment the welcome agent flips the flag, the next turn's
`pick_target_agent_id` returns the new value, the cache hit
predicate falls through, and `build_session_agent` is invoked.

### 3. Cache hit predicate extended

```rust
let mut agent = match prior {
    Some(entry)
        if entry.model_override == model_override
            && entry.temperature == temperature
            && entry.target_agent_id == target_agent_id =>
    {
        log::info!("[web-channel] reusing cached session agent id={} ...");
        entry.agent
    }
    Some(prior_entry) => {
        log::info!(
            "[web-channel] cache miss — rebuilding session agent \
             (was id={}, now id={}) ...",
            prior_entry.target_agent_id, target_agent_id
        );
        build_session_agent(...)?
    }
    None => build_session_agent(...)?,
};
```

The `Some(prior_entry)` arm now distinguishes stale-cache hits
(rebuild + log the transition) from cold misses (rebuild silently).
The transition log line gives observability for the welcome →
orchestrator handoff: whenever you see "cache miss — rebuilding
session agent (was id=welcome, now id=orchestrator)", that's the
moment of the handoff in production.

## Tests

Library compiles. The cache-key change is small and the logic is
straightforward; no test changes needed (existing tests don't
exercise the `THREAD_SESSIONS` cache flow, and adding a unit test
would require mocking the global config + memory backend which is
significantly more setup than the change itself warrants).

Live verification: type `hi` (welcome), wait for the "all set"
message + flag flip, type a follow-up. Expected logs:

```
[web-channel] routing chat turn to 'welcome'  (turn 1)
[agent::builder] building session agent id=welcome
... welcome runs, calls complete(), flag flips ...

[web-channel] routing chat turn to 'orchestrator'  (turn 2)
[web-channel] cache miss — rebuilding session agent (was id=welcome, now id=orchestrator)
[agent::builder] building session agent id=orchestrator
```

That's the complete welcome → orchestrator handoff that has been
the goal of #525 since day one.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(complete_onboarding): document tool semantics in schema, terse success result (#525)

Stopping the prompt-bloat cycle on the welcome agent. Previous
commits (10, 12, and a half-applied iteration of 14) progressively
added MANDATORY blockquotes to welcome's prompt.md to fix three
distinct failure modes: chitchat-fallback in iter 1, missing
complete() in iter 2, and prose-leak in iter 3. Each fix worked but
the welcome prompt is now ~250 lines of imperatives encoding what
should be tool semantics, not agent persona.

The right home for tool semantics is the tool's own schema —
specifically `Tool::description()` and `parameters_schema()`. Those
fields are what the LLM sees when deciding when and how to call the
tool, and they apply to every agent that uses the tool, not just
welcome. Encoding the contract there lets the welcome prompt stay
about persona / workflow / tone, not implementation details.

## Changes

### `tools/impl/agent/complete_onboarding.rs`

#### `Tool::description()` — full rewrite

Replaces the previous 5-line description with a structured ~30-line
contract documenting BOTH actions:

* **`check_status`** — explicit list of what the report contains
  (auth, default model, channels, integrations, memory, both
  onboarding flags), explicit "side effects: NONE (read-only)"
  declaration, and explicit framing as "intended for an LLM agent
  to read and use as the basis for a personalized welcome message"
  so consumers know how to use the result.

* **`complete`** — explicit semantics: flips
  `chat_onboarding_completed`, triggers welcome→orchestrator
  handoff, seeds proactive cron jobs, idempotent, writes config.toml,
  has a pre-condition (must be authenticated). And critically:

  > The complete action returns the literal token "ok" on success.
  > **This return value is a machine-readable success marker, not
  > user-facing prose.** Do not paraphrase it, summarize it, or
  > acknowledge it back to the user — the actual user-facing welcome
  > text should have been emitted alongside the tool call in the
  > same iteration. The chat layer extracts the LAST iteration's
  > text as the user-visible reply, so any prose written after this
  > tool returns will overwrite the welcome message in the chat pane.

  This is the contract that prevents the iter-3 paraphrase leak,
  documented at the source of the tool result instead of in every
  consumer's prompt.

#### `parameters_schema()` — extended action description

The `action` enum's description now mirrors the contract:
> "check_status" → read-only inspection ... "complete" → finalize
> the chat welcome flow, flips chat_onboarding_completed to true,
> returns the literal token "ok" (NOT a user-facing message — do
> not paraphrase the result back to the user).

JSON-schema descriptions are seen by the LLM at function-calling
decision time, so the warning lands in the same place the LLM is
deciding whether to call the tool.

#### `complete()` return value

Changed from a 118-char chatty success string ("Chat welcome flow
marked as complete. Morning briefing and proactive agent jobs have
been set up. The user is all set!") to the literal 2-char `"ok"`.

The chatty string was the source of the iter-3 paraphrase leak —
the LLM kept treating it as something to summarize back to the user
in iteration 3, which overwrote the iter-2 welcome message in the
chat pane (because the chat layer extracts the last iteration's
text). With "ok" there's nothing to paraphrase.

The inline comment block on the return statement records the bug
history so a future maintainer who sees `Ok(ToolResult::success("ok"))`
and is tempted to make it more "informative" understands why it's
deliberately terse.

### `agents/welcome/prompt.md`

Reverts the half-applied "Step 6: STOP after iteration 2" blockquote
that I started adding in the previous commit attempt. Replaces it
with a single one-line pointer at the end of Step 5:

> "(See the `complete_onboarding` tool's own description for what
> its `"ok"` return value means and why you should not paraphrase it
> back to the user.)"

That's enough context for the welcome agent to understand the
return-value contract without duplicating the contract text. The
contract lives in the tool, not in the agent that consumes it.

## What this DOESN'T touch

* Commits 10 (mandatory first action) and 12 (mandatory second tool
  call) stay as-is. They're still arguably too forceful, but they
  encode workflow steps specific to the welcome agent's persona,
  not general tool semantics. Trimming them is a separate prompt
  audit follow-up.

* No code changes outside `complete_onboarding.rs`. No agent.toml
  changes, no schema changes, no other tool changes. Pure
  documentation + return-value tweak.

## Tests

`tool_metadata` still passes (it pins name, scope, permission_level,
and schema shape — not description text, which is intentionally
allowed to drift). 1/1 complete_onboarding tests pass. Library
compiles clean.

## Test plan

Restage sidecar, relaunch Tauri, type `hi`. Expected:
* iter 1: check_status → 600-char status report
* iter 2: 1500-char welcome message + complete_onboarding(complete)
* iter 3 (if it fires): no prose, or trivial prose ≪ iter 2 chars
* Chat pane shows the iter-2 welcome message, NOT a confirmation
  paraphrase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(complete_onboarding): merge check + finalize, return JSON, halve welcome prompt (#525)

End-to-end testing surfaced the cleanest version of this design,
collapsing what had grown into a multi-iteration imperative dance
back into a single tool call with a JSON return value. Three
problems addressed in one pass:

## Problem 1 — two tool calls were a state-machine race condition

Previous design required the welcome agent to call two tools in
order: `complete_onboarding(check_status)` in iteration 1, then
`complete_onboarding(complete)` in iteration 2 alongside the welcome
text. This created a fragile state machine: if the model wrote the
welcome message but forgot the second tool call, the chat_onboarding
flag never flipped and the user got stuck in welcome forever. The
prompt grew a 50-line "MANDATORY SECOND TOOL CALL" blockquote with
multiple / examples to compensate for what was really an API
shape problem.

## Problem 2 — Markdown report fed paraphrase loops

`check_status` returned a 600-char human-readable Markdown report
with section headers, bullet points, and check marks. The LLM kept
treating it as a draft and paraphrasing fragments back into the
welcome message instead of using the field values as ground-truth
facts. Worse, the structured-looking output gave the model
permission to "wrap up" the tool result in iteration 3, producing
the (parenthetical) leak text the user kept seeing.

## Problem 3 — prompt was bloated past the point of usefulness

After three rounds of "the LLM did the wrong thing, add a more
forceful blockquote", the welcome prompt was ~250 lines, with
overlapping MANDATORY blocks, repeated / examples, and
imperatives that contradicted the tone guidelines. A model trying
to serve a coherent welcome persona could not emerge from that.

## Fix — single call, JSON return, auto-finalize as a side effect

`check_status` now does all of:

1. Reads the user's config.
2. Checks JWT via `crate::api::jwt::get_session_token` (the auth
   source the rest of the codebase uses).
3. **If authenticated AND chat_onboarding_completed is false, flips
   the flag + seeds proactive cron jobs as a side effect**. No
   second tool call, no parameter, no opt-in. Authentication is the
   only gate.
4. Returns a structured JSON snapshot:
   ```
   {
     "authenticated": true,
     "auth_source": "session_token",
     "default_model": "reasoning-v1",
     "channels_connected": ["telegram"],
     "active_channel": "web",
     "integrations": {
       "composio": false,
       "browser": false,
       "web_search": true,
       "http_request": false,
       "local_ai": true
     },
     "memory": { "backend": "sqlite", "auto_save": true },
     "delegate_agents": [],
     "ui_onboarding_completed": true,
     "chat_onboarding_completed": true,
     "finalize_action": "flipped"
   }
   ```

The `finalize_action` field discriminates three cases the welcome
agent needs to handle differently:

* `"flipped"` — auth ok, first welcome, flag was just flipped by
  this call. Write the full welcome and hand off.
* `"already_complete"` — auth ok, chat flow already done from a
  prior call. Friendly re-entry; still write a welcome but
  acknowledge they've been here before.
* `"skipped_no_auth"` — not authenticated. Don't write a celebratory
  welcome; explain the auth problem and let them retry on the next
  chat turn.

The `complete` action stays as a legacy manual finalize-only escape
hatch for admin tools / tests. Returns "ok". Welcome agent doesn't
use it.

## Welcome prompt rewrite

Down from ~250 lines to ~140. Specifically removed:

* "MANDATORY FIRST ACTION" blockquote (40 lines) — replaced with
  one tight paragraph in the new "Iteration 1: call
  complete_onboarding" section.
* "MANDATORY SECOND TOOL CALL" blockquote (50 lines) — entirely
  gone. There is no second tool call.
* "STOP after iteration 2" Step 6 from a previous failed iteration
  (never landed in this branch but the structure that motivated it
  is gone too).
* "Decision rule for iteration 2" (5 lines) — replaced with a
  three-bullet list keyed off `finalize_action`.
* Three duplicate "Don't reply with a 1-line greeting" / "Don't
  treat 'hi' as a signal to be brief" rules — collapsed into one
  shorter rule.

Added:

* "You have exactly two iterations. There is no third." — single
  declarative sentence that does the work the previous 90 lines of
  iteration imperatives were doing.
* Explicit framing that JSON is a fact source, not a draft. "Don't
  quote, paraphrase, or summarise the JSON snapshot back to the
  user."
* `finalize_action` decision tree (three bullets) so the agent
  knows what message to write for each case without the prompt
  needing to enumerate side effects.
* Handling for `skipped_no_auth` — short, helpful "you need to log
  in first" message instead of the celebratory welcome.

## Why this works without any iteration-2 imperatives

With a single tool call, the agent loop converges naturally:

* iter 1: tool call (the only thing the prompt allows)
* tool returns: JSON snapshot, flag already flipped server-side
* iter 2: prose response (no remaining tool calls to make)
* iter 3: doesn't fire because there's no incomplete work — the
  loop terminator at `turn.rs:351` returns when `calls.is_empty()`,
  which is iter 2's state.

No race, no forgotten flip, no paraphrased tool result, no
parenthetical leak.

## Files

`src/openhuman/tools/impl/agent/complete_onboarding.rs` — full
rewrite. New `check_status` builds the JSON, performs the auto-
finalize side effect, returns serialised JSON via `ToolResult::
success`. Old Markdown-report code deleted (~150 lines). Tool
description and `parameters_schema` rewritten to document the new
contract; the `finalize` parameter from the previous attempt is
removed entirely (auth presence IS the gate).

`src/openhuman/agent/agents/welcome/prompt.md` — full rewrite.
~250 → ~140 lines. New "two iterations, no third" workflow.
JSON-aware drafting instructions. `finalize_action` decision tree.

## Tests

* `tool_metadata` (1/1): pins schema shape (action enum, required
  fields). Still passes — schema unchanged at the level the test
  asserts.
* `agent::agents::*` (14/14): all welcome-definition shape tests
  pass. The TOML hasn't changed, only the embedded `prompt.md`.

## Verification

Restage sidecar, relaunch Tauri, type `hi`. Expected:

* iter 1: `complete_onboarding(check_status)` → JSON returned with
  `finalize_action: "flipped"`. Side effect: chat_onboarding
  flipped to true on disk.
* iter 2: ~250-word welcome message based on the JSON.
* iter 3: not reached. Loop terminates after iter 2.
* Chat pane shows the iter-2 welcome. No paraphrase leak.

Then type a follow-up:

* `[web-channel] cache miss — rebuilding session agent (was
  id=welcome, now id=orchestrator)` — the welcome → orchestrator
  handoff fires via Commit 13's cache invalidation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(welcome): hide the welcome→orchestrator handoff from the user (#525)

The welcome agent's prompt previously instructed it to close every
welcome message with an explicit handoff line:

> "From here, you're in the hands of the full OpenHuman assistant.
>  Just start a new conversation and ask it anything — it knows how
>  to delegate to specialists, run tools, search the web..."

That line is a UX leak. From the user's perspective they are
talking to "OpenHuman" — one assistant, one conversation. They do
not know there are multiple agents under the hood, and they
shouldn't have to. The welcome → orchestrator transition is a
routing-layer implementation detail. Surfacing it in the chat
makes openhuman feel like a chatbot framework instead of a
product.

## Changes (welcome prompt only — no code)

### Step 5 of the message structure

Renamed from "Hand off" to "Close naturally". New instruction:

> "End the message with something inviting like 'anything you'd
>  like to try first?' or 'what should we dig into?' — a normal
>  conversational close that lets the user pick up the next turn.
>  Do NOT mention handing off, transferring, or any change in who
>  they're talking to. The user does not know there are multiple
>  agents under the hood; from their perspective they're talking
>  to OpenHuman as one entity, and the next message they send
>  will just continue the conversation."

### "What NOT to do" list

Replaced the old "Don't forget the handoff" rule with its inverse:

> "**Don't reveal that the conversation is being routed to a
> different agent.** From the user's perspective they are talking
> to 'OpenHuman' — one assistant, one conversation. Do NOT say
> 'I'll hand you off to the main assistant', 'the orchestrator
> will take over', 'you're now in the hands of the full assistant',
> 'from here on out you'll be talking to a different agent', or
> any variation that exposes the welcome → orchestrator handoff.
> The handoff happens transparently in the routing layer; the user
> just sends another message and the conversation continues.
> Phrases like 'what should we dig into?' or 'anything you'd like
> to try first?' are correct conversational closes — they invite
> the next turn without leaking the architecture."

The rule lists four specific forbidden phrasings the previous
welcome agent runs produced (variations of "you're in the hands
of the full assistant" and "the orchestrator will take over") so
the model has explicit anti-patterns to match against, plus two
acceptable conversational closes.

## Why this matters for #525

The welcome → orchestrator handoff is the central architectural
contribution of this PR. From a developer's perspective it is a
big deal — different agent definition, different tool scope,
different prompt, different temperature, different model hint.
From the user's perspective it should be invisible. They type a
message, OpenHuman replies. They type another, OpenHuman replies.
The fact that one of those replies came from `welcome` and the
next from `orchestrator` is purely an implementation detail they
should never have to think about.

## What does NOT change

* No code changes. Pure prompt edit.
* The handoff still happens — `chat_onboarding_completed` still
  flips to true via the `check_status` auto-finalize, the
  `THREAD_SESSIONS` cache still invalidates between turns (Commit
  13), the next chat turn still routes to orchestrator (Commit 8).
  The mechanism is unchanged; only the user-facing framing of it
  is.
* No test changes. `agent::agents::welcome_has_onboarding_and_
  memory_tools` and `every_builtin_has_a_prompt_body` only
  validate the agent definition shape, not the prompt body text,
  so this commit does not affect them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(complete_onboarding): delegate auto-finalize side effect to complete() (#525)

Small refactor on top of Commit 15. The auto-finalize block inside
`check_status` previously inlined the entire flag-flip + cron-seed
sequence:

```rust
config.chat_onboarding_completed = true;
config.save().await?;
let seed_config = config.clone();
tokio::spawn(async move {
    if let Err(e) = crate::openhuman::cron::seed::seed_proactive_agents(&seed_config) {
        tracing::warn!("...");
    }
});
```

But the legacy `complete()` action does literally the same work.
Duplicating it meant two places to update if the finalize semantics
ever changed (e.g. add another side effect, change the cron seed
strategy). Refactor `check_status` to delegate to `complete()`:

```rust
let _ = complete().await?;
config.chat_onboarding_completed = true;  // mirror disk into local
```

The `let _` discards `complete()`'s `ToolResult::success("ok")`
return value — `check_status` is producing its own JSON snapshot,
the caller just wants the side effect.

The `config.chat_onboarding_completed = true` after the call is an
in-memory mirror of the flip that `complete()` just performed on
disk. Without it, the JSON snapshot built later in `check_status`
would still show the pre-flip value, because the local `config` was
loaded BEFORE `complete()` ran and `complete()` operates on its own
fresh disk-loaded copy. The mirror is one line, no extra disk read,
no behavior change.

## Why not extract a `perform_finalize(&mut Config)` helper instead

Considered. Would eliminate the in-memory mirror and the redundant
disk read inside `complete()`. Ruled out because:

* `complete()` is already documented and used as a public legacy
  action; changing its signature would ripple to admin tools and
  tests that call it through the action dispatcher.
* The mirror cost is one line, zero I/O, zero correctness risk.
* The literal "call complete() inside check_status" framing matches
  the request more directly.

If a future maintainer wants to refactor further, the obvious move
is to make `complete()` take `&mut Config`, drop the redundant
`Config::load_or_init` inside `complete()`, and have both
`check_status` and the public action handler load the config once
at their top level. That's a separate refactor.

## Files

`src/openhuman/tools/impl/agent/complete_onboarding.rs` — single
function body change in `check_status`. The "flipped" arm of the
`finalize_action` match now calls `complete()` and mirrors the
flip locally instead of inlining the flip + save + cron seed.
About -10 / +5 lines.

## Tests

Library compiles. The 1 `tool_metadata` test still passes (it
pins the schema shape, not function bodies). No behavior change
for any caller.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style(builder): cargo fmt cleanup of from_config_for_agent body (#525)

Pure indentation cleanup applied by `cargo fmt` against the
`target_def` resolution block I added in Commit 8 (`feat(web-channel):
route Tauri in-app chat to welcome/orchestrator`). The original
indentation had an awkward type annotation on the `let target_def:
Option<...> = ` line that wrapped before the `match` keyword;
rustfmt prefers the `=` on the same line as the type and the
`match` body indented under it.

Zero behavior change. Same control flow, same logic, same
diagnostics. Detected by the husky pre-push hook running
`yarn rust:check` (which runs `cargo fmt --check`).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(json_rpc_e2e): set chat_onboarding_completed=true in seed config (#525)

The e2e config seeded by `write_min_config` now sets
`chat_onboarding_completed = true` so `channel_web_chat` routes straight
to the orchestrator. Without this the first chat turn goes through the
new welcome agent whose tool contract is not modelled by the in-process
mock upstream, which tore down the SSE stream mid-response and caused
`json_rpc_protocol_auth_and_agent_hello` to fail with
`sse stream read failed: error decoding response body`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Steven Enamakel <31011319+senamakel@users.noreply.github.com>
2026-04-13 14:17:11 -07:00

1904 lines
63 KiB
Rust

//! HTTP JSON-RPC integration tests against a real axum stack and a mock upstream API.
//!
//! Isolates config under a temp `HOME` so auth profiles and the OpenHuman provider resolve
//! the same state directory. Run with: `cargo test --test json_rpc_e2e`
use std::net::SocketAddr;
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use axum::http::{header::AUTHORIZATION, HeaderMap, StatusCode};
use axum::routing::{get, post};
use axum::{Json, Router};
use futures_util::StreamExt;
use serde_json::{json, Value};
use tempfile::tempdir;
use openhuman_core::core::jsonrpc::build_core_http_router;
struct EnvVarGuard {
key: &'static str,
old: Option<String>,
}
impl EnvVarGuard {
fn set_to_path(key: &'static str, path: &Path) -> Self {
let old = std::env::var(key).ok();
std::env::set_var(key, path.as_os_str());
Self { key, old }
}
fn unset(key: &'static str) -> Self {
let old = std::env::var(key).ok();
std::env::remove_var(key);
Self { key, old }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.old {
Some(v) => std::env::set_var(self.key, v),
None => std::env::remove_var(self.key),
}
}
}
/// Serializes tests in this binary: `HOME` / `OPENHUMAN_WORKSPACE` / backend URL overrides are
/// process-global, so parallel tests would clobber each other and hit the wrong `config.toml` or
/// inherited `VITE_BACKEND_URL`.
static JSON_RPC_E2E_ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
fn json_rpc_e2e_env_lock() -> std::sync::MutexGuard<'static, ()> {
let mutex = JSON_RPC_E2E_ENV_LOCK.get_or_init(|| Mutex::new(()));
// Recover from poison so that a panic in one test does not cascade to all others.
match mutex.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
fn mock_upstream_router() -> Router {
const GENERAL_TOKEN: &str = "e2e-test-jwt";
const BILLING_TOKEN: &str = "e2e-billing-jwt";
const TEAM_TOKEN: &str = "e2e-team-jwt";
fn error_json(status: StatusCode, message: &str) -> (StatusCode, Json<Value>) {
(
status,
Json(json!({
"success": false,
"error": message,
"message": message,
})),
)
}
fn require_bearer(
headers: &HeaderMap,
expected_token: &str,
) -> Result<(), (StatusCode, Json<Value>)> {
require_any_bearer(headers, &[expected_token])
}
fn require_any_bearer(
headers: &HeaderMap,
expected_tokens: &[&str],
) -> Result<(), (StatusCode, Json<Value>)> {
let actual = headers
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.map(str::trim);
match actual {
Some(value)
if expected_tokens
.iter()
.any(|token| value == format!("Bearer {token}")) =>
{
Ok(())
}
Some(_) => Err(error_json(
StatusCode::UNAUTHORIZED,
"invalid Authorization bearer token",
)),
None => Err(error_json(
StatusCode::UNAUTHORIZED,
"missing Authorization bearer token",
)),
}
}
fn require_string_field<'a>(
body: &'a Value,
field: &str,
) -> Result<&'a str, (StatusCode, Json<Value>)> {
body.get(field)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
error_json(
StatusCode::BAD_REQUEST,
&format!("missing or invalid '{field}'"),
)
})
}
fn require_positive_f64_field(
body: &Value,
field: &str,
) -> Result<f64, (StatusCode, Json<Value>)> {
body.get(field)
.and_then(Value::as_f64)
.filter(|value| value.is_finite() && *value > 0.0)
.ok_or_else(|| {
error_json(
StatusCode::BAD_REQUEST,
&format!("missing or invalid '{field}'"),
)
})
}
// Matches authenticated profile fetches used during session validation.
async fn current_user(headers: HeaderMap) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
require_any_bearer(&headers, &[GENERAL_TOKEN, BILLING_TOKEN, TEAM_TOKEN])?;
Ok(Json(json!({
"success": true,
"data": {
"_id": "e2e-user-1",
"username": "e2e"
}
})))
}
async fn chat_completions(Json(_body): Json<Value>) -> Json<Value> {
Json(json!({
"choices": [{
"message": {
"role": "assistant",
"content": "Hello from e2e mock agent"
}
}]
}))
}
// ── Billing mock routes ──────────────────────────────────────────────────
async fn stripe_current_plan(
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
require_bearer(&headers, BILLING_TOKEN)?;
Ok(Json(json!({
"success": true,
"data": {
"plan": "PRO",
"hasActiveSubscription": true,
"planExpiry": "2030-01-01T00:00:00.000Z",
"subscription": { "id": "sub_mock_123", "status": "active" }
}
})))
}
async fn stripe_purchase_plan(
headers: HeaderMap,
Json(body): Json<Value>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
require_bearer(&headers, BILLING_TOKEN)?;
let plan = require_string_field(&body, "plan")?;
if !matches!(plan, "basic" | "pro" | "BASIC" | "PRO") {
return Err(error_json(
StatusCode::BAD_REQUEST,
"missing or invalid 'plan'",
));
}
let checkout_url = "http://127.0.0.1/mock-checkout";
let session_id = "cs_mock_abc";
if checkout_url.is_empty() || session_id.is_empty() {
return Err(error_json(
StatusCode::BAD_REQUEST,
"missing checkoutUrl or sessionId",
));
}
Ok(Json(json!({
"success": true,
"data": { "checkoutUrl": checkout_url, "sessionId": session_id }
})))
}
async fn stripe_portal(headers: HeaderMap) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
require_bearer(&headers, BILLING_TOKEN)?;
let portal_url = "http://127.0.0.1/mock-portal";
if portal_url.is_empty() {
return Err(error_json(StatusCode::BAD_REQUEST, "missing portalUrl"));
}
Ok(Json(json!({
"success": true,
"data": { "portalUrl": portal_url }
})))
}
async fn credits_top_up(
headers: HeaderMap,
Json(body): Json<Value>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
require_bearer(&headers, BILLING_TOKEN)?;
let amount_usd = require_positive_f64_field(&body, "amountUsd")?;
let gateway = require_string_field(&body, "gateway")?;
if !matches!(gateway, "stripe" | "coinbase") {
return Err(error_json(
StatusCode::BAD_REQUEST,
"missing or invalid 'gateway'",
));
}
Ok(Json(json!({
"success": true,
"data": {
"url": "http://127.0.0.1/mock-topup",
"gatewayTransactionId": "txn_mock_1",
"amountUsd": amount_usd,
"gateway": gateway
}
})))
}
async fn coinbase_charge(
headers: HeaderMap,
Json(body): Json<Value>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
require_bearer(&headers, BILLING_TOKEN)?;
let plan = require_string_field(&body, "plan")?;
let interval = body
.get("interval")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("annual");
if !matches!(plan, "basic" | "pro" | "BASIC" | "PRO") {
return Err(error_json(
StatusCode::BAD_REQUEST,
"missing or invalid 'plan'",
));
}
if interval != "annual" {
return Err(error_json(
StatusCode::BAD_REQUEST,
"missing or invalid 'interval'",
));
}
Ok(Json(json!({
"success": true,
"data": {
"gatewayTransactionId": "coinbase_mock_1",
"hostedUrl": "http://127.0.0.1/mock-coinbase",
"status": "NEW",
"expiresAt": "2030-01-01T01:00:00.000Z"
}
})))
}
// ── Team mock routes ─────────────────────────────────────────────────────
async fn team_members(headers: HeaderMap) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
require_bearer(&headers, TEAM_TOKEN)?;
Ok(Json(json!({
"success": true,
"data": [
{ "id": "user-1", "username": "alice", "role": "ADMIN" },
{ "id": "user-2", "username": "bob", "role": "MEMBER" }
]
})))
}
async fn team_invites_get(
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
require_bearer(&headers, TEAM_TOKEN)?;
Ok(Json(json!({
"success": true,
"data": [
{ "id": "inv-1", "code": "ALPHA1", "maxUses": 5, "usedCount": 1, "expiresAt": null }
]
})))
}
async fn team_invites_post(
headers: HeaderMap,
Json(body): Json<Value>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
require_bearer(&headers, TEAM_TOKEN)?;
let max_uses = body
.get("maxUses")
.and_then(Value::as_u64)
.ok_or_else(|| error_json(StatusCode::BAD_REQUEST, "missing or invalid 'maxUses'"))?;
let expires_in_days = body
.get("expiresInDays")
.and_then(Value::as_u64)
.ok_or_else(|| {
error_json(
StatusCode::BAD_REQUEST,
"missing or invalid 'expiresInDays'",
)
})?;
if max_uses == 0 || expires_in_days == 0 {
return Err(error_json(
StatusCode::BAD_REQUEST,
"invite payload values must be greater than zero",
));
}
Ok(Json(json!({
"success": true,
"data": { "id": "inv-new", "code": "NEWCODE", "maxUses": max_uses, "usedCount": 0, "expiresAt": null }
})))
}
async fn team_member_delete(
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
require_bearer(&headers, TEAM_TOKEN)?;
Ok(Json(json!({ "success": true, "data": {} })))
}
async fn team_member_role_put(
headers: HeaderMap,
Json(body): Json<Value>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
require_bearer(&headers, TEAM_TOKEN)?;
let role = require_string_field(&body, "role")?;
if !matches!(role, "ADMIN" | "MEMBER" | "OWNER") {
return Err(error_json(
StatusCode::BAD_REQUEST,
"missing or invalid 'role'",
));
}
Ok(Json(json!({ "success": true, "data": {} })))
}
async fn team_invite_delete(
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
require_bearer(&headers, TEAM_TOKEN)?;
Ok(Json(json!({ "success": true, "data": {} })))
}
Router::new()
.route("/settings", get(current_user))
.route("/auth/me", get(current_user))
.route("/openai/v1/chat/completions", post(chat_completions))
// billing
.route("/payments/stripe/currentPlan", get(stripe_current_plan))
.route("/payments/stripe/purchasePlan", post(stripe_purchase_plan))
.route("/payments/stripe/portal", post(stripe_portal))
.route("/payments/credits/top-up", post(credits_top_up))
.route("/payments/coinbase/charge", post(coinbase_charge))
// team
.route("/teams/{team_id}/members", get(team_members))
.route(
"/teams/{team_id}/members/{user_id}",
axum::routing::delete(team_member_delete),
)
.route(
"/teams/{team_id}/members/{user_id}/role",
axum::routing::put(team_member_role_put),
)
.route(
"/teams/{team_id}/invites",
get(team_invites_get).post(team_invites_post),
)
.route(
"/teams/{team_id}/invites/{invite_id}",
axum::routing::delete(team_invite_delete),
)
}
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 post_json_rpc(rpc_base: &str, id: i64, method: &str, params: Value) -> Value {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(120))
.build()
.expect("client");
let body = json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params
});
let url = format!("{}/rpc", 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}"))
}
#[allow(dead_code)]
async fn read_first_sse_event(events_url: &str) -> Value {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(120))
.build()
.expect("client");
let resp = client
.get(events_url)
.send()
.await
.unwrap_or_else(|e| panic!("GET {events_url}: {e}"));
assert!(
resp.status().is_success(),
"SSE HTTP error {} for {}",
resp.status(),
events_url
);
let mut stream = resp.bytes_stream();
let mut buffer = String::new();
while let Some(item) = stream.next().await {
let chunk = item.unwrap_or_else(|e| panic!("sse stream read failed: {e}"));
let text = std::str::from_utf8(&chunk).unwrap_or("");
buffer.push_str(text);
while let Some(idx) = buffer.find("\n\n") {
let block = buffer[..idx].to_string();
buffer = buffer[idx + 2..].to_string();
let mut data_lines = Vec::new();
for line in block.lines() {
if let Some(data) = line.strip_prefix("data:") {
data_lines.push(data.trim_start());
}
}
if !data_lines.is_empty() {
let payload = data_lines.join("\n");
let value: Value = serde_json::from_str(&payload)
.unwrap_or_else(|e| panic!("invalid sse data json: {e}"));
return value;
}
}
}
panic!("SSE stream ended before any event payload");
}
/// Read SSE events until one matches the given `event` field value, skipping
/// progress events (inference_start, iteration_start, etc.) that precede the
/// terminal event.
async fn read_sse_event_by_type(events_url: &str, target_event: &str) -> Value {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(120))
.build()
.expect("client");
let resp = client
.get(events_url)
.send()
.await
.unwrap_or_else(|e| panic!("GET {events_url}: {e}"));
assert!(
resp.status().is_success(),
"SSE HTTP error {} for {}",
resp.status(),
events_url
);
let mut stream = resp.bytes_stream();
let mut buffer = String::new();
while let Some(item) = stream.next().await {
let chunk = item.unwrap_or_else(|e| panic!("sse stream read failed: {e}"));
let text = std::str::from_utf8(&chunk).unwrap_or("");
buffer.push_str(text);
while let Some(idx) = buffer.find("\n\n") {
let block = buffer[..idx].to_string();
buffer = buffer[idx + 2..].to_string();
let mut data_lines = Vec::new();
for line in block.lines() {
if let Some(data) = line.strip_prefix("data:") {
data_lines.push(data.trim_start());
}
}
if !data_lines.is_empty() {
let payload = data_lines.join("\n");
let value: Value = serde_json::from_str(&payload)
.unwrap_or_else(|e| panic!("invalid sse data json: {e}"));
if value.get("event").and_then(Value::as_str) == Some(target_event) {
return value;
}
}
}
}
panic!("SSE stream ended before receiving '{target_event}' event");
}
fn assert_no_jsonrpc_error<'a>(v: &'a Value, context: &str) -> &'a Value {
if let Some(err) = v.get("error") {
panic!("{context}: JSON-RPC error: {err}");
}
v.get("result")
.unwrap_or_else(|| panic!("{context}: missing result: {v}"))
}
fn extract_string_outcome(result: &Value) -> String {
if let Some(s) = result.as_str() {
return s.to_string();
}
if let Some(inner) = result.get("result").and_then(Value::as_str) {
return inner.to_string();
}
panic!("expected string or {{result: string}}, got {result}");
}
fn write_min_config(openhuman_dir: &Path, api_origin: &str) {
// `chat_onboarding_completed = true` bypasses the welcome agent so that
// `channel_web_chat` in tests routes straight to the orchestrator. Without
// this, the first chat turn goes through the welcome flow whose tool
// contract is not modelled by the e2e mock, which closes the SSE stream
// mid-response.
let cfg = format!(
r#"api_url = "{api_origin}"
default_model = "e2e-mock-model"
default_temperature = 0.7
chat_onboarding_completed = true
[secrets]
encrypt = false
"#
);
fn write_config_file(config_dir: &Path, cfg: &str) {
std::fs::create_dir_all(config_dir).expect("mkdir openhuman");
let path = config_dir.join("config.toml");
std::fs::write(&path, cfg).expect("write config");
}
write_config_file(openhuman_dir, &cfg);
// Runtime config resolution is user-scoped before login, so tests that seed
// the root `~/.openhuman` directory also need the equivalent pre-login
// config under `~/.openhuman/users/local`.
if openhuman_dir
.file_name()
.is_some_and(|name| name == std::ffi::OsStr::new(".openhuman"))
{
write_config_file(&openhuman_dir.join("users").join("local"), &cfg);
}
let _: openhuman_core::openhuman::config::Config =
toml::from_str(&cfg).expect("config toml must match Config schema");
}
#[cfg(target_os = "macos")]
fn write_min_config_with_local_ai_disabled(openhuman_dir: &Path, api_origin: &str) {
let cfg = format!(
r#"api_url = "{api_origin}"
default_model = "e2e-mock-model"
default_temperature = 0.7
chat_onboarding_completed = true
[secrets]
encrypt = false
[local_ai]
enabled = false
"#
);
fn write_config_file(config_dir: &Path, cfg: &str) {
std::fs::create_dir_all(config_dir).expect("mkdir openhuman");
let path = config_dir.join("config.toml");
std::fs::write(&path, cfg).expect("write config");
}
write_config_file(openhuman_dir, &cfg);
if openhuman_dir
.file_name()
.is_some_and(|name| name == std::ffi::OsStr::new(".openhuman"))
{
write_config_file(&openhuman_dir.join("users").join("local"), &cfg);
}
let _: openhuman_core::openhuman::config::Config =
toml::from_str(&cfg).expect("config toml must match Config schema");
}
#[tokio::test]
async fn json_rpc_protocol_auth_and_agent_hello() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
// Always use the in-process Axum mock for /settings + /openai so this test does not pick up
// BACKEND_URL/VITE_BACKEND_URL from the developer shell (e.g. mock-api that returns 401 for
// the synthetic JWT used below).
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
let mock_origin = format!("http://{}", mock_addr);
write_min_config(&openhuman_home, &mock_origin);
// Pre-create the user-scoped config directory so that when store_session
// activates user "e2e-user" and reloads config, it finds the correct
// api_url and secrets.encrypt=false (rather than defaults).
let user_scoped_dir = openhuman_home.join("users").join("e2e-user");
write_min_config(&user_scoped_dir, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
tokio::time::sleep(Duration::from_millis(100)).await;
// --- core.ping (baseline protocol) ---
let ping = post_json_rpc(&rpc_base, 1, "core.ping", json!({})).await;
let ping_result = assert_no_jsonrpc_error(&ping, "core.ping");
assert_eq!(ping_result.get("ok"), Some(&json!(true)));
// --- unknown method ---
let unknown = post_json_rpc(&rpc_base, 2, "core.not_a_real_method", json!({})).await;
assert!(
unknown.get("error").is_some(),
"expected error for unknown method: {unknown}"
);
// --- auth: session state (no JWT yet) ---
let state_before = post_json_rpc(&rpc_base, 3, "openhuman.auth_get_state", json!({})).await;
let state_outer = assert_no_jsonrpc_error(&state_before, "get_state");
let state_body = state_outer.get("result").unwrap_or(state_outer);
assert!(
state_body.get("isAuthenticated").is_some() || state_body.get("is_authenticated").is_some(),
"unexpected auth state shape: {state_body}"
);
// --- auth: store session (validates JWT via mock GET /auth/me) ---
let store = post_json_rpc(
&rpc_base,
4,
"openhuman.auth_store_session",
json!({
"token": "e2e-test-jwt",
"user_id": "e2e-user"
}),
)
.await;
assert_no_jsonrpc_error(&store, "store_session");
// --- agent: single chat turn (mock chat completions) ---
let chat = post_json_rpc(
&rpc_base,
5,
"openhuman.local_ai_agent_chat",
json!({
"message": "Hello",
}),
)
.await;
let chat_result = assert_no_jsonrpc_error(&chat, "agent_chat");
let reply = extract_string_outcome(chat_result);
assert!(
reply.contains("e2e mock") || reply.contains("Hello"),
"unexpected agent reply: {reply:?}"
);
// --- web channel RPC + SSE loop ---
let client_id = "e2e-client-1";
let thread_id = "thread-1";
let events_url = format!("{}/events?client_id={}", rpc_base, client_id);
let sse_task =
tokio::spawn(async move { read_sse_event_by_type(&events_url, "chat_done").await });
let web_chat = post_json_rpc(
&rpc_base,
6,
"openhuman.channel_web_chat",
json!({
"client_id": client_id,
"thread_id": thread_id,
"message": "Hello from web channel",
"model_override": "e2e-mock-model",
}),
)
.await;
let web_chat_result = assert_no_jsonrpc_error(&web_chat, "channel_web_chat");
assert_eq!(
web_chat_result
.get("result")
.and_then(|v| v.get("accepted")),
Some(&json!(true))
);
let sse_event = sse_task.await.expect("sse task join should succeed");
assert_eq!(
sse_event.get("event").and_then(Value::as_str),
Some("chat_done")
);
assert_eq!(
sse_event.get("thread_id").and_then(Value::as_str),
Some(thread_id)
);
assert!(
sse_event
.get("full_response")
.and_then(Value::as_str)
.unwrap_or_default()
.len()
> 0,
"expected non-empty chat_done response payload: {sse_event}"
);
mock_join.abort();
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_rejects_non_object_params_with_clear_error() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
let mock_origin = format!("http://{}", mock_addr);
write_min_config(&openhuman_home, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
tokio::time::sleep(Duration::from_millis(100)).await;
let invalid = post_json_rpc(
&rpc_base,
1001,
"openhuman.auth_get_state",
json!(["invalid", "params"]),
)
.await;
let err_message = invalid
.get("error")
.and_then(|e| e.get("message"))
.and_then(Value::as_str)
.unwrap_or("");
assert!(
!err_message.is_empty(),
"expected non-empty JSON-RPC error message: {invalid}"
);
mock_join.abort();
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_screen_intelligence_capture_test_returns_stable_shape() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
let mock_origin = format!("http://{}", mock_addr);
write_min_config(&openhuman_home, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
tokio::time::sleep(Duration::from_millis(100)).await;
let capture = post_json_rpc(
&rpc_base,
1002,
"openhuman.screen_intelligence_capture_test",
json!({}),
)
.await;
let capture_outer = assert_no_jsonrpc_error(&capture, "screen_intelligence_capture_test");
let capture_result = capture_outer.get("result").unwrap_or(capture_outer);
assert!(
capture_result.get("ok").and_then(Value::as_bool).is_some(),
"expected bool ok field: {capture_result}"
);
assert!(
matches!(
capture_result.get("capture_mode").and_then(Value::as_str),
Some("windowed" | "fullscreen")
),
"expected capture_mode field: {capture_result}"
);
assert!(
capture_result
.get("timing_ms")
.and_then(Value::as_u64)
.is_some(),
"expected timing_ms field: {capture_result}"
);
let ok = capture_result
.get("ok")
.and_then(Value::as_bool)
.expect("ok should be bool");
let image_ref = capture_result.get("image_ref").and_then(Value::as_str);
let error = capture_result.get("error").and_then(Value::as_str);
if ok {
assert!(
image_ref
.map(|value| value.starts_with("data:image/png;base64,"))
.unwrap_or(false),
"successful capture should include a PNG data URL: {capture_result}"
);
assert!(
error.is_none(),
"successful capture should not include an error"
);
} else {
assert!(
image_ref.is_none(),
"failed capture should not include image data"
);
assert!(
error.is_some(),
"failed capture should include an error message"
);
}
mock_join.abort();
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_screen_intelligence_status_returns_stable_shape() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
let mock_origin = format!("http://{}", mock_addr);
write_min_config(&openhuman_home, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
tokio::time::sleep(Duration::from_millis(100)).await;
let status = post_json_rpc(
&rpc_base,
1003,
"openhuman.screen_intelligence_status",
json!({}),
)
.await;
let result = assert_no_jsonrpc_error(&status, "screen_intelligence_status");
let status_result = result.get("result").unwrap_or(result);
// Required top-level fields
assert!(
status_result
.get("platform_supported")
.and_then(Value::as_bool)
.is_some(),
"expected bool platform_supported: {status_result}"
);
assert!(
status_result
.get("is_context_blocked")
.and_then(Value::as_bool)
.is_some(),
"expected bool is_context_blocked: {status_result}"
);
// session block
let session = status_result
.get("session")
.expect("expected session object");
assert!(
session.get("active").and_then(Value::as_bool).is_some(),
"expected bool session.active: {status_result}"
);
assert_eq!(
session.get("active").and_then(Value::as_bool),
Some(false),
"session should not be active without start_session: {status_result}"
);
assert!(
session
.get("capture_count")
.and_then(Value::as_u64)
.is_some(),
"expected u64 session.capture_count: {status_result}"
);
assert!(
session
.get("vision_persist_count")
.and_then(Value::as_u64)
.is_some(),
"expected u64 session.vision_persist_count: {status_result}"
);
assert!(
session.get("last_vision_persist_error").is_some(),
"expected nullable session.last_vision_persist_error: {status_result}"
);
// permissions block
let perms = status_result
.get("permissions")
.expect("expected permissions object");
assert!(
perms
.get("screen_recording")
.and_then(Value::as_str)
.is_some(),
"expected string permissions.screen_recording: {status_result}"
);
mock_join.abort();
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_app_state_snapshot_returns_runtime_shape() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
let mock_origin = format!("http://{}", mock_addr);
write_min_config(&openhuman_home, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
tokio::time::sleep(Duration::from_millis(100)).await;
let snapshot = post_json_rpc(&rpc_base, 1004, "openhuman.app_state_snapshot", json!({})).await;
let result = assert_no_jsonrpc_error(&snapshot, "app_state_snapshot");
let body = result.get("result").unwrap_or(result);
assert!(
body.get("auth").and_then(Value::as_object).is_some(),
"expected auth object: {body}"
);
assert!(
body.get("localState").and_then(Value::as_object).is_some(),
"expected localState object: {body}"
);
let runtime = body.get("runtime").expect("expected runtime object");
assert!(
runtime
.get("screenIntelligence")
.and_then(Value::as_object)
.is_some(),
"expected runtime.screenIntelligence object: {runtime}"
);
assert!(
runtime.get("localAi").and_then(Value::as_object).is_some(),
"expected runtime.localAi object: {runtime}"
);
assert!(
runtime
.get("autocomplete")
.and_then(Value::as_object)
.is_some(),
"expected runtime.autocomplete object: {runtime}"
);
assert!(
runtime.get("service").and_then(Value::as_object).is_some(),
"expected runtime.service object: {runtime}"
);
mock_join.abort();
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_screen_intelligence_vision_recent_returns_empty_without_session() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
let mock_origin = format!("http://{}", mock_addr);
write_min_config(&openhuman_home, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
tokio::time::sleep(Duration::from_millis(100)).await;
let recent = post_json_rpc(
&rpc_base,
1004,
"openhuman.screen_intelligence_vision_recent",
json!({ "limit": 10 }),
)
.await;
let result = assert_no_jsonrpc_error(&recent, "screen_intelligence_vision_recent");
let recent_result = result.get("result").unwrap_or(result);
let summaries = recent_result
.get("summaries")
.and_then(Value::as_array)
.expect("expected summaries array: {recent_result}");
assert!(
summaries.is_empty(),
"vision_recent should return empty list without an active session, got {} items",
summaries.len()
);
mock_join.abort();
rpc_join.abort();
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn json_rpc_autocomplete_runtime_settings_and_logs_flow() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
let mock_origin = format!("http://{}", mock_addr);
write_min_config_with_local_ai_disabled(&openhuman_home, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
tokio::time::sleep(Duration::from_millis(100)).await;
let set_style = post_json_rpc(
&rpc_base,
2001,
"openhuman.autocomplete_set_style",
json!({
"enabled": true,
"debounce_ms": 180,
"max_chars": 160,
"accept_with_tab": false,
"style_preset": "balanced",
"style_examples": ["[mail] ...Can you share an update? → Can you share a quick update?"],
"disabled_apps": []
}),
)
.await;
let set_style_outer = assert_no_jsonrpc_error(&set_style, "autocomplete_set_style");
let set_style_payload = set_style_outer.get("result").unwrap_or(set_style_outer);
let set_style_logs = set_style_outer
.get("logs")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
assert_eq!(
set_style_payload
.get("config")
.and_then(|v| v.get("debounce_ms"))
.and_then(Value::as_u64),
Some(180)
);
assert_eq!(
set_style_payload
.get("config")
.and_then(|v| v.get("max_chars"))
.and_then(Value::as_u64),
Some(160)
);
assert!(
set_style_logs.iter().any(|entry| {
entry
.as_str()
.map(|s| s.contains("[autocomplete] set_style"))
.unwrap_or(false)
}),
"expected structured set_style log line: {set_style_outer}"
);
let cfg = post_json_rpc(&rpc_base, 2002, "openhuman.config_get", json!({})).await;
let cfg_outer = assert_no_jsonrpc_error(&cfg, "get_config");
let cfg_payload = cfg_outer.get("result").unwrap_or(cfg_outer);
let cfg_autocomplete = cfg_payload
.get("config")
.and_then(|v| v.get("autocomplete"))
.expect("autocomplete config should exist");
assert_eq!(
cfg_autocomplete.get("debounce_ms").and_then(Value::as_u64),
Some(180)
);
assert_eq!(
cfg_autocomplete.get("max_chars").and_then(Value::as_u64),
Some(160)
);
assert_eq!(
cfg_autocomplete
.get("accept_with_tab")
.and_then(Value::as_bool),
Some(false)
);
let start = post_json_rpc(
&rpc_base,
2003,
"openhuman.autocomplete_start",
json!({ "debounce_ms": 180 }),
)
.await;
let start_outer = assert_no_jsonrpc_error(&start, "autocomplete_start");
let start_logs = start_outer
.get("logs")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
assert!(
start_logs.iter().any(|entry| {
entry
.as_str()
.map(|s| s.contains("[autocomplete] start"))
.unwrap_or(false)
}),
"expected structured start log line: {start_outer}"
);
let status_running =
post_json_rpc(&rpc_base, 2004, "openhuman.autocomplete_status", json!({})).await;
let status_running_outer = assert_no_jsonrpc_error(&status_running, "autocomplete_status");
let status_running_payload = status_running_outer
.get("result")
.unwrap_or(status_running_outer);
assert_eq!(
status_running_payload
.get("running")
.and_then(Value::as_bool),
Some(true)
);
assert_eq!(
status_running_payload
.get("enabled")
.and_then(Value::as_bool),
Some(true)
);
assert_eq!(
status_running_payload
.get("debounce_ms")
.and_then(Value::as_u64),
Some(180)
);
let current = post_json_rpc(
&rpc_base,
2005,
"openhuman.autocomplete_current",
json!({ "context": "Please review this changeset and" }),
)
.await;
let current_outer = assert_no_jsonrpc_error(&current, "autocomplete_current");
let current_payload = current_outer.get("result").unwrap_or(current_outer);
let current_logs = current_outer
.get("logs")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
assert_eq!(
current_payload.get("context").and_then(Value::as_str),
Some("Please review this changeset and")
);
assert!(
current_logs.iter().any(|entry| {
entry
.as_str()
.map(|s| s.contains("[autocomplete] current"))
.unwrap_or(false)
}),
"expected structured current log line: {current_outer}"
);
let accept = post_json_rpc(
&rpc_base,
2006,
"openhuman.autocomplete_accept",
json!({
"suggestion": " share your thoughts.",
"skip_apply": true
}),
)
.await;
let accept_outer = assert_no_jsonrpc_error(&accept, "autocomplete_accept");
let accept_payload = accept_outer.get("result").unwrap_or(accept_outer);
let accept_logs = accept_outer
.get("logs")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
assert_eq!(
accept_payload.get("accepted").and_then(Value::as_bool),
Some(true)
);
assert_eq!(
accept_payload.get("applied").and_then(Value::as_bool),
Some(false)
);
assert!(
accept_logs.iter().any(|entry| {
entry
.as_str()
.map(|s| s.contains("[autocomplete] accept"))
.unwrap_or(false)
}),
"expected structured accept log line: {accept_outer}"
);
let stop = post_json_rpc(
&rpc_base,
2007,
"openhuman.autocomplete_stop",
json!({ "reason": "json_rpc_e2e" }),
)
.await;
let stop_outer = assert_no_jsonrpc_error(&stop, "autocomplete_stop");
let stop_payload = stop_outer.get("result").unwrap_or(stop_outer);
assert_eq!(
stop_payload.get("stopped").and_then(Value::as_bool),
Some(true)
);
let status_stopped =
post_json_rpc(&rpc_base, 2008, "openhuman.autocomplete_status", json!({})).await;
let status_stopped_outer = assert_no_jsonrpc_error(&status_stopped, "autocomplete_status");
let status_stopped_payload = status_stopped_outer
.get("result")
.unwrap_or(status_stopped_outer);
assert_eq!(
status_stopped_payload
.get("running")
.and_then(Value::as_bool),
Some(false)
);
mock_join.abort();
rpc_join.abort();
}
// ---------------------------------------------------------------------------
// Local AI device profile, presets, and apply preset
// ---------------------------------------------------------------------------
#[tokio::test]
async fn json_rpc_local_ai_device_profile_and_presets() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let _tier_guard = EnvVarGuard::unset("OPENHUMAN_LOCAL_AI_TIER");
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
let mock_origin = format!("http://{}", mock_addr);
write_min_config(&openhuman_home, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
tokio::time::sleep(Duration::from_millis(100)).await;
// --- device_profile ---
let profile = post_json_rpc(
&rpc_base,
30,
"openhuman.local_ai_device_profile",
json!({}),
)
.await;
let profile_result = assert_no_jsonrpc_error(&profile, "device_profile");
assert!(
profile_result
.get("total_ram_bytes")
.and_then(Value::as_u64)
.unwrap_or(0)
> 0,
"expected positive RAM: {profile_result}"
);
assert!(
profile_result
.get("cpu_count")
.and_then(Value::as_u64)
.unwrap_or(0)
> 0,
"expected positive CPU count: {profile_result}"
);
// --- presets ---
let presets = post_json_rpc(&rpc_base, 31, "openhuman.local_ai_presets", json!({})).await;
let presets_result = assert_no_jsonrpc_error(&presets, "presets");
let presets_arr = presets_result
.get("presets")
.and_then(Value::as_array)
.expect("presets should be an array");
assert_eq!(presets_arr.len(), 5, "expected 5 presets: {presets_result}");
let recommended = presets_result
.get("recommended_tier")
.and_then(Value::as_str)
.expect("should have recommended_tier");
assert!(
[
"ram_1gb",
"ram_2_4gb",
"ram_4_8gb",
"ram_8_16gb",
"ram_16_plus_gb",
]
.contains(&recommended),
"unexpected recommended_tier: {recommended}"
);
let current = presets_result
.get("current_tier")
.and_then(Value::as_str)
.expect("should have current_tier");
// Default config uses gemma3:4b-it-qat which now maps to the 8-16 GB tier.
assert_eq!(
current, "ram_8_16gb",
"default config should be the 8-16 GB tier"
);
// --- apply_preset (switch to 2-4 GB) ---
let apply = post_json_rpc(
&rpc_base,
32,
"openhuman.local_ai_apply_preset",
json!({"tier": "ram_2_4gb"}),
)
.await;
let apply_result = assert_no_jsonrpc_error(&apply, "apply_preset");
assert_eq!(
apply_result.get("applied_tier").and_then(Value::as_str),
Some("ram_2_4gb")
);
assert_eq!(
apply_result.get("chat_model_id").and_then(Value::as_str),
Some("gemma3:1b-it-qat")
);
assert_eq!(
apply_result.get("vision_mode").and_then(Value::as_str),
Some("disabled")
);
// --- verify presets reflects the change ---
let presets_after = post_json_rpc(&rpc_base, 33, "openhuman.local_ai_presets", json!({})).await;
let presets_after_result = assert_no_jsonrpc_error(&presets_after, "presets_after");
assert_eq!(
presets_after_result
.get("current_tier")
.and_then(Value::as_str),
Some("ram_2_4gb"),
"current tier should now be 2-4 GB after apply"
);
// --- apply_preset with invalid tier should error ---
let bad_apply = post_json_rpc(
&rpc_base,
34,
"openhuman.local_ai_apply_preset",
json!({"tier": "ultra"}),
)
.await;
assert!(
bad_apply.get("error").is_some(),
"expected error for invalid tier: {bad_apply}"
);
mock_join.abort();
rpc_join.abort();
}
// ── Billing & Team E2E tests ──────────────────────────────────────────────────
/// End-to-end test for billing RPC methods.
///
/// Spins up an in-process Axum mock backend and a real JSON-RPC server, stores a
/// session JWT, then exercises every billing controller through the RPC surface
/// exactly as the desktop app or a CI script would.
#[tokio::test]
async fn billing_rpc_e2e() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
let mock_origin = format!("http://{}", mock_addr);
write_min_config(&openhuman_home, &mock_origin);
// Pre-create the user-scoped config so store_session finds correct settings.
let user_scoped_dir = openhuman_home.join("users").join("e2e-user");
write_min_config(&user_scoped_dir, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
tokio::time::sleep(Duration::from_millis(100)).await;
// Store a session first — all billing methods require it.
let store = post_json_rpc(
&rpc_base,
1,
"openhuman.auth_store_session",
json!({ "token": "e2e-billing-jwt", "user_id": "e2e-user" }),
)
.await;
assert_no_jsonrpc_error(&store, "store_session");
// Helper: the RPC outcome wraps backend data in {result: ..., logs: [...]}.
// We peel off the inner "result" field to get the actual backend payload.
fn inner(outer: &Value, _ctx: &str) -> Value {
outer
.get("result")
.cloned()
.unwrap_or_else(|| outer.clone())
}
// --- billing_get_current_plan ---
let plan = post_json_rpc(
&rpc_base,
2,
"openhuman.billing_get_current_plan",
json!({}),
)
.await;
let plan_outer = assert_no_jsonrpc_error(&plan, "billing_get_current_plan");
let plan_result = inner(plan_outer, "billing_get_current_plan");
assert_eq!(
plan_result.get("plan").and_then(Value::as_str),
Some("PRO"),
"expected PRO plan: {plan_result}"
);
assert_eq!(
plan_result
.get("hasActiveSubscription")
.and_then(Value::as_bool),
Some(true),
"expected active subscription: {plan_result}"
);
// --- billing_purchase_plan ---
let purchase = post_json_rpc(
&rpc_base,
3,
"openhuman.billing_purchase_plan",
json!({ "plan": "pro" }),
)
.await;
let purchase_outer = assert_no_jsonrpc_error(&purchase, "billing_purchase_plan");
let purchase_result = inner(purchase_outer, "billing_purchase_plan");
assert!(
purchase_result
.get("checkoutUrl")
.and_then(Value::as_str)
.is_some(),
"expected checkoutUrl: {purchase_result}"
);
// --- billing_create_portal_session ---
let portal = post_json_rpc(
&rpc_base,
4,
"openhuman.billing_create_portal_session",
json!({}),
)
.await;
let portal_outer = assert_no_jsonrpc_error(&portal, "billing_create_portal_session");
let portal_result = inner(portal_outer, "billing_create_portal_session");
assert!(
portal_result
.get("portalUrl")
.and_then(Value::as_str)
.is_some(),
"expected portalUrl: {portal_result}"
);
// --- billing_top_up ---
let top_up = post_json_rpc(
&rpc_base,
5,
"openhuman.billing_top_up",
json!({ "amountUsd": 10.0, "gateway": "stripe" }),
)
.await;
let top_up_outer = assert_no_jsonrpc_error(&top_up, "billing_top_up");
let top_up_result = inner(top_up_outer, "billing_top_up");
assert_eq!(
top_up_result.get("amountUsd").and_then(Value::as_f64),
Some(10.0),
"expected amountUsd 10.0: {top_up_result}"
);
// --- billing_create_coinbase_charge ---
let charge = post_json_rpc(
&rpc_base,
6,
"openhuman.billing_create_coinbase_charge",
json!({ "plan": "pro" }),
)
.await;
let charge_outer = assert_no_jsonrpc_error(&charge, "billing_create_coinbase_charge");
let charge_result = inner(charge_outer, "billing_create_coinbase_charge");
assert!(
charge_result
.get("hostedUrl")
.and_then(Value::as_str)
.is_some(),
"expected hostedUrl: {charge_result}"
);
assert_eq!(
charge_result.get("status").and_then(Value::as_str),
Some("NEW"),
"expected NEW status: {charge_result}"
);
mock_join.abort();
rpc_join.abort();
}
/// End-to-end test for team RPC methods.
///
/// Spins up an in-process Axum mock backend and a real JSON-RPC server, stores a
/// session JWT, then exercises every team controller through the RPC surface.
#[tokio::test]
async fn team_rpc_e2e() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
let mock_origin = format!("http://{}", mock_addr);
write_min_config(&openhuman_home, &mock_origin);
// Pre-create the user-scoped config so store_session finds correct settings.
let user_scoped_dir = openhuman_home.join("users").join("e2e-user");
write_min_config(&user_scoped_dir, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
tokio::time::sleep(Duration::from_millis(100)).await;
// Store a session first — all team methods require it.
let store = post_json_rpc(
&rpc_base,
1,
"openhuman.auth_store_session",
json!({ "token": "e2e-team-jwt", "user_id": "e2e-user" }),
)
.await;
assert_no_jsonrpc_error(&store, "store_session");
// Helper: peel off the inner "result" field from the RPC outcome envelope.
fn inner(outer: &Value, _ctx: &str) -> Value {
outer
.get("result")
.cloned()
.unwrap_or_else(|| outer.clone())
}
let team_id = "team-1";
// --- team_list_members ---
let members = post_json_rpc(
&rpc_base,
2,
"openhuman.team_list_members",
json!({ "teamId": team_id }),
)
.await;
let members_outer = assert_no_jsonrpc_error(&members, "team_list_members");
let members_result = inner(members_outer, "team_list_members");
let members_arr = members_result
.as_array()
.expect("expected array of members");
assert_eq!(members_arr.len(), 2, "expected 2 members: {members_result}");
assert_eq!(
members_arr[0].get("username").and_then(Value::as_str),
Some("alice")
);
// --- team_create_invite ---
let invite = post_json_rpc(
&rpc_base,
3,
"openhuman.team_create_invite",
json!({ "teamId": team_id, "maxUses": 3, "expiresInDays": 7 }),
)
.await;
let invite_outer = assert_no_jsonrpc_error(&invite, "team_create_invite");
let invite_result = inner(invite_outer, "team_create_invite");
assert!(
invite_result.get("code").and_then(Value::as_str).is_some(),
"expected invite code: {invite_result}"
);
// --- team_list_invites ---
let invites = post_json_rpc(
&rpc_base,
4,
"openhuman.team_list_invites",
json!({ "teamId": team_id }),
)
.await;
let invites_outer = assert_no_jsonrpc_error(&invites, "team_list_invites");
let invites_result = inner(invites_outer, "team_list_invites");
let invites_arr = invites_result
.as_array()
.expect("expected array of invites");
assert!(
!invites_arr.is_empty(),
"expected at least one invite: {invites_result}"
);
// --- team_revoke_invite (no payload to check, just assert no error) ---
let revoke = post_json_rpc(
&rpc_base,
5,
"openhuman.team_revoke_invite",
json!({ "teamId": team_id, "inviteId": "inv-1" }),
)
.await;
assert_no_jsonrpc_error(&revoke, "team_revoke_invite");
// --- team_remove_member ---
let remove = post_json_rpc(
&rpc_base,
6,
"openhuman.team_remove_member",
json!({ "teamId": team_id, "userId": "user-2" }),
)
.await;
assert_no_jsonrpc_error(&remove, "team_remove_member");
// --- team_change_member_role ---
let role_change = post_json_rpc(
&rpc_base,
7,
"openhuman.team_change_member_role",
json!({ "teamId": team_id, "userId": "user-1", "role": "MEMBER" }),
)
.await;
assert_no_jsonrpc_error(&role_change, "team_change_member_role");
mock_join.abort();
rpc_join.abort();
}
#[tokio::test]
async fn about_app_rpc_list_lookup_and_search() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
let mock_origin = format!("http://{}", mock_addr);
write_min_config(&openhuman_home, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
tokio::time::sleep(Duration::from_millis(100)).await;
fn inner(outer: &Value) -> Value {
outer
.get("result")
.cloned()
.unwrap_or_else(|| outer.clone())
}
let list = post_json_rpc(&rpc_base, 200, "openhuman.about_app_list", json!({})).await;
let list_outer = assert_no_jsonrpc_error(&list, "about_app_list");
let list_result = inner(list_outer);
let capabilities = list_result
.as_array()
.expect("about_app list should return an array");
assert!(
capabilities.len() >= 40,
"expected large capability catalog, got: {list_result}"
);
assert!(capabilities.iter().any(|capability| {
capability.get("id").and_then(Value::as_str) == Some("local_ai.download_model")
}));
let filtered = post_json_rpc(
&rpc_base,
201,
"openhuman.about_app_list",
json!({ "category": "local_ai" }),
)
.await;
let filtered_outer = assert_no_jsonrpc_error(&filtered, "about_app_list filtered");
let filtered_result = inner(filtered_outer);
let filtered_capabilities = filtered_result
.as_array()
.expect("filtered about_app list should return an array");
assert!(
!filtered_capabilities.is_empty(),
"expected local_ai capabilities: {filtered_result}"
);
assert!(filtered_capabilities.iter().all(|capability| {
capability.get("category").and_then(Value::as_str) == Some("local_ai")
}));
let lookup = post_json_rpc(
&rpc_base,
202,
"openhuman.about_app_lookup",
json!({ "id": "team.generate_invite_codes" }),
)
.await;
let lookup_outer = assert_no_jsonrpc_error(&lookup, "about_app_lookup");
let lookup_result = inner(lookup_outer);
assert_eq!(
lookup_result.get("id").and_then(Value::as_str),
Some("team.generate_invite_codes")
);
assert_eq!(
lookup_result.get("category").and_then(Value::as_str),
Some("team")
);
let search = post_json_rpc(
&rpc_base,
203,
"openhuman.about_app_search",
json!({ "query": "invite" }),
)
.await;
let search_outer = assert_no_jsonrpc_error(&search, "about_app_search");
let search_result = inner(search_outer);
let search_capabilities = search_result
.as_array()
.expect("about_app search should return an array");
assert!(
search_capabilities.iter().any(|capability| {
capability.get("id").and_then(Value::as_str) == Some("team.join_via_invite_code")
}),
"expected invite-related capability in search results: {search_result}"
);
assert!(
search_capabilities.iter().any(|capability| {
capability.get("id").and_then(Value::as_str) == Some("team.generate_invite_codes")
}),
"expected invite generation capability in search results: {search_result}"
);
mock_join.abort();
rpc_join.abort();
}
#[tokio::test]
async fn voice_status_returns_availability() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let _whisper_guard = EnvVarGuard::unset("WHISPER_BIN");
let _piper_guard = EnvVarGuard::unset("PIPER_BIN");
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
let mock_origin = format!("http://{}", mock_addr);
write_min_config(&openhuman_home, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
tokio::time::sleep(Duration::from_millis(100)).await;
// voice_status does not require auth — it only checks filesystem availability
let status = post_json_rpc(&rpc_base, 1, "openhuman.voice_status", json!({})).await;
let result = assert_no_jsonrpc_error(&status, "voice_status");
// Without whisper/piper installed in the test env, both should be unavailable
assert!(
result.get("stt_available").is_some(),
"expected stt_available field: {result}"
);
assert!(
result.get("tts_available").is_some(),
"expected tts_available field: {result}"
);
assert!(
result.get("stt_model_id").is_some(),
"expected stt_model_id field: {result}"
);
assert!(
result.get("tts_voice_id").is_some(),
"expected tts_voice_id field: {result}"
);
// Verify that without binaries, availability is false
assert_eq!(
result.get("stt_available").and_then(Value::as_bool),
Some(false),
"stt should be unavailable without whisper binary"
);
assert_eq!(
result.get("tts_available").and_then(Value::as_bool),
Some(false),
"tts should be unavailable without piper binary"
);
mock_join.abort();
rpc_join.abort();
}