From 5d1dc644dc1d15d72593db9a74c8cefa6c432867 Mon Sep 17 00:00:00 2001 From: Chen Qian Date: Fri, 22 May 2026 09:12:20 -0700 Subject: [PATCH 01/21] docs(spec): UI-configurable max_actions_per_hour design Scopes issue #2486 to a single autonomy knob: add a new config_get_autonomy_settings / config_update_autonomy_settings RPC pair, surface it as an "Agent autonomy" subsection in DeveloperOptionsPanel, persist to user config.toml. Mirrors the existing config_*_settings pattern; effect applies to next session. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...26-05-22-max-actions-per-hour-ui-design.md | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-22-max-actions-per-hour-ui-design.md diff --git a/docs/superpowers/specs/2026-05-22-max-actions-per-hour-ui-design.md b/docs/superpowers/specs/2026-05-22-max-actions-per-hour-ui-design.md new file mode 100644 index 000000000..74d72950b --- /dev/null +++ b/docs/superpowers/specs/2026-05-22-max-actions-per-hour-ui-design.md @@ -0,0 +1,216 @@ +# Design: UI-configurable `max_actions_per_hour` + +**Issue:** [tinyhumansai/openhuman#2486](https://github.com/tinyhumansai/openhuman/issues/2486) (scoped to one item). +**Date:** 2026-05-22. +**Status:** Draft (pre-implementation). + +## Problem + +The agent's tool action ceiling defaults to `max_actions_per_hour = 20`. Once exhausted, all subsequent tool calls are silently denied (`"Rate limit exceeded: action budget exhausted"`). For non-trivial sessions this is too low. Today, raising it requires hand-editing `~/.openhuman/.../config.toml` and restarting the core. There is no UI control. + +The backend already supports the field — `AutonomyConfig.max_actions_per_hour` is loaded from TOML, defaults to 20, and threaded through `SecurityPolicy::from_config()` at every site that builds a policy (session builder, cron scheduler, channels runtime, MCP server, node runtime, local CLI). What's missing is a way to change it from the app. + +## Scope + +**In scope** +- A user-editable `max_actions_per_hour` field, persisted to the user's `config.toml`. +- A pair of JSON-RPC methods to read and write the value. +- A new "Agent autonomy" subsection inside the existing `DeveloperOptionsPanel`. +- Validation: `1 <= value <= 10_000`. + +**Out of scope (deliberate)** +- Other autonomy fields raised in issue #2486 — `allowed_commands`, `auto_approve`, `block_high_risk_commands`, `max_cost_per_day_cents`. The new RPC and panel are shaped so these can be added later by extending the same patch struct + panel section. +- Hot-reload of running sessions / cron jobs / channels. New value applies to the *next* session; running policies keep their existing limit. +- Aggregated per-user usage display (`"X / Y used this hour"`). The action counter lives inside per-session `SecurityPolicy` instances, so there is no single number to display without first building one. +- Fixing the pre-existing `openhuman.security_policy_info` bug (returns `SecurityPolicy::default()` instead of the loaded config). Filed as a separate follow-up; this PR sidesteps it by not reading from that endpoint. + +## Architecture + +Four thin layers following the established `config_*_settings` pattern in this repo (e.g. `config_get_meet_settings` / `config_update_meet_settings`): + +``` +UI (React) + DeveloperOptionsPanel — new "Agent autonomy" subsection + │ + ▼ coreRpcClient → invoke('core_rpc_relay', …) +JSON-RPC controllers (src/openhuman/config/schemas.rs) + handle_get_autonomy_settings → openhuman.config_get_autonomy_settings + handle_update_autonomy_settings → openhuman.config_update_autonomy_settings + │ + ▼ +Domain ops (src/openhuman/config/ops.rs) + apply_autonomy_settings(&mut Config, AutonomySettingsPatch) + load_and_apply_autonomy_settings(AutonomySettingsPatch) + │ + ▼ config.save() → user TOML +Existing readers (unchanged) + SecurityPolicy::from_config() in: + - agent/harness/session/builder.rs + - cron/scheduler.rs, cron/ops.rs + - channels/runtime/startup.rs + - mcp_server/tools.rs + - runtime_node/ops.rs + - tools/local_cli.rs +``` + +Each new construction of `SecurityPolicy` reads the current `Config`, so a saved change takes effect on the next session / cron tick / channel pickup without any propagation work. + +## Components + +### Rust core + +**`src/openhuman/config/ops.rs`** — add (mirror `MeetSettingsPatch` / `apply_meet_settings`): + +```rust +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct AutonomySettingsPatch { + pub max_actions_per_hour: Option, +} + +pub async fn apply_autonomy_settings( + config: &mut Config, + update: AutonomySettingsPatch, +) -> Result, String> { + if let Some(v) = update.max_actions_per_hour { + if v == 0 || v > 10_000 { + return Err("max_actions_per_hour must be between 1 and 10000".into()); + } + config.autonomy.max_actions_per_hour = v; + } + config.save().await.map_err(|e| e.to_string())?; + let snapshot = snapshot_config_json(config)?; + Ok(RpcOutcome::new( + snapshot, + vec![format!("autonomy settings saved to {}", config.config_path.display())], + )) +} + +pub async fn load_and_apply_autonomy_settings( + update: AutonomySettingsPatch, +) -> Result, String> { + let mut config = load_config_with_timeout().await?; + apply_autonomy_settings(&mut config, update).await +} +``` + +**`src/openhuman/config/schemas.rs`** — add: + +- `ControllerSchema` entries for `get_autonomy_settings` and `update_autonomy_settings`, registered in the controller list near the existing meet entries (~line 286). +- Schema definitions in the `schemas(name)` match block (~line 672) — `get_autonomy_settings` takes no params; `update_autonomy_settings` takes `{ max_actions_per_hour?: u32 }`. +- `handle_get_autonomy_settings` returns `{ "max_actions_per_hour": }` from a loaded `Config`. +- `handle_update_autonomy_settings` deserialises into an `AutonomySettingsUpdate` DTO, builds the patch, calls `load_and_apply_autonomy_settings`. + +Both handlers follow the existing `debug!("[config][rpc] X enter") / ok / failed` logging pattern. + +Resulting RPC method names: +- `openhuman.config_get_autonomy_settings` +- `openhuman.config_update_autonomy_settings` + +### Tauri / TypeScript + +**`app/src/utils/tauriCommands/config.ts`** — add `getAutonomySettings()` and `updateAutonomySettings(patch)` wrappers, mirroring the meet-settings wrappers. + +**`app/src/services/rpcMethods.ts`** — add the two new method constants. + +**`app/src/components/settings/panels/DeveloperOptionsPanel.tsx`** — append an "Agent autonomy" subsection: +- Heading + helper text: *"Maximum tool actions an agent can run per hour. New value applies to your next chat — running sessions keep their current limit."* +- Number `` with `min=1`, `max=10000`, integer step. +- Preset chips: `20 (default)`, `100`, `500`, `1000`. +- Save button — disabled when value is unchanged or invalid. +- Inline confirmation on save; inline error message on failure. + +The panel fetches the current value via `getAutonomySettings()` on mount; on save, calls `updateAutonomySettings({ max_actions_per_hour })`. On success, keeps the edited value as the new committed state and shows confirmation; on error, reverts the UI to the last committed value and shows the error message. + +## Data flow + +**Save** +1. User edits → clicks Save in `DeveloperOptionsPanel`. +2. `updateAutonomySettings({ max_actions_per_hour: 200 })` → `core_rpc_relay` → core. +3. `handle_update_autonomy_settings` → `load_and_apply_autonomy_settings` → mutates `config.autonomy.max_actions_per_hour` → `config.save()` writes user TOML. +4. RPC returns `RpcOutcome { value: snapshot_json, logs: ["autonomy settings saved to "] }`. +5. UI shows inline "Saved" confirmation. + +**Read** +1. Panel mounts → `getAutonomySettings()` → `openhuman.config_get_autonomy_settings`. +2. `handle_get_autonomy_settings` calls `load_config_with_timeout()` → returns `{ max_actions_per_hour: config.autonomy.max_actions_per_hour }`. +3. UI initialises field state with returned value. + +## Error handling + +- **Invalid input** (≤0, >10000, non-integer): rejected client-side first via `min`/`max` attributes; re-validated in the handler — returns `Err("max_actions_per_hour must be between 1 and 10000")`. UI surfaces the message inline. +- **Config load timeout / disk write failure**: propagates as RPC error; panel shows the message inline; existing TOML on disk is unchanged. +- **Core not yet ready**: panel handles this the same way other panels do — loading skeleton, retry on RPC error. +- **Edits do not affect running sessions**: documented in the panel's helper text. This is expected behavior, not a failure mode — no warning surfaced. + +## Logging + +Per repo rule (verbose diagnostics on new/changed flows, stable grep-friendly prefixes): + +- `[config][rpc] update_autonomy_settings enter max_actions_per_hour=` +- `[config][rpc] update_autonomy_settings ok` / `... failed: ` +- `[config][rpc] get_autonomy_settings enter` +- `[config][rpc] get_autonomy_settings ok max_actions_per_hour=` / `... failed: ` + +## Testing + +**Rust unit (`src/openhuman/config/ops_tests.rs`)** — alongside the `apply_meet_settings` tests: +- `apply_autonomy_settings_persists_max_actions_per_hour` — assert config mutated + saved to disk. +- `apply_autonomy_settings_no_op_when_patch_empty` — `None` patch leaves the value unchanged. +- `apply_autonomy_settings_rejects_zero` and `_rejects_above_cap` — validation works at both bounds. +- `load_and_apply_autonomy_settings_roundtrip` — load → apply → reload → value matches. + +**Rust handler (`src/openhuman/config/schemas_tests.rs`)**: +- `update_autonomy_settings` and `get_autonomy_settings` route through the controller registry and return the expected JSON shape. +- Invalid params return `Err`. + +**Rust E2E (`tests/json_rpc_e2e.rs`)**: +- New test: post `openhuman.config_update_autonomy_settings` then `openhuman.config_get_autonomy_settings`; assert the round-trip over actual JSON-RPC. + +**TypeScript unit (`app/src/utils/tauriCommands/config.test.ts`)**: +- `getAutonomySettings` invokes the correct method. +- `updateAutonomySettings` passes the patch correctly. + +**UI (`app/src/components/settings/panels/__tests__/DeveloperOptionsPanel.test.tsx`)**: +- Loads current value on mount. +- Save button disabled until value changes and is valid. +- Save calls the wrapper with the new value, shows confirmation. +- Validation error surfaces inline. + +**E2E (`app/test/e2e/specs/settings-advanced-config.spec.ts`)** — add a case to the existing spec rather than a new file: +- Open Developer Options, change the rate-limit field, save, reopen, confirm persisted value. + +Coverage on changed lines must meet the repo's ≥80% merge gate. + +## File touch list + +``` +src/openhuman/config/ops.rs (+ ~30 lines, mirror meet pattern) +src/openhuman/config/ops_tests.rs (+ ~80 lines, new tests) +src/openhuman/config/schemas.rs (+ ~60 lines: schema, registration, handlers) +src/openhuman/config/schemas_tests.rs (+ ~40 lines) +tests/json_rpc_e2e.rs (+ ~30 lines, round-trip test) + +app/src/utils/tauriCommands/config.ts (+ ~25 lines, two wrappers) +app/src/utils/tauriCommands/config.test.ts (+ ~30 lines) +app/src/services/rpcMethods.ts (+ 2 lines, method constants) +app/src/components/settings/panels/DeveloperOptionsPanel.tsx (+ ~80 lines, new section) +app/src/components/settings/panels/__tests__/DeveloperOptionsPanel.test.tsx (+ ~60 lines) +app/test/e2e/specs/settings-advanced-config.spec.ts (+ ~30 lines, new case) +``` + +No new files; no schema-breaking changes to existing handlers; no changes to `SecurityPolicy`, `from_config`, or any consumer of those. + +## Risks & open questions + +- **Stale running sessions** — a user who hits the ceiling, raises the limit, and expects the *current* chat to recover will be confused. Mitigated by helper text. If this turns out to be a common complaint, Approach C (live propagation via event bus) is the follow-up. +- **`security_policy_info` returns defaults** — pre-existing bug, deferred. The UI does not read from it. +- **Cap of 10,000** — chosen as "effectively unlimited for human use" while bounding the field against typos. Easy to lift if needed. + +## Sequencing + +1. Rust ops + schemas + their unit tests. +2. Rust E2E round-trip test. +3. TS wrappers + their unit tests. +4. UI panel section + UI tests. +5. E2E spec case. +6. Manual smoke (start dev app, change value, restart-free verify by starting a new agent session). From 7646ccc99326ce39e061fc8710ad42c8629ae650 Mon Sep 17 00:00:00 2001 From: Chen Qian Date: Fri, 22 May 2026 09:39:39 -0700 Subject: [PATCH 02/21] docs(plan): max_actions_per_hour UI implementation plan 13-task TDD plan covering Rust ops + RPC schemas + handlers, JSON-RPC roundtrip, TS wrappers + tests, new AutonomyPanel + route, UI tests, and E2E case in settings-advanced-config.spec.ts. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-22-max-actions-per-hour-ui.md | 1340 +++++++++++++++++ 1 file changed, 1340 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-22-max-actions-per-hour-ui.md diff --git a/docs/superpowers/plans/2026-05-22-max-actions-per-hour-ui.md b/docs/superpowers/plans/2026-05-22-max-actions-per-hour-ui.md new file mode 100644 index 000000000..520eaf568 --- /dev/null +++ b/docs/superpowers/plans/2026-05-22-max-actions-per-hour-ui.md @@ -0,0 +1,1340 @@ +# Max Actions Per Hour UI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the agent's `max_actions_per_hour` rate limit editable from Settings instead of requiring hand-edits to `config.toml`. + +**Architecture:** Add a new `config_get_autonomy_settings` / `config_update_autonomy_settings` RPC pair (mirroring the existing `config_*_meet_settings` pair). Persist the new value to the user's `config.toml`. Surface it through a new `AutonomyPanel` linked from `DeveloperOptionsPanel`. Effect takes hold on the next `SecurityPolicy::from_config(...)` call (next chat / cron tick); running policies keep their existing limit — documented in helper text. + +**Tech Stack:** Rust (`openhuman-core` lib, tokio, serde, schemars), TypeScript / React (`app/` workspace, Vite, Tailwind, Vitest, WDIO). + +**Spec:** `docs/superpowers/specs/2026-05-22-max-actions-per-hour-ui-design.md` + +**Branch:** `feat/ui-max-actions-per-hour` (already created; spec already committed). + +**Note on scope refinement vs. spec**: the spec said "append an Agent autonomy subsection inside DeveloperOptionsPanel." On inspection, that panel is a list of `SettingsMenuItem` rows that each navigate to a dedicated subpanel; in-page form callouts (`CoreModeBadge`, `LogsFolderRow`, `SentryTestRow`) are reserved for tiny diagnostic widgets. A user-editable form belongs in its own subpanel — that also matches how every other autonomy/security knob added later would land. The plan therefore creates `AutonomyPanel.tsx` and adds a menu link in `DeveloperOptionsPanel`. Same UX intent, just plumbed via the standard pattern. + +--- + +## File Structure + +| File | Status | Responsibility | +| --- | --- | --- | +| `src/openhuman/config/ops.rs` | modify | Add `AutonomySettingsPatch` + `apply_autonomy_settings` + `load_and_apply_autonomy_settings` | +| `src/openhuman/config/ops_tests.rs` | modify | Unit tests for the new ops | +| `src/openhuman/config/schemas.rs` | modify | Add `AutonomySettingsUpdate` DTO, two `ControllerSchema` entries, two handlers, register both controllers | +| `src/openhuman/config/schemas_tests.rs` | modify | Handler-level tests through the controller registry | +| `tests/json_rpc_e2e.rs` | modify | New roundtrip test over real JSON-RPC | +| `app/src/services/rpcMethods.ts` | modify | Add two method-name constants | +| `app/src/utils/tauriCommands/config.ts` | modify | Add `openhumanGetAutonomySettings` + `openhumanUpdateAutonomySettings` wrappers | +| `app/src/utils/tauriCommands/config.test.ts` | modify | Unit tests for the two wrappers | +| `app/src/components/settings/panels/AutonomyPanel.tsx` | create | The form (number input + presets + save) | +| `app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx` | create | UI tests for the panel | +| `app/src/components/settings/panels/DeveloperOptionsPanel.tsx` | modify | Add menu item linking to the new panel | +| `app/src/components/settings/hooks/useSettingsNavigation.ts` | modify | Add `'autonomy'` to `SettingsRoute` union; add path detection | +| `app/src/pages/Settings.tsx` | modify | Register the `/settings/autonomy` route | +| `app/test/e2e/specs/settings-advanced-config.spec.ts` | modify | Add E2E case for save + persist | + +No new top-level RPC namespaces, no schema-breaking changes to existing handlers, no changes to `SecurityPolicy` / `from_config` / consumers. + +--- + +## Task 1: Rust — `AutonomySettingsPatch` + `apply_autonomy_settings` + +**Files:** +- Modify: `src/openhuman/config/ops.rs` (add struct + function after existing `MeetSettingsPatch` at line 384 area) +- Test: `src/openhuman/config/ops_tests.rs` + +- [ ] **Step 1: Write the failing test** + +Append to `src/openhuman/config/ops_tests.rs` (after the existing `apply_meet_settings_updates_handoff_flag` test): + +```rust +#[tokio::test] +async fn apply_autonomy_settings_persists_max_actions_per_hour() { + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + let outcome = apply_autonomy_settings( + &mut cfg, + AutonomySettingsPatch { + max_actions_per_hour: Some(200), + }, + ) + .await + .expect("apply"); + assert_eq!(cfg.autonomy.max_actions_per_hour, 200); + // Snapshot returned so the caller can echo the saved state. + assert!(outcome.value.get("config").is_some()); + // Round-trip from disk: reload the saved TOML and confirm. + let on_disk = tokio::fs::read_to_string(&cfg.config_path).await.unwrap(); + assert!( + on_disk.contains("max_actions_per_hour = 200"), + "expected TOML to contain max_actions_per_hour = 200, got:\n{on_disk}" + ); +} + +#[tokio::test] +async fn apply_autonomy_settings_no_op_when_patch_empty() { + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + let prior = cfg.autonomy.max_actions_per_hour; + let _ = apply_autonomy_settings( + &mut cfg, + AutonomySettingsPatch { max_actions_per_hour: None }, + ) + .await + .expect("apply noop"); + assert_eq!(cfg.autonomy.max_actions_per_hour, prior); +} + +#[tokio::test] +async fn apply_autonomy_settings_rejects_zero() { + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + let err = apply_autonomy_settings( + &mut cfg, + AutonomySettingsPatch { max_actions_per_hour: Some(0) }, + ) + .await + .unwrap_err(); + assert!( + err.contains("between 1 and 10000"), + "expected validation error, got: {err}" + ); +} + +#[tokio::test] +async fn apply_autonomy_settings_rejects_above_cap() { + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + let err = apply_autonomy_settings( + &mut cfg, + AutonomySettingsPatch { max_actions_per_hour: Some(10_001) }, + ) + .await + .unwrap_err(); + assert!(err.contains("between 1 and 10000")); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run from repo root: + +```bash +pnpm debug rust apply_autonomy_settings +``` + +Expected: 4 tests fail to compile — `cannot find type AutonomySettingsPatch in this scope`, `cannot find function apply_autonomy_settings`. That's the failing state for TDD. + +- [ ] **Step 3: Add the struct + function** + +In `src/openhuman/config/ops.rs`, immediately after the existing `MeetSettingsPatch` definition (around line 386), add: + +```rust +#[derive(Debug, Clone, Default)] +pub struct AutonomySettingsPatch { + pub max_actions_per_hour: Option, +} +``` + +Then add the apply function. Put it next to `apply_meet_settings` (around line 764) so it's discoverable with the other settings ops: + +```rust +/// Updates the autonomy policy settings in the configuration. +/// Validation: 1 <= max_actions_per_hour <= 10_000. +pub async fn apply_autonomy_settings( + config: &mut Config, + update: AutonomySettingsPatch, +) -> Result, String> { + if let Some(v) = update.max_actions_per_hour { + if v == 0 || v > 10_000 { + return Err(format!( + "max_actions_per_hour must be between 1 and 10000 (got {v})" + )); + } + config.autonomy.max_actions_per_hour = v; + } + config.save().await.map_err(|e| e.to_string())?; + let snapshot = snapshot_config_json(config)?; + Ok(RpcOutcome::new( + snapshot, + vec![format!( + "autonomy settings saved to {}", + config.config_path.display() + )], + )) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +pnpm debug rust apply_autonomy_settings +``` + +Expected: all 4 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/openhuman/config/ops.rs src/openhuman/config/ops_tests.rs +git commit -m "feat(config): add AutonomySettingsPatch + apply_autonomy_settings" +``` + +--- + +## Task 2: Rust — `load_and_apply_autonomy_settings` roundtrip + +**Files:** +- Modify: `src/openhuman/config/ops.rs` (add wrapper next to `load_and_apply_meet_settings` ~line 783) +- Test: `src/openhuman/config/ops_tests.rs` + +- [ ] **Step 1: Write the failing test** + +Append to `src/openhuman/config/ops_tests.rs`. Use the pattern from `load_and_apply_dictation_settings_rejects_invalid_activation_mode` at line 692 — that test shows how to set up `OPENHUMAN_WORKSPACE` so `load_config_with_timeout` reads the temp dir: + +```rust +#[tokio::test] +async fn load_and_apply_autonomy_settings_roundtrip() { + let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempdir().unwrap(); + unsafe { + std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path()); + } + + let patch = AutonomySettingsPatch { max_actions_per_hour: Some(500) }; + let outcome = load_and_apply_autonomy_settings(patch).await.expect("apply"); + assert!(outcome.value.get("config").is_some()); + + // Reload from scratch and confirm the saved value sticks. + let reloaded = load_config_with_timeout().await.expect("reload"); + assert_eq!(reloaded.autonomy.max_actions_per_hour, 500); + + unsafe { std::env::remove_var("OPENHUMAN_WORKSPACE"); } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +```bash +pnpm debug rust load_and_apply_autonomy_settings_roundtrip +``` + +Expected: fails — `cannot find function load_and_apply_autonomy_settings`. + +- [ ] **Step 3: Add the wrapper** + +In `src/openhuman/config/ops.rs`, immediately after `load_and_apply_meet_settings` (~line 783): + +```rust +/// Loads the configuration, applies autonomy settings updates, and saves it. +pub async fn load_and_apply_autonomy_settings( + update: AutonomySettingsPatch, +) -> Result, String> { + let mut config = load_config_with_timeout().await?; + apply_autonomy_settings(&mut config, update).await +} +``` + +- [ ] **Step 4: Run to verify pass** + +```bash +pnpm debug rust load_and_apply_autonomy_settings_roundtrip +``` + +Expected: pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/openhuman/config/ops.rs src/openhuman/config/ops_tests.rs +git commit -m "feat(config): add load_and_apply_autonomy_settings roundtrip" +``` + +--- + +## Task 3: Rust — `AutonomySettingsUpdate` DTO + schema entries + +**Files:** +- Modify: `src/openhuman/config/schemas.rs` + +This task is schema/registration plumbing — no test step yet. Tests come in Task 4 (handler) and Task 5 (E2E). + +- [ ] **Step 1: Add the DTO** + +In `src/openhuman/config/schemas.rs`, after the existing `MeetSettingsUpdate` struct (around line 120): + +```rust +#[derive(Debug, Deserialize)] +struct AutonomySettingsUpdate { + max_actions_per_hour: Option, +} +``` + +- [ ] **Step 2: Add schema definitions** + +Inside the `schemas(name)` match block. Insert immediately after the `"get_meet_settings"` arm (around line 694): + +```rust + "update_autonomy_settings" => ControllerSchema { + namespace: "config", + function: "update_autonomy_settings", + description: + "Update agent autonomy policy settings (currently the per-hour tool action ceiling).", + inputs: vec![FieldSchema { + name: "max_actions_per_hour", + ty: TypeSchema::Option(Box::new(TypeSchema::U64)), + comment: "Maximum tool actions an agent may run per rolling hour (1-10000).", + required: false, + }], + outputs: vec![json_output("snapshot", "Updated config snapshot.")], + }, + "get_autonomy_settings" => ControllerSchema { + namespace: "config", + function: "get_autonomy_settings", + description: "Read current agent autonomy policy settings.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "max_actions_per_hour", + ty: TypeSchema::U64, + comment: "Current maximum tool actions per rolling hour.", + required: true, + }], + }, +``` + +Note: `TypeSchema::U32` does not exist (see `src/core/mod.rs:81`). Use `U64` for the schema (informational); the DTO still uses `u32` and serde narrows the JSON number — out-of-range values get rejected by the validation in `apply_autonomy_settings`. + +- [ ] **Step 3: Register in `all_controller_schemas`** + +In the `all_controller_schemas()` vec (around line 207), append after `schemas("get_meet_settings")`: + +```rust + schemas("update_autonomy_settings"), + schemas("get_autonomy_settings"), +``` + +- [ ] **Step 4: Verify it compiles** + +```bash +cargo check --manifest-path Cargo.toml 2>&1 | tail -20 +``` + +Expected: clean compile (or only an unused-function warning for the not-yet-wired handlers we'll add in Task 4). + +- [ ] **Step 5: Commit** + +```bash +git add src/openhuman/config/schemas.rs +git commit -m "feat(config): add autonomy_settings schemas + DTO" +``` + +--- + +## Task 4: Rust — handlers + controller registration + +**Files:** +- Modify: `src/openhuman/config/schemas.rs` +- Test: `src/openhuman/config/schemas_tests.rs` + +- [ ] **Step 1: Write the failing tests** + +Look at `src/openhuman/config/schemas_tests.rs` for the testing convention used for other handlers — find an existing handler test (search for `handle_update_meet_settings` or `handle_get_meet_settings` in that file) and mirror the pattern. If no such test exists for meet, fall back to the analytics one (`handle_get_analytics_settings`). Append: + +```rust +#[tokio::test] +async fn handle_get_autonomy_settings_returns_current_value() { + let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + unsafe { + std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path()); + } + // Apply a known value first. + let _ = crate::openhuman::config::ops::load_and_apply_autonomy_settings( + crate::openhuman::config::ops::AutonomySettingsPatch { + max_actions_per_hour: Some(123), + }, + ) + .await + .expect("seed"); + + let out = super::handle_get_autonomy_settings(serde_json::Map::new()) + .await + .expect("handler"); + let value = out.get("max_actions_per_hour").and_then(|v| v.as_u64()); + assert_eq!(value, Some(123)); + + unsafe { std::env::remove_var("OPENHUMAN_WORKSPACE"); } +} + +#[tokio::test] +async fn handle_update_autonomy_settings_rejects_invalid_value() { + let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + unsafe { + std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path()); + } + let mut params = serde_json::Map::new(); + params.insert("max_actions_per_hour".into(), serde_json::json!(0)); + + let err = super::handle_update_autonomy_settings(params).await.unwrap_err(); + assert!(err.contains("between 1 and 10000"), "got: {err}"); + + unsafe { std::env::remove_var("OPENHUMAN_WORKSPACE"); } +} +``` + +If `TEST_ENV_LOCK` isn't already imported at the top of `schemas_tests.rs`, mirror what `ops_tests.rs` does (`use crate::openhuman::config::TEST_ENV_LOCK;`). If `handle_get_autonomy_settings` / `handle_update_autonomy_settings` aren't visible (they're private fns in `schemas.rs`), use the controller-registry route shown in the alternative below. + +**Alternative if private-fn access blocks compilation**: invoke through the registered controller dispatcher. Find an existing test in `schemas_tests.rs` that calls a controller by method name (`grep -n 'handle_' schemas_tests.rs`) and adapt it. The handler functions are `pub(super) fn handle_*` or `fn handle_*` — if they're not in scope, dispatching through `crate::core::dispatch::try_invoke_registered_rpc("openhuman.config_get_autonomy_settings", Map::new())` is the canonical alternative (this is what `src/core/all_tests.rs:436` does for `security_policy_info`). + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pnpm debug rust handle_get_autonomy_settings +``` + +Expected: fail — handlers don't exist / aren't registered. + +- [ ] **Step 3: Add the handlers** + +In `src/openhuman/config/schemas.rs`, immediately after `handle_get_meet_settings` (around line 1154-1176), add: + +```rust +fn handle_update_autonomy_settings(params: Map) -> ControllerFuture { + Box::pin(async move { + log::debug!("[config][rpc] update_autonomy_settings enter"); + let update = match deserialize_params::(params) { + Ok(u) => u, + Err(err) => { + log::warn!("[config][rpc] update_autonomy_settings invalid params: {err}"); + return Err(err); + } + }; + log::debug!( + "[config][rpc] update_autonomy_settings patch max_actions_per_hour={:?}", + update.max_actions_per_hour + ); + let patch = config_rpc::AutonomySettingsPatch { + max_actions_per_hour: update.max_actions_per_hour, + }; + match config_rpc::load_and_apply_autonomy_settings(patch).await { + Ok(outcome) => { + log::debug!("[config][rpc] update_autonomy_settings ok"); + to_json(outcome) + } + Err(err) => { + log::warn!("[config][rpc] update_autonomy_settings failed: {err}"); + Err(err) + } + } + }) +} + +fn handle_get_autonomy_settings(_params: Map) -> ControllerFuture { + Box::pin(async { + log::debug!("[config][rpc] get_autonomy_settings enter"); + let config = match config_rpc::load_config_with_timeout().await { + Ok(c) => c, + Err(err) => { + log::warn!("[config][rpc] get_autonomy_settings load failed: {err}"); + return Err(err); + } + }; + let max_actions_per_hour = config.autonomy.max_actions_per_hour; + log::debug!( + "[config][rpc] get_autonomy_settings ok max_actions_per_hour={max_actions_per_hour}" + ); + let result = serde_json::json!({ + "max_actions_per_hour": max_actions_per_hour, + }); + to_json(RpcOutcome::new( + result, + vec!["autonomy settings read".to_string()], + )) + }) +} +``` + +`config_rpc` here is the existing alias for `crate::openhuman::config::ops` — confirm by grepping (`grep -n 'config_rpc' src/openhuman/config/schemas.rs | head`). + +- [ ] **Step 4: Register in `all_registered_controllers`** + +In `src/openhuman/config/schemas.rs` `all_registered_controllers()` vec (around line 289-292), append after the `get_meet_settings` entry: + +```rust + RegisteredController { + schema: schemas("update_autonomy_settings"), + handler: handle_update_autonomy_settings, + }, + RegisteredController { + schema: schemas("get_autonomy_settings"), + handler: handle_get_autonomy_settings, + }, +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +pnpm debug rust handle_get_autonomy_settings handle_update_autonomy_settings +``` + +Expected: both tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/openhuman/config/schemas.rs src/openhuman/config/schemas_tests.rs +git commit -m "feat(config): add autonomy_settings handlers + register controllers" +``` + +--- + +## Task 5: Rust — JSON-RPC E2E roundtrip + +**Files:** +- Test: `tests/json_rpc_e2e.rs` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/json_rpc_e2e.rs` (the file ends around line 3100; append after the last `#[tokio::test]`). Pattern adapted from the existing `json_rpc_web_chat_*` tests' setup: + +```rust +#[tokio::test] +async fn json_rpc_config_autonomy_settings_roundtrip() { + 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; + + // GET → expect the default (20). + let initial = post_json_rpc( + &rpc_base, + 7001, + "openhuman.config_get_autonomy_settings", + json!({}), + ) + .await; + let initial_result = assert_no_jsonrpc_error(&initial, "get_autonomy_settings initial"); + let initial_value = initial_result + .get("result") + .and_then(|r| r.get("max_actions_per_hour")) + .and_then(Value::as_u64); + assert_eq!(initial_value, Some(20), "expected default 20, got: {initial_result}"); + + // UPDATE → 250. + let update = post_json_rpc( + &rpc_base, + 7002, + "openhuman.config_update_autonomy_settings", + json!({ "max_actions_per_hour": 250 }), + ) + .await; + assert_no_jsonrpc_error(&update, "update_autonomy_settings"); + + // GET again → expect 250. + let after = post_json_rpc( + &rpc_base, + 7003, + "openhuman.config_get_autonomy_settings", + json!({}), + ) + .await; + let after_result = assert_no_jsonrpc_error(&after, "get_autonomy_settings after"); + let after_value = after_result + .get("result") + .and_then(|r| r.get("max_actions_per_hour")) + .and_then(Value::as_u64); + assert_eq!(after_value, Some(250)); + + // Invalid value rejected. + let bad = post_json_rpc( + &rpc_base, + 7004, + "openhuman.config_update_autonomy_settings", + json!({ "max_actions_per_hour": 99999 }), + ) + .await; + let err = bad.get("error").cloned().unwrap_or_else(|| bad.clone()); + let err_str = err.to_string(); + assert!( + err_str.contains("between 1 and 10000"), + "expected validation error in: {err_str}" + ); + + mock_join.abort(); + rpc_join.abort(); +} +``` + +- [ ] **Step 2: Run to verify it fails initially** (sanity — should fail if anything's mis-wired) + +```bash +pnpm debug rust json_rpc_config_autonomy_settings_roundtrip +``` + +If Tasks 1-4 are all done correctly, this should already pass on first run. If it fails, follow the debug-log output and re-check the controller registration in Task 4 Step 4. + +- [ ] **Step 3: Commit** + +```bash +git add tests/json_rpc_e2e.rs +git commit -m "test(rpc): roundtrip for config_*_autonomy_settings" +``` + +--- + +## Task 6: TS — RPC method constants + +**Files:** +- Modify: `app/src/services/rpcMethods.ts` + +- [ ] **Step 1: Add constants** + +In `app/src/services/rpcMethods.ts`, inside the `CORE_RPC_METHODS` object (keep alphabetical order — insert after `configGetAnalyticsSettings` at line 3): + +```ts + configGetAutonomySettings: 'openhuman.config_get_autonomy_settings', +``` + +and inside the update-settings group (after `configUpdateAnalyticsSettings` at line 7): + +```ts + configUpdateAutonomySettings: 'openhuman.config_update_autonomy_settings', +``` + +- [ ] **Step 2: Typecheck** + +```bash +pnpm typecheck 2>&1 | tail -10 +``` + +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add app/src/services/rpcMethods.ts +git commit -m "feat(app): add autonomy_settings RPC method constants" +``` + +--- + +## Task 7: TS — wrapper functions + +**Files:** +- Modify: `app/src/utils/tauriCommands/config.ts` + +- [ ] **Step 1: Add the wrappers** + +In `app/src/utils/tauriCommands/config.ts`, immediately after `openhumanGetMeetSettings` (around line 356, before the `ComposioTriggerSettingsUpdate` interface), add: + +```ts +export async function openhumanUpdateAutonomySettings(update: { + max_actions_per_hour?: number; +}): Promise> { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + return await callCoreRpc>({ + method: CORE_RPC_METHODS.configUpdateAutonomySettings, + params: update, + }); +} + +export async function openhumanGetAutonomySettings(): Promise< + CommandResponse<{ max_actions_per_hour: number }> +> { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + return await callCoreRpc>({ + method: CORE_RPC_METHODS.configGetAutonomySettings, + }); +} +``` + +- [ ] **Step 2: Typecheck** + +```bash +pnpm typecheck 2>&1 | tail -10 +``` + +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add app/src/utils/tauriCommands/config.ts +git commit -m "feat(app): add openhuman{Get,Update}AutonomySettings wrappers" +``` + +--- + +## Task 8: TS — wrapper unit tests + +**Files:** +- Test: `app/src/utils/tauriCommands/config.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Append to `app/src/utils/tauriCommands/config.test.ts` (after the meet-settings describe blocks, around line 98). Pattern is the same as `openhumanUpdateMeetSettings` (lines 61-98): + +```ts + describe('openhumanUpdateAutonomySettings', () => { + test('throws when not running in Tauri', async () => { + mockIsTauri.mockReturnValue(false); + await expect( + openhumanUpdateAutonomySettings({ max_actions_per_hour: 100 }) + ).rejects.toThrow('Not running in Tauri'); + expect(mockCallCoreRpc).not.toHaveBeenCalled(); + }); + + test('forwards the patch to openhuman.config_update_autonomy_settings', async () => { + mockCallCoreRpc.mockResolvedValue({ + result: { config: {}, workspace_dir: '/tmp', config_path: '/tmp/cfg.toml' }, + logs: [], + }); + await openhumanUpdateAutonomySettings({ max_actions_per_hour: 100 }); + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.config_update_autonomy_settings', + params: { max_actions_per_hour: 100 }, + }); + }); + }); + + describe('openhumanGetAutonomySettings', () => { + test('throws when not running in Tauri', async () => { + mockIsTauri.mockReturnValue(false); + await expect(openhumanGetAutonomySettings()).rejects.toThrow('Not running in Tauri'); + expect(mockCallCoreRpc).not.toHaveBeenCalled(); + }); + + test('reads via openhuman.config_get_autonomy_settings', async () => { + mockCallCoreRpc.mockResolvedValue({ + result: { max_actions_per_hour: 250 }, + logs: [], + }); + const out = await openhumanGetAutonomySettings(); + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.config_get_autonomy_settings', + }); + expect(out.result.max_actions_per_hour).toBe(250); + }); + }); +``` + +Add the imports at the top of the file (find the existing `openhumanUpdateMeetSettings` import and add the new ones alongside it): + +```ts +import { + // ... existing imports ... + openhumanGetAutonomySettings, + openhumanUpdateAutonomySettings, +} from './config'; +``` + +- [ ] **Step 2: Run to verify they pass** + +```bash +pnpm debug unit app/src/utils/tauriCommands/config.test.ts -t "AutonomySettings" +``` + +Expected: 4 tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add app/src/utils/tauriCommands/config.test.ts +git commit -m "test(app): cover openhuman{Get,Update}AutonomySettings wrappers" +``` + +--- + +## Task 9: New `AutonomyPanel.tsx` + +**Files:** +- Create: `app/src/components/settings/panels/AutonomyPanel.tsx` + +- [ ] **Step 1: Create the panel** + +Write `app/src/components/settings/panels/AutonomyPanel.tsx`: + +```tsx +import { useEffect, useState } from 'react'; + +import SettingsHeader from '../components/SettingsHeader'; +import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; +import { + openhumanGetAutonomySettings, + openhumanUpdateAutonomySettings, +} from '../../../utils/tauriCommands/config'; + +const PRESETS = [ + { label: '20 (default)', value: 20 }, + { label: '100', value: 100 }, + { label: '500', value: 500 }, + { label: '1000', value: 1000 }, +]; + +const MIN = 1; +const MAX = 10_000; + +type Status = + | { kind: 'idle' } + | { kind: 'loading' } + | { kind: 'saving' } + | { kind: 'saved' } + | { kind: 'error'; message: string }; + +const AutonomyPanel = () => { + const { navigateBack, breadcrumbs } = useSettingsNavigation(); + const [committed, setCommitted] = useState(null); + const [draft, setDraft] = useState(''); + const [status, setStatus] = useState({ kind: 'loading' }); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await openhumanGetAutonomySettings(); + if (cancelled) return; + const value = res.result.max_actions_per_hour; + setCommitted(value); + setDraft(String(value)); + setStatus({ kind: 'idle' }); + } catch (err) { + if (cancelled) return; + setStatus({ + kind: 'error', + message: err instanceof Error ? err.message : String(err), + }); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const parsed = Number.parseInt(draft, 10); + const isValid = + Number.isInteger(parsed) && parsed >= MIN && parsed <= MAX; + const isChanged = committed !== null && parsed !== committed; + const canSave = isValid && isChanged && status.kind !== 'saving'; + + const applyPreset = (value: number) => { + setDraft(String(value)); + if (status.kind === 'saved' || status.kind === 'error') { + setStatus({ kind: 'idle' }); + } + }; + + const onSave = async () => { + if (!canSave) return; + setStatus({ kind: 'saving' }); + try { + await openhumanUpdateAutonomySettings({ max_actions_per_hour: parsed }); + setCommitted(parsed); + setStatus({ kind: 'saved' }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + // Revert UI to last committed value, then surface the error. + if (committed !== null) setDraft(String(committed)); + setStatus({ kind: 'error', message }); + } + }; + + return ( +
+ +
+
+ +

+ Maximum tool actions an agent can run per rolling hour. New value + applies to your next chat — running sessions keep their current + limit. +

+ +
+ { + setDraft(e.target.value); + if (status.kind === 'saved' || status.kind === 'error') { + setStatus({ kind: 'idle' }); + } + }} + disabled={status.kind === 'loading' || status.kind === 'saving'} + className="w-32 px-3 py-1.5 rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-sm font-mono" + /> + +
+ +
+ {PRESETS.map(p => ( + + ))} +
+ +
+ {!isValid && draft.trim() !== '' && ( + + Must be an integer between {MIN} and {MAX.toLocaleString()}. + + )} + {status.kind === 'saved' && ( + Saved. + )} + {status.kind === 'error' && ( + + Failed: {status.message} + + )} +
+
+
+
+ ); +}; + +export default AutonomyPanel; +``` + +- [ ] **Step 2: Typecheck** + +```bash +pnpm typecheck 2>&1 | tail -10 +``` + +Expected: clean. If `useT` or i18n keys are missing for "Agent autonomy" / helper text, that's fine — strings are inline for now (i18n can be added later; matches the inline-string style used in `SentryTestRow`). + +- [ ] **Step 3: Commit** + +```bash +git add app/src/components/settings/panels/AutonomyPanel.tsx +git commit -m "feat(app): add AutonomyPanel for max_actions_per_hour control" +``` + +--- + +## Task 10: Wire `AutonomyPanel` into routing + Developer Options menu + +**Files:** +- Modify: `app/src/components/settings/hooks/useSettingsNavigation.ts` +- Modify: `app/src/pages/Settings.tsx` +- Modify: `app/src/components/settings/panels/DeveloperOptionsPanel.tsx` + +- [ ] **Step 1: Extend `SettingsRoute` union** + +In `app/src/components/settings/hooks/useSettingsNavigation.ts`, find the `SettingsRoute` type (top of file, around line 5-40). Add `'autonomy'` to the union. Pick a logical spot — e.g. right after `'developer-options'`. + +```ts +export type SettingsRoute = + // ... existing variants ... + | 'developer-options' + | 'autonomy' + // ... rest ... +``` + +Then add path detection in `getCurrentRoute()` (around line 94). Place it next to `'developer-options'`: + +```ts + if (path.includes('/settings/autonomy')) return 'autonomy'; +``` + +- [ ] **Step 2: Register the route** + +In `app/src/pages/Settings.tsx`: + +1. Add import (alphabetical-ish with the other panel imports near the top): + +```ts +import AutonomyPanel from '../components/settings/panels/AutonomyPanel'; +``` + +2. Inside the `` block (around line 355 next to the `developer-options` route), add: + +```tsx + )} /> +``` + +- [ ] **Step 3: Add menu link in `DeveloperOptionsPanel`** + +In `app/src/components/settings/panels/DeveloperOptionsPanel.tsx`, append to the `developerItems` array (after the `mcp-server` entry at line 240-256, before the closing `];`): + +```tsx + { + id: 'autonomy', + titleKey: 'settings.developerMenu.autonomy.title', + descriptionKey: 'settings.developerMenu.autonomy.desc', + route: 'autonomy', + icon: ( + + + + ), + }, +``` + +(SVG path is the standard "padlock" icon — fits the safety/autonomy framing.) + +The `titleKey` and `descriptionKey` will fall back to the literal key strings if no translation is registered yet — that's fine for now (other entries use the same pattern; i18n can be added in a follow-up commit if needed). To avoid raw keys in the UI, use literal strings instead: + +```tsx + { + id: 'autonomy', + titleKey: undefined, + descriptionKey: undefined, + title: 'Agent autonomy', + description: 'Tool action rate limits and safety thresholds.', + route: 'autonomy', + // ... icon as above ... + }, +``` + +BUT the existing render block (line 498-508) calls `t(item.titleKey)` directly — so the cleanest path is to register the i18n keys. Open `app/src/lib/i18n/locales/en.json` (or whichever file holds the existing `settings.developerMenu.*` keys — find it via `grep -rn 'settings.developerMenu.mcpServer' app/src/lib/i18n`) and add: + +```json +"settings.developerMenu.autonomy.title": "Agent autonomy", +"settings.developerMenu.autonomy.desc": "Tool action rate limits and safety thresholds." +``` + +If the i18n file uses nested JSON, mirror the existing structure (drill into `settings → developerMenu → mcpServer` and add `autonomy` as a sibling object with `title` + `desc` keys). + +- [ ] **Step 4: Typecheck + lint** + +```bash +pnpm typecheck 2>&1 | tail -10 +pnpm lint 2>&1 | tail -10 +``` + +Expected: clean. + +- [ ] **Step 5: Commit** + +```bash +git add app/src/components/settings/hooks/useSettingsNavigation.ts app/src/pages/Settings.tsx app/src/components/settings/panels/DeveloperOptionsPanel.tsx app/src/lib/i18n +git commit -m "feat(app): route + menu link for AutonomyPanel" +``` + +--- + +## Task 11: UI tests for `AutonomyPanel` + +**Files:** +- Test: `app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx` + +- [ ] **Step 1: Inspect a reference test for setup conventions** + +Run: + +```bash +ls app/src/components/settings/panels/__tests__/ +``` + +Open one of the simpler existing panel tests (e.g. `MessagingPanel.test.tsx` or `NotificationsPanel.test.tsx`) to copy the mocking pattern for `tauriCommands/config`. Look for the `vi.mock('../../../../utils/tauriCommands/...')` setup at the top. + +- [ ] **Step 2: Write the failing tests** + +Create `app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx`: + +```tsx +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('../../../../utils/tauriCommands/config', () => ({ + openhumanGetAutonomySettings: vi.fn(), + openhumanUpdateAutonomySettings: vi.fn(), +})); + +import AutonomyPanel from '../AutonomyPanel'; +import { + openhumanGetAutonomySettings, + openhumanUpdateAutonomySettings, +} from '../../../../utils/tauriCommands/config'; + +const mockGet = vi.mocked(openhumanGetAutonomySettings); +const mockUpdate = vi.mocked(openhumanUpdateAutonomySettings); + +const renderPanel = () => + render( + + + + ); + +describe('AutonomyPanel', () => { + beforeEach(() => { + mockGet.mockReset(); + mockUpdate.mockReset(); + }); + + test('loads the current value on mount', async () => { + mockGet.mockResolvedValue({ result: { max_actions_per_hour: 250 }, logs: [] }); + renderPanel(); + const input = (await screen.findByLabelText(/Max actions per hour/i)) as HTMLInputElement; + await waitFor(() => expect(input).toHaveValue(250)); + }); + + test('Save is disabled until the value changes', async () => { + mockGet.mockResolvedValue({ result: { max_actions_per_hour: 20 }, logs: [] }); + renderPanel(); + const saveBtn = await screen.findByRole('button', { name: /^Save$/ }); + expect(saveBtn).toBeDisabled(); + + const input = await screen.findByDisplayValue('20'); + fireEvent.change(input, { target: { value: '100' } }); + expect(saveBtn).not.toBeDisabled(); + }); + + test('Save invokes the wrapper and shows confirmation', async () => { + mockGet.mockResolvedValue({ result: { max_actions_per_hour: 20 }, logs: [] }); + mockUpdate.mockResolvedValue({ + result: { config: {}, workspace_dir: '/tmp', config_path: '/tmp/cfg.toml' }, + logs: [], + }); + renderPanel(); + const input = await screen.findByDisplayValue('20'); + fireEvent.change(input, { target: { value: '300' } }); + fireEvent.click(screen.getByRole('button', { name: /^Save$/ })); + await waitFor(() => + expect(mockUpdate).toHaveBeenCalledWith({ max_actions_per_hour: 300 }) + ); + await screen.findByText(/Saved\./i); + }); + + test('shows inline validation when the value is out of range', async () => { + mockGet.mockResolvedValue({ result: { max_actions_per_hour: 20 }, logs: [] }); + renderPanel(); + const input = await screen.findByDisplayValue('20'); + fireEvent.change(input, { target: { value: '0' } }); + await screen.findByText(/Must be an integer between 1 and 10,000/i); + expect(screen.getByRole('button', { name: /^Save$/ })).toBeDisabled(); + }); + + test('surfaces RPC errors and reverts to the last committed value', async () => { + mockGet.mockResolvedValue({ result: { max_actions_per_hour: 50 }, logs: [] }); + mockUpdate.mockRejectedValue(new Error('disk full')); + renderPanel(); + const input = await screen.findByDisplayValue('50'); + fireEvent.change(input, { target: { value: '500' } }); + fireEvent.click(screen.getByRole('button', { name: /^Save$/ })); + await screen.findByText(/Failed: disk full/); + // Reverted to last committed value. + expect(input).toHaveValue(50); + }); +}); +``` + +- [ ] **Step 3: Run to verify they pass** + +```bash +pnpm debug unit app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx +``` + +Expected: 5 tests pass. If `findByLabelText` fails, the test falls back to `findByDisplayValue`. If a different test helper is conventional in this codebase, check a neighbouring panel's test for the right imports — `app/src/test/setup.ts` may register `@testing-library/jest-dom`. + +- [ ] **Step 4: Commit** + +```bash +git add app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx +git commit -m "test(app): cover AutonomyPanel load/save/validate/error paths" +``` + +--- + +## Task 12: E2E case — persist through real core RPC + +**Files:** +- Modify: `app/test/e2e/specs/settings-advanced-config.spec.ts` + +- [ ] **Step 1: Add the E2E case** + +Append inside the existing `describe('Settings - Advanced Config', …)` block, after the `'persists composio trigger triage settings'` test (around line 99): + +```ts + it('persists autonomy max_actions_per_hour through core RPC', async function () { + this.timeout(60_000); + const before = await callOpenhumanRpc('openhuman.config_get_autonomy_settings', {}); + expect(before.ok).toBe(true); + + await navigateViaHash('/settings/autonomy'); + await waitForText('Agent autonomy', 15_000); + + const input = await browser.$('#autonomy-max-actions'); + await input.waitForExist({ timeout: 10_000 }); + await input.setValue('250'); + await clickText('Save', 10_000); + await waitForText('Saved', 10_000); + + await browser.waitUntil( + async () => { + const after = await callOpenhumanRpc('openhuman.config_get_autonomy_settings', {}); + return after.ok && after.result?.result?.max_actions_per_hour === 250; + }, + { timeout: 15_000, interval: 500, timeoutMsg: 'autonomy setting did not persist' } + ); + }); +``` + +- [ ] **Step 2: Build the bundle, then run just this spec** + +```bash +pnpm test:e2e:build +bash app/scripts/e2e-run-spec.sh test/e2e/specs/settings-advanced-config.spec.ts settings-advanced-config +``` + +Expected: all cases in this spec pass, including the new one. If the new case fails because the input id mismatches, check the `id="autonomy-max-actions"` attribute on the `` in Task 9. + +- [ ] **Step 3: Commit** + +```bash +git add app/test/e2e/specs/settings-advanced-config.spec.ts +git commit -m "test(e2e): persist autonomy max_actions_per_hour through core RPC" +``` + +--- + +## Task 13: Final integration — coverage, full test sweep, manual smoke + +- [ ] **Step 1: Run the changed-file unit test suites + Rust tests** + +```bash +pnpm debug unit app/src/utils/tauriCommands/config.test.ts +pnpm debug unit app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx +pnpm debug rust autonomy +pnpm debug rust json_rpc_config_autonomy_settings_roundtrip +``` + +Expected: all pass. + +- [ ] **Step 2: Lint + format** + +```bash +pnpm lint 2>&1 | tail +pnpm format:check 2>&1 | tail +cargo fmt --manifest-path Cargo.toml -- --check 2>&1 | tail +``` + +If `format:check` complains, run `pnpm format` and amend the fixup into a single trailing commit. + +- [ ] **Step 3: Coverage on changed lines** + +The PR coverage gate is `≥80% on changed lines`. Quickly sanity-check: + +```bash +pnpm test:coverage 2>&1 | tail -20 +``` + +If a changed line in `AutonomyPanel.tsx` or `config.ts` isn't covered, add a focused test rather than padding existing ones. + +- [ ] **Step 4: Manual smoke (HUMAN-IN-THE-LOOP)** + +Don't skip this — the spec calls for it. + +```bash +pnpm dev:app +``` + +In the running app: +1. Open `Settings → Developer Options → Agent autonomy`. +2. Confirm the current value loads (default 20 on a fresh workspace). +3. Change to 300, click Save → confirm "Saved" appears. +4. Reopen the panel → confirm 300 is shown. +5. Try entering 0 → confirm validation message, Save disabled. +6. Try entering 99999 → confirm validation message client-side. + +Then verify the new value actually changes agent behavior: +1. Open a fresh chat with the agent and trigger more than 20 tool calls (e.g. a multi-step task). +2. With the limit at 300, the agent should not hit the "Rate limit exceeded" error. + +Document the smoke result in the PR description. + +- [ ] **Step 5: Push branch and open PR** + +```bash +git push -u origin feat/ui-max-actions-per-hour +gh pr create --repo tinyhumansai/openhuman --head EvanCarson:feat/ui-max-actions-per-hour --base main \ + --title "feat(app): UI control for max_actions_per_hour (#2486)" \ + --body "$(cat <<'EOF' +## Summary +- Adds `config_get_autonomy_settings` / `config_update_autonomy_settings` JSON-RPC methods (mirrors the existing `config_*_meet_settings` pair). +- Surfaces them through a new `AutonomyPanel` linked from `Settings → Developer Options`. Number input with presets (20/100/500/1000); validates 1–10000. +- Persists to the user's `config.toml`. Takes effect on the next agent session — running sessions keep their current limit (documented in helper text). + +Scoped from #2486 to the single `max_actions_per_hour` knob; the new panel is shaped so follow-up PRs can add `allowed_commands`, `auto_approve`, etc. + +Pre-existing `openhuman.security_policy_info` bug (returns `SecurityPolicy::default()` instead of loaded config) is **not** fixed here — UI sidesteps it by reading from the new dedicated RPC. Separate follow-up. + +## Test plan +- [ ] `pnpm debug rust autonomy` — Rust unit + roundtrip +- [ ] `pnpm debug rust json_rpc_config_autonomy_settings_roundtrip` — JSON-RPC E2E +- [ ] `pnpm debug unit app/src/utils/tauriCommands/config.test.ts` — TS wrapper unit tests +- [ ] `pnpm debug unit app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx` — UI unit tests +- [ ] `bash app/scripts/e2e-run-spec.sh test/e2e/specs/settings-advanced-config.spec.ts settings-advanced-config` — WDIO E2E +- [ ] Manual smoke in `pnpm dev:app` — load value, save, restart-free verify via new chat + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +(Replace `EvanCarson:` if the user's fork remote is named differently — check `git remote -v`.) + +--- + +## Self-Review Notes + +- **Spec coverage**: every "In scope" bullet has at least one task. Out-of-scope items (other autonomy fields, hot-reload, usage display, `security_policy_info` bug) are explicitly excluded in PR body + helper text. +- **Type consistency**: `max_actions_per_hour` is the field name everywhere — Rust struct field, JSON property, RPC param, TS wrapper arg, input id. RPC method names use the `config_*_autonomy_settings` shape consistently. +- **Schema gap noted**: the spec called for `TypeSchema::U32` implicitly but the type system has no `U32` variant — Task 3 documents the `U64` fallback (informational schema only; serde narrows on the DTO side). +- **Spec deviation called out**: the spec described the UI as "a subsection inside DeveloperOptionsPanel"; the plan creates a dedicated subpanel + route instead, matching every other entry in DeveloperOptionsPanel. Same UX intent; better extensibility for the follow-up autonomy fields. From 7b03e1fb0bd2b0f2fee41bd1684d0bee0387b246 Mon Sep 17 00:00:00 2001 From: Chen Qian Date: Fri, 22 May 2026 09:47:54 -0700 Subject: [PATCH 03/21] feat(config): add AutonomySettingsPatch + apply_autonomy_settings Adds the patch struct and apply function for the autonomy settings domain, following the established MeetSettingsPatch / apply_meet_settings pattern. Validates max_actions_per_hour is between 1 and 10000 before persisting. Includes 4 TDD tests covering persist, no-op, and two validation rejection cases. Co-Authored-By: Claude Sonnet 4.6 --- src/openhuman/config/ops.rs | 30 ++++++++++++++ src/openhuman/config/ops_tests.rs | 68 +++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/src/openhuman/config/ops.rs b/src/openhuman/config/ops.rs index 2bb2b727d..14fe6c87a 100644 --- a/src/openhuman/config/ops.rs +++ b/src/openhuman/config/ops.rs @@ -385,6 +385,11 @@ pub struct MeetSettingsPatch { pub auto_orchestrator_handoff: Option, } +#[derive(Debug, Clone, Default)] +pub struct AutonomySettingsPatch { + pub max_actions_per_hour: Option, +} + #[derive(Debug, Clone, Default)] pub struct LocalAiSettingsPatch { pub runtime_enabled: Option, @@ -787,6 +792,31 @@ pub async fn load_and_apply_meet_settings( apply_meet_settings(&mut config, update).await } +/// Updates the autonomy policy settings in the configuration. +/// Validation: 1 <= max_actions_per_hour <= 10_000. +pub async fn apply_autonomy_settings( + config: &mut Config, + update: AutonomySettingsPatch, +) -> Result, String> { + if let Some(v) = update.max_actions_per_hour { + if v == 0 || v > 10_000 { + return Err(format!( + "max_actions_per_hour must be between 1 and 10000 (got {v})" + )); + } + config.autonomy.max_actions_per_hour = v; + } + config.save().await.map_err(|e| e.to_string())?; + let snapshot = snapshot_config_json(config)?; + Ok(RpcOutcome::new( + snapshot, + vec![format!( + "autonomy settings saved to {}", + config.config_path.display() + )], + )) +} + /// Loads the configuration, applies browser settings updates, and saves it. pub async fn load_and_apply_browser_settings( update: BrowserSettingsPatch, diff --git a/src/openhuman/config/ops_tests.rs b/src/openhuman/config/ops_tests.rs index 2d1bdc069..e23bdc2f5 100644 --- a/src/openhuman/config/ops_tests.rs +++ b/src/openhuman/config/ops_tests.rs @@ -1003,3 +1003,71 @@ async fn apply_screen_intelligence_settings_clamps_baseline_fps() { .expect("low clamp"); assert!((cfg.screen_intelligence.baseline_fps - 0.2).abs() < f32::EPSILON); } + +// ── apply_autonomy_settings ──────────────────────────────────── + +#[tokio::test] +async fn apply_autonomy_settings_persists_max_actions_per_hour() { + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + let outcome = apply_autonomy_settings( + &mut cfg, + AutonomySettingsPatch { + max_actions_per_hour: Some(200), + }, + ) + .await + .expect("apply"); + assert_eq!(cfg.autonomy.max_actions_per_hour, 200); + // Snapshot returned so the caller can echo the saved state. + assert!(outcome.value.get("config").is_some()); + // Round-trip from disk: reload the saved TOML and confirm. + let on_disk = tokio::fs::read_to_string(&cfg.config_path).await.unwrap(); + assert!( + on_disk.contains("max_actions_per_hour = 200"), + "expected TOML to contain max_actions_per_hour = 200, got:\n{on_disk}" + ); +} + +#[tokio::test] +async fn apply_autonomy_settings_no_op_when_patch_empty() { + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + let prior = cfg.autonomy.max_actions_per_hour; + let _ = apply_autonomy_settings( + &mut cfg, + AutonomySettingsPatch { max_actions_per_hour: None }, + ) + .await + .expect("apply noop"); + assert_eq!(cfg.autonomy.max_actions_per_hour, prior); +} + +#[tokio::test] +async fn apply_autonomy_settings_rejects_zero() { + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + let err = apply_autonomy_settings( + &mut cfg, + AutonomySettingsPatch { max_actions_per_hour: Some(0) }, + ) + .await + .unwrap_err(); + assert!( + err.contains("between 1 and 10000"), + "expected validation error, got: {err}" + ); +} + +#[tokio::test] +async fn apply_autonomy_settings_rejects_above_cap() { + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + let err = apply_autonomy_settings( + &mut cfg, + AutonomySettingsPatch { max_actions_per_hour: Some(10_001) }, + ) + .await + .unwrap_err(); + assert!(err.contains("between 1 and 10000")); +} From 678ba50cfcb75ebfe452541c84c9e566b5c657d9 Mon Sep 17 00:00:00 2001 From: Chen Qian Date: Fri, 22 May 2026 09:53:25 -0700 Subject: [PATCH 04/21] feat(config): add load_and_apply_autonomy_settings roundtrip Co-Authored-By: Claude Sonnet 4.6 --- src/openhuman/config/ops.rs | 8 ++++++++ src/openhuman/config/ops_tests.rs | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/openhuman/config/ops.rs b/src/openhuman/config/ops.rs index 14fe6c87a..cb6bdf580 100644 --- a/src/openhuman/config/ops.rs +++ b/src/openhuman/config/ops.rs @@ -817,6 +817,14 @@ pub async fn apply_autonomy_settings( )) } +/// Loads the configuration, applies autonomy settings updates, and saves it. +pub async fn load_and_apply_autonomy_settings( + update: AutonomySettingsPatch, +) -> Result, String> { + let mut config = load_config_with_timeout().await?; + apply_autonomy_settings(&mut config, update).await +} + /// Loads the configuration, applies browser settings updates, and saves it. pub async fn load_and_apply_browser_settings( update: BrowserSettingsPatch, diff --git a/src/openhuman/config/ops_tests.rs b/src/openhuman/config/ops_tests.rs index e23bdc2f5..3cc485f7a 100644 --- a/src/openhuman/config/ops_tests.rs +++ b/src/openhuman/config/ops_tests.rs @@ -1071,3 +1071,22 @@ async fn apply_autonomy_settings_rejects_above_cap() { .unwrap_err(); assert!(err.contains("between 1 and 10000")); } + +#[tokio::test] +async fn load_and_apply_autonomy_settings_roundtrip() { + let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempdir().unwrap(); + unsafe { + std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path()); + } + + let patch = AutonomySettingsPatch { max_actions_per_hour: Some(500) }; + let outcome = load_and_apply_autonomy_settings(patch).await.expect("apply"); + assert!(outcome.value.get("config").is_some()); + + // Reload from scratch and confirm the saved value sticks. + let reloaded = load_config_with_timeout().await.expect("reload"); + assert_eq!(reloaded.autonomy.max_actions_per_hour, 500); + + unsafe { std::env::remove_var("OPENHUMAN_WORKSPACE"); } +} From 0d09c22196ce9bd3a216c0090a84ab9384e511c3 Mon Sep 17 00:00:00 2001 From: Chen Qian Date: Fri, 22 May 2026 09:56:35 -0700 Subject: [PATCH 05/21] feat(config): add autonomy_settings schemas + DTO Add AutonomySettingsUpdate DTO and ControllerSchema entries for update_autonomy_settings / get_autonomy_settings in the config domain schema registry. Handlers and controller registration follow in Task 4. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/openhuman/config/schemas.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/openhuman/config/schemas.rs b/src/openhuman/config/schemas.rs index d8aa47085..81430c488 100644 --- a/src/openhuman/config/schemas.rs +++ b/src/openhuman/config/schemas.rs @@ -121,6 +121,11 @@ struct MeetSettingsUpdate { auto_orchestrator_handoff: Option, } +#[derive(Debug, Deserialize)] +struct AutonomySettingsUpdate { + max_actions_per_hour: Option, +} + #[derive(Debug, Deserialize)] struct LocalAiSettingsUpdate { runtime_enabled: Option, @@ -206,6 +211,8 @@ pub fn all_controller_schemas() -> Vec { schemas("get_analytics_settings"), schemas("update_meet_settings"), schemas("get_meet_settings"), + schemas("update_autonomy_settings"), + schemas("get_autonomy_settings"), schemas("agent_server_status"), schemas("reset_local_data"), schemas("get_data_paths"), @@ -692,6 +699,31 @@ pub fn schemas(function: &str) -> ControllerSchema { required: true, }], }, + "update_autonomy_settings" => ControllerSchema { + namespace: "config", + function: "update_autonomy_settings", + description: + "Update agent autonomy policy settings (currently the per-hour tool action ceiling).", + inputs: vec![FieldSchema { + name: "max_actions_per_hour", + ty: TypeSchema::Option(Box::new(TypeSchema::U64)), + comment: "Maximum tool actions an agent may run per rolling hour (1-10000).", + required: false, + }], + outputs: vec![json_output("snapshot", "Updated config snapshot.")], + }, + "get_autonomy_settings" => ControllerSchema { + namespace: "config", + function: "get_autonomy_settings", + description: "Read current agent autonomy policy settings.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "max_actions_per_hour", + ty: TypeSchema::U64, + comment: "Current maximum tool actions per rolling hour.", + required: true, + }], + }, "agent_server_status" => ControllerSchema { namespace: "config", function: "agent_server_status", From 8dc8032d7043d9bcea23af4426c314eac9a8cc91 Mon Sep 17 00:00:00 2001 From: Chen Qian Date: Fri, 22 May 2026 10:04:47 -0700 Subject: [PATCH 06/21] feat(config): add autonomy_settings handlers + register controllers Implement handle_update_autonomy_settings and handle_get_autonomy_settings in config/schemas.rs, register both in all_registered_controllers, and add two handler-level tests covering the happy path and validation rejection. Co-Authored-By: Claude Sonnet 4.6 --- src/openhuman/config/schemas.rs | 62 +++++++++++++++++++++++++++ src/openhuman/config/schemas_tests.rs | 57 ++++++++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/src/openhuman/config/schemas.rs b/src/openhuman/config/schemas.rs index 81430c488..566e00283 100644 --- a/src/openhuman/config/schemas.rs +++ b/src/openhuman/config/schemas.rs @@ -297,6 +297,14 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("get_meet_settings"), handler: handle_get_meet_settings, }, + RegisteredController { + schema: schemas("update_autonomy_settings"), + handler: handle_update_autonomy_settings, + }, + RegisteredController { + schema: schemas("get_autonomy_settings"), + handler: handle_get_autonomy_settings, + }, RegisteredController { schema: schemas("agent_server_status"), handler: handle_agent_server_status, @@ -1207,6 +1215,60 @@ fn handle_get_meet_settings(_params: Map) -> ControllerFuture { }) } +fn handle_update_autonomy_settings(params: Map) -> ControllerFuture { + Box::pin(async move { + log::debug!("[config][rpc] update_autonomy_settings enter"); + let update = match deserialize_params::(params) { + Ok(u) => u, + Err(err) => { + log::warn!("[config][rpc] update_autonomy_settings invalid params: {err}"); + return Err(err); + } + }; + log::debug!( + "[config][rpc] update_autonomy_settings patch max_actions_per_hour={:?}", + update.max_actions_per_hour + ); + let patch = config_rpc::AutonomySettingsPatch { + max_actions_per_hour: update.max_actions_per_hour, + }; + match config_rpc::load_and_apply_autonomy_settings(patch).await { + Ok(outcome) => { + log::debug!("[config][rpc] update_autonomy_settings ok"); + to_json(outcome) + } + Err(err) => { + log::warn!("[config][rpc] update_autonomy_settings failed: {err}"); + Err(err) + } + } + }) +} + +fn handle_get_autonomy_settings(_params: Map) -> ControllerFuture { + Box::pin(async { + log::debug!("[config][rpc] get_autonomy_settings enter"); + let config = match config_rpc::load_config_with_timeout().await { + Ok(c) => c, + Err(err) => { + log::warn!("[config][rpc] get_autonomy_settings load failed: {err}"); + return Err(err); + } + }; + let max_actions_per_hour = config.autonomy.max_actions_per_hour; + log::debug!( + "[config][rpc] get_autonomy_settings ok max_actions_per_hour={max_actions_per_hour}" + ); + let result = serde_json::json!({ + "max_actions_per_hour": max_actions_per_hour, + }); + to_json(RpcOutcome::new( + result, + vec!["autonomy settings read".to_string()], + )) + }) +} + fn handle_agent_server_status(_params: Map) -> ControllerFuture { Box::pin(async { to_json(config_rpc::agent_server_status()) }) } diff --git a/src/openhuman/config/schemas_tests.rs b/src/openhuman/config/schemas_tests.rs index acdbc00da..19d13a3d7 100644 --- a/src/openhuman/config/schemas_tests.rs +++ b/src/openhuman/config/schemas_tests.rs @@ -43,6 +43,8 @@ fn every_registered_key_resolves_to_non_unknown_schema() { "get_analytics_settings", "update_meet_settings", "get_meet_settings", + "update_autonomy_settings", + "get_autonomy_settings", "agent_server_status", "reset_local_data", "get_onboarding_completed", @@ -217,3 +219,58 @@ fn default_onboarding_flag_constant_points_to_hidden_marker() { // stays stable across refactors. assert_eq!(DEFAULT_ONBOARDING_FLAG_NAME, ".skip_onboarding"); } + +// ── autonomy settings handlers ─────────────────────────────── + +use crate::openhuman::config::TEST_ENV_LOCK; + +#[tokio::test] +async fn handle_get_autonomy_settings_returns_current_value() { + let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + unsafe { + std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path()); + } + // Seed a known value before reading. + let _ = crate::openhuman::config::ops::load_and_apply_autonomy_settings( + crate::openhuman::config::ops::AutonomySettingsPatch { + max_actions_per_hour: Some(123), + }, + ) + .await + .expect("seed"); + + let out = super::handle_get_autonomy_settings(serde_json::Map::new()) + .await + .expect("handler"); + // into_cli_compatible_json wraps data under "result" when logs are present. + let inner = out.get("result").unwrap_or(&out); + let value = inner + .get("max_actions_per_hour") + .and_then(|v| v.as_u64()); + assert_eq!(value, Some(123)); + + unsafe { + std::env::remove_var("OPENHUMAN_WORKSPACE"); + } +} + +#[tokio::test] +async fn handle_update_autonomy_settings_rejects_invalid_value() { + let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + unsafe { + std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path()); + } + let mut params = serde_json::Map::new(); + params.insert("max_actions_per_hour".into(), serde_json::json!(0)); + + let err = super::handle_update_autonomy_settings(params) + .await + .unwrap_err(); + assert!(err.contains("between 1 and 10000"), "got: {err}"); + + unsafe { + std::env::remove_var("OPENHUMAN_WORKSPACE"); + } +} From fe5a0956c7d3981f9e0f1e1d0498e219f65216b3 Mon Sep 17 00:00:00 2001 From: Chen Qian Date: Fri, 22 May 2026 10:09:36 -0700 Subject: [PATCH 07/21] test(rpc): roundtrip for config_*_autonomy_settings Adds json_rpc_config_autonomy_settings_roundtrip E2E test: verifies the default value (20), a successful update to 250 persisted across gets, and that out-of-range values (99999) are rejected with a validation error. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/json_rpc_e2e.rs | 96 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index fa1534fa1..1994ec672 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -6449,3 +6449,99 @@ async fn mcp_clients_lifecycle() { mock_join.abort(); rpc_join.abort(); } + +#[tokio::test] +async fn json_rpc_config_autonomy_settings_roundtrip() { + 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; + + // GET → expect the default (20). + let initial = post_json_rpc( + &rpc_base, + 7001, + "openhuman.config_get_autonomy_settings", + json!({}), + ) + .await; + eprintln!("initial response envelope: {initial}"); + let initial_outer = assert_no_jsonrpc_error(&initial, "get_autonomy_settings initial"); + // assert_no_jsonrpc_error already strips the JSON-RPC envelope; one more hop + // strips the into_cli_compatible_json wrapper to reach the payload fields. + let initial_value = initial_outer + .get("result") + .and_then(|r| r.get("max_actions_per_hour")) + .and_then(Value::as_u64); + assert_eq!( + initial_value, + Some(20), + "expected default 20, got envelope: {initial_outer}" + ); + + // UPDATE → 250. + let update = post_json_rpc( + &rpc_base, + 7002, + "openhuman.config_update_autonomy_settings", + json!({ "max_actions_per_hour": 250 }), + ) + .await; + eprintln!("update response envelope: {update}"); + assert_no_jsonrpc_error(&update, "update_autonomy_settings"); + + // GET again → expect 250. + let after = post_json_rpc( + &rpc_base, + 7003, + "openhuman.config_get_autonomy_settings", + json!({}), + ) + .await; + eprintln!("after response envelope: {after}"); + let after_outer = assert_no_jsonrpc_error(&after, "get_autonomy_settings after"); + let after_value = after_outer + .get("result") + .and_then(|r| r.get("max_actions_per_hour")) + .and_then(Value::as_u64); + assert_eq!( + after_value, + Some(250), + "expected 250 after update, got envelope: {after_outer}" + ); + + // Invalid value rejected — server returns JSON-RPC error envelope, not a result. + let bad = post_json_rpc( + &rpc_base, + 7004, + "openhuman.config_update_autonomy_settings", + json!({ "max_actions_per_hour": 99999 }), + ) + .await; + eprintln!("bad response envelope: {bad}"); + let err_message = bad + .get("error") + .and_then(|e| e.get("message")) + .and_then(Value::as_str) + .unwrap_or_else(|| panic!("expected JSON-RPC error, got: {bad}")); + assert!( + err_message.contains("between 1 and 10000"), + "expected validation error in: {err_message}" + ); + + mock_join.abort(); + rpc_join.abort(); +} From b1e1213ccd9d9faae31f73a6966df33e0a6b0ce5 Mon Sep 17 00:00:00 2001 From: Chen Qian Date: Fri, 22 May 2026 10:12:33 -0700 Subject: [PATCH 08/21] chore(test): remove debug prints, use assert_jsonrpc_error in autonomy roundtrip --- tests/json_rpc_e2e.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 1994ec672..a5cf340dc 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -6478,7 +6478,6 @@ async fn json_rpc_config_autonomy_settings_roundtrip() { json!({}), ) .await; - eprintln!("initial response envelope: {initial}"); let initial_outer = assert_no_jsonrpc_error(&initial, "get_autonomy_settings initial"); // assert_no_jsonrpc_error already strips the JSON-RPC envelope; one more hop // strips the into_cli_compatible_json wrapper to reach the payload fields. @@ -6500,7 +6499,6 @@ async fn json_rpc_config_autonomy_settings_roundtrip() { json!({ "max_actions_per_hour": 250 }), ) .await; - eprintln!("update response envelope: {update}"); assert_no_jsonrpc_error(&update, "update_autonomy_settings"); // GET again → expect 250. @@ -6511,7 +6509,6 @@ async fn json_rpc_config_autonomy_settings_roundtrip() { json!({}), ) .await; - eprintln!("after response envelope: {after}"); let after_outer = assert_no_jsonrpc_error(&after, "get_autonomy_settings after"); let after_value = after_outer .get("result") @@ -6531,12 +6528,11 @@ async fn json_rpc_config_autonomy_settings_roundtrip() { json!({ "max_actions_per_hour": 99999 }), ) .await; - eprintln!("bad response envelope: {bad}"); - let err_message = bad - .get("error") - .and_then(|e| e.get("message")) + let bad_err = assert_jsonrpc_error(&bad, "update_autonomy_settings bad value"); + let err_message = bad_err + .get("message") .and_then(Value::as_str) - .unwrap_or_else(|| panic!("expected JSON-RPC error, got: {bad}")); + .unwrap_or_else(|| panic!("error object missing message: {bad_err}")); assert!( err_message.contains("between 1 and 10000"), "expected validation error in: {err_message}" From 97e67a18a50766458613f2dd6281a9299d1a7fab Mon Sep 17 00:00:00 2001 From: Chen Qian Date: Fri, 22 May 2026 10:13:17 -0700 Subject: [PATCH 09/21] feat(app): add autonomy_settings RPC method constants --- app/src/services/rpcMethods.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/services/rpcMethods.ts b/app/src/services/rpcMethods.ts index 008e539fb..6535c057a 100644 --- a/app/src/services/rpcMethods.ts +++ b/app/src/services/rpcMethods.ts @@ -1,10 +1,12 @@ export const CORE_RPC_METHODS = { configGet: 'openhuman.config_get', configGetAnalyticsSettings: 'openhuman.config_get_analytics_settings', + configGetAutonomySettings: 'openhuman.config_get_autonomy_settings', configGetComposioTriggerSettings: 'openhuman.config_get_composio_trigger_settings', configGetRuntimeFlags: 'openhuman.config_get_runtime_flags', configSetBrowserAllowAll: 'openhuman.config_set_browser_allow_all', configUpdateAnalyticsSettings: 'openhuman.config_update_analytics_settings', + configUpdateAutonomySettings: 'openhuman.config_update_autonomy_settings', configUpdateBrowserSettings: 'openhuman.config_update_browser_settings', configUpdateComposioTriggerSettings: 'openhuman.config_update_composio_trigger_settings', configUpdateLocalAiSettings: 'openhuman.config_update_local_ai_settings', From 29cb5613e8239a63718d2c13cf9349fc1921c59b Mon Sep 17 00:00:00 2001 From: Chen Qian Date: Fri, 22 May 2026 10:14:07 -0700 Subject: [PATCH 10/21] feat(app): add openhuman{Get,Update}AutonomySettings wrappers --- app/src/utils/tauriCommands/config.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/app/src/utils/tauriCommands/config.ts b/app/src/utils/tauriCommands/config.ts index 1faa5e916..bf454b7fd 100644 --- a/app/src/utils/tauriCommands/config.ts +++ b/app/src/utils/tauriCommands/config.ts @@ -355,6 +355,29 @@ export async function openhumanGetMeetSettings(): Promise< }); } +export async function openhumanUpdateAutonomySettings(update: { + max_actions_per_hour?: number; +}): Promise> { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + return await callCoreRpc>({ + method: CORE_RPC_METHODS.configUpdateAutonomySettings, + params: update, + }); +} + +export async function openhumanGetAutonomySettings(): Promise< + CommandResponse<{ max_actions_per_hour: number }> +> { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + return await callCoreRpc>({ + method: CORE_RPC_METHODS.configGetAutonomySettings, + }); +} + export interface ComposioTriggerSettingsUpdate { triage_disabled?: boolean | null; triage_disabled_toolkits?: string[] | null; From b828ce67ee4c848f538ba05fae6bc1307f5fe493 Mon Sep 17 00:00:00 2001 From: Chen Qian Date: Fri, 22 May 2026 10:15:25 -0700 Subject: [PATCH 11/21] test(app): cover openhuman{Get,Update}AutonomySettings wrappers Co-Authored-By: Claude Sonnet 4.6 --- app/src/utils/tauriCommands/config.test.ts | 50 +++++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/app/src/utils/tauriCommands/config.test.ts b/app/src/utils/tauriCommands/config.test.ts index c643aeafa..ebcd12c76 100644 --- a/app/src/utils/tauriCommands/config.test.ts +++ b/app/src/utils/tauriCommands/config.test.ts @@ -9,17 +9,21 @@ vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() })); describe('tauriCommands/config', () => { const mockIsTauri = isTauri as Mock; const mockCallCoreRpc = callCoreRpc as Mock; + let openhumanGetAutonomySettings: typeof import('./config').openhumanGetAutonomySettings; + let openhumanGetMeetSettings: typeof import('./config').openhumanGetMeetSettings; + let openhumanUpdateAutonomySettings: typeof import('./config').openhumanUpdateAutonomySettings; let openhumanUpdateLocalAiSettings: typeof import('./config').openhumanUpdateLocalAiSettings; let openhumanUpdateMeetSettings: typeof import('./config').openhumanUpdateMeetSettings; - let openhumanGetMeetSettings: typeof import('./config').openhumanGetMeetSettings; beforeEach(async () => { vi.clearAllMocks(); mockIsTauri.mockReturnValue(true); const actual = await vi.importActual('./config'); + openhumanGetAutonomySettings = actual.openhumanGetAutonomySettings; + openhumanGetMeetSettings = actual.openhumanGetMeetSettings; + openhumanUpdateAutonomySettings = actual.openhumanUpdateAutonomySettings; openhumanUpdateLocalAiSettings = actual.openhumanUpdateLocalAiSettings; openhumanUpdateMeetSettings = actual.openhumanUpdateMeetSettings; - openhumanGetMeetSettings = actual.openhumanGetMeetSettings; }); afterEach(() => { @@ -97,6 +101,48 @@ describe('tauriCommands/config', () => { }); }); + describe('openhumanUpdateAutonomySettings', () => { + test('throws when not running in Tauri', async () => { + mockIsTauri.mockReturnValue(false); + await expect( + openhumanUpdateAutonomySettings({ max_actions_per_hour: 100 }) + ).rejects.toThrow('Not running in Tauri'); + expect(mockCallCoreRpc).not.toHaveBeenCalled(); + }); + + test('forwards the patch to openhuman.config_update_autonomy_settings', async () => { + mockCallCoreRpc.mockResolvedValue({ + result: { config: {}, workspace_dir: '/tmp', config_path: '/tmp/cfg.toml' }, + logs: [], + }); + await openhumanUpdateAutonomySettings({ max_actions_per_hour: 100 }); + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.config_update_autonomy_settings', + params: { max_actions_per_hour: 100 }, + }); + }); + }); + + describe('openhumanGetAutonomySettings', () => { + test('throws when not running in Tauri', async () => { + mockIsTauri.mockReturnValue(false); + await expect(openhumanGetAutonomySettings()).rejects.toThrow('Not running in Tauri'); + expect(mockCallCoreRpc).not.toHaveBeenCalled(); + }); + + test('reads via openhuman.config_get_autonomy_settings', async () => { + mockCallCoreRpc.mockResolvedValue({ + result: { max_actions_per_hour: 250 }, + logs: [], + }); + const out = await openhumanGetAutonomySettings(); + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.config_get_autonomy_settings', + }); + expect(out.result.max_actions_per_hour).toBe(250); + }); + }); + describe('openhumanUpdateComposioTriggerSettings', () => { let openhumanUpdateComposioTriggerSettings: typeof import('./config').openhumanUpdateComposioTriggerSettings; From 5e1b399a809bc2a33529abaab75eaf7131b4727e Mon Sep 17 00:00:00 2001 From: Chen Qian Date: Fri, 22 May 2026 10:17:46 -0700 Subject: [PATCH 12/21] feat(app): add AutonomyPanel for max_actions_per_hour control Adds the new settings panel at app/src/components/settings/panels/AutonomyPanel.tsx that lets users view and update the max_actions_per_hour autonomy limit via the openhumanGetAutonomySettings / openhumanUpdateAutonomySettings RPC wrappers. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../settings/panels/AutonomyPanel.tsx | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 app/src/components/settings/panels/AutonomyPanel.tsx diff --git a/app/src/components/settings/panels/AutonomyPanel.tsx b/app/src/components/settings/panels/AutonomyPanel.tsx new file mode 100644 index 000000000..c1b096681 --- /dev/null +++ b/app/src/components/settings/panels/AutonomyPanel.tsx @@ -0,0 +1,166 @@ +import { useEffect, useState } from 'react'; + +import SettingsHeader from '../components/SettingsHeader'; +import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; +import { + openhumanGetAutonomySettings, + openhumanUpdateAutonomySettings, +} from '../../../utils/tauriCommands/config'; + +const PRESETS = [ + { label: '20 (default)', value: 20 }, + { label: '100', value: 100 }, + { label: '500', value: 500 }, + { label: '1000', value: 1000 }, +]; + +const MIN = 1; +const MAX = 10_000; + +type Status = + | { kind: 'idle' } + | { kind: 'loading' } + | { kind: 'saving' } + | { kind: 'saved' } + | { kind: 'error'; message: string }; + +const AutonomyPanel = () => { + const { navigateBack, breadcrumbs } = useSettingsNavigation(); + const [committed, setCommitted] = useState(null); + const [draft, setDraft] = useState(''); + const [status, setStatus] = useState({ kind: 'loading' }); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await openhumanGetAutonomySettings(); + if (cancelled) return; + const value = res.result.max_actions_per_hour; + setCommitted(value); + setDraft(String(value)); + setStatus({ kind: 'idle' }); + } catch (err) { + if (cancelled) return; + setStatus({ + kind: 'error', + message: err instanceof Error ? err.message : String(err), + }); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const parsed = Number.parseInt(draft, 10); + const isValid = + Number.isInteger(parsed) && parsed >= MIN && parsed <= MAX; + const isChanged = committed !== null && parsed !== committed; + const canSave = isValid && isChanged && status.kind !== 'saving'; + + const applyPreset = (value: number) => { + setDraft(String(value)); + if (status.kind === 'saved' || status.kind === 'error') { + setStatus({ kind: 'idle' }); + } + }; + + const onSave = async () => { + if (!canSave) return; + setStatus({ kind: 'saving' }); + try { + await openhumanUpdateAutonomySettings({ max_actions_per_hour: parsed }); + setCommitted(parsed); + setStatus({ kind: 'saved' }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + // Revert UI to last committed value, then surface the error. + if (committed !== null) setDraft(String(committed)); + setStatus({ kind: 'error', message }); + } + }; + + return ( +
+ +
+
+ +

+ Maximum tool actions an agent can run per rolling hour. New value + applies to your next chat — running sessions keep their current + limit. +

+ +
+ { + setDraft(e.target.value); + if (status.kind === 'saved' || status.kind === 'error') { + setStatus({ kind: 'idle' }); + } + }} + disabled={status.kind === 'loading' || status.kind === 'saving'} + className="w-32 px-3 py-1.5 rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-sm font-mono" + /> + +
+ +
+ {PRESETS.map(p => ( + + ))} +
+ +
+ {!isValid && draft.trim() !== '' && ( + + Must be an integer between {MIN} and {MAX.toLocaleString()}. + + )} + {status.kind === 'saved' && ( + Saved. + )} + {status.kind === 'error' && ( + + Failed: {status.message} + + )} +
+
+
+
+ ); +}; + +export default AutonomyPanel; From 4a12043b21f270779200b1a3fa2a3f32cc5dd375 Mon Sep 17 00:00:00 2001 From: Chen Qian Date: Fri, 22 May 2026 10:22:22 -0700 Subject: [PATCH 13/21] feat(app): route + menu link for AutonomyPanel Co-Authored-By: Claude Sonnet 4.6 --- .../settings/hooks/useSettingsNavigation.ts | 3 +++ .../settings/panels/DeveloperOptionsPanel.tsx | 16 ++++++++++++++++ app/src/lib/i18n/chunks/en-5.ts | 2 ++ app/src/lib/i18n/en.ts | 2 ++ app/src/pages/Settings.tsx | 2 ++ 5 files changed, 25 insertions(+) diff --git a/app/src/components/settings/hooks/useSettingsNavigation.ts b/app/src/components/settings/hooks/useSettingsNavigation.ts index d7961f91b..d9620fc55 100644 --- a/app/src/components/settings/hooks/useSettingsNavigation.ts +++ b/app/src/components/settings/hooks/useSettingsNavigation.ts @@ -16,6 +16,7 @@ export type SettingsRoute = | 'team-members' | 'team-invites' | 'developer-options' + | 'autonomy' | 'ai' | 'llm' | 'voice' @@ -92,6 +93,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { if (path.includes('/settings/privacy')) return 'privacy'; if (path.includes('/settings/billing')) return 'billing'; if (path.includes('/settings/developer-options')) return 'developer-options'; + if (path.includes('/settings/autonomy')) return 'autonomy'; if (path.includes('/settings/llm')) return 'llm'; if (path.includes('/settings/ai')) return 'ai'; if (path.includes('/settings/local-model-debug')) return 'local-model-debug'; @@ -222,6 +224,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { case 'composio-routing': case 'notification-routing': case 'mcp-server': + case 'autonomy': return [settingsCrumb, developerCrumb]; // Developer options section page diff --git a/app/src/components/settings/panels/DeveloperOptionsPanel.tsx b/app/src/components/settings/panels/DeveloperOptionsPanel.tsx index aef409134..ef4a2fa78 100644 --- a/app/src/components/settings/panels/DeveloperOptionsPanel.tsx +++ b/app/src/components/settings/panels/DeveloperOptionsPanel.tsx @@ -253,6 +253,22 @@ const developerItems = [ ), }, + { + id: 'autonomy', + titleKey: 'settings.developerMenu.autonomy.title', + descriptionKey: 'settings.developerMenu.autonomy.desc', + route: 'autonomy', + icon: ( + + + + ), + }, ]; const CoreModeBadge = () => { diff --git a/app/src/lib/i18n/chunks/en-5.ts b/app/src/lib/i18n/chunks/en-5.ts index f1470083d..3f57027a7 100644 --- a/app/src/lib/i18n/chunks/en-5.ts +++ b/app/src/lib/i18n/chunks/en-5.ts @@ -482,6 +482,8 @@ const en5: TranslationMap = { 'settings.mascot.title': 'OpenHuman', 'settings.developerMenu.mcpServer.title': 'MCP Server', 'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman', + 'settings.developerMenu.autonomy.title': 'Agent autonomy', + 'settings.developerMenu.autonomy.desc': 'Tool action rate limits and safety thresholds.', 'settings.mcpServer.title': 'MCP Server', 'settings.mcpServer.toolsSectionTitle': 'Available Tools', 'settings.mcpServer.toolsSectionDesc': diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 9ac18e4cf..f303dc111 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1958,6 +1958,8 @@ const en: TranslationMap = { 'Configure AI triage settings for Composio integration triggers', 'settings.developerMenu.mcpServer.title': 'MCP Server', 'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman', + 'settings.developerMenu.autonomy.title': 'Agent autonomy', + 'settings.developerMenu.autonomy.desc': 'Tool action rate limits and safety thresholds.', 'settings.mcpServer.title': 'MCP Server', 'settings.mcpServer.toolsSectionTitle': 'Available Tools', 'settings.mcpServer.toolsSectionDesc': diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index 20db4f13e..a0cfce277 100644 --- a/app/src/pages/Settings.tsx +++ b/app/src/pages/Settings.tsx @@ -7,6 +7,7 @@ import AIPanel from '../components/settings/panels/AIPanel'; import AppearancePanel from '../components/settings/panels/AppearancePanel'; import AutocompleteDebugPanel from '../components/settings/panels/AutocompleteDebugPanel'; import AutocompletePanel from '../components/settings/panels/AutocompletePanel'; +import AutonomyPanel from '../components/settings/panels/AutonomyPanel'; import BillingPanel from '../components/settings/panels/BillingPanel'; import CompanionPanel from '../components/settings/panels/CompanionPanel'; import ComposioPanel from '../components/settings/panels/ComposioPanel'; @@ -353,6 +354,7 @@ const Settings = () => { )} /> {/* Developer Options */} )} /> + )} /> )} /> Date: Fri, 22 May 2026 10:24:43 -0700 Subject: [PATCH 14/21] test(app): cover AutonomyPanel load/save/validate/error paths Co-Authored-By: Claude Sonnet 4.6 --- .../panels/__tests__/AutonomyPanel.test.tsx | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx diff --git a/app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx b/app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx new file mode 100644 index 000000000..2eefbc2d7 --- /dev/null +++ b/app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx @@ -0,0 +1,93 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('../../hooks/useSettingsNavigation', () => ({ + useSettingsNavigation: () => ({ + navigateBack: vi.fn(), + navigateToSettings: vi.fn(), + breadcrumbs: [], + }), +})); + +vi.mock('../../../../utils/tauriCommands/config', async () => { + const actual = await vi.importActual( + '../../../../utils/tauriCommands/config' + ); + return { + ...actual, + openhumanGetAutonomySettings: vi.fn(), + openhumanUpdateAutonomySettings: vi.fn(), + }; +}); + +import { + openhumanGetAutonomySettings, + openhumanUpdateAutonomySettings, +} from '../../../../utils/tauriCommands/config'; +import { renderWithProviders } from '../../../../test/test-utils'; +import AutonomyPanel from '../AutonomyPanel'; + +const mockGet = vi.mocked(openhumanGetAutonomySettings); +const mockUpdate = vi.mocked(openhumanUpdateAutonomySettings); + +describe('AutonomyPanel', () => { + beforeEach(() => { + mockGet.mockReset(); + mockUpdate.mockReset(); + }); + + test('loads the current value on mount', async () => { + mockGet.mockResolvedValue({ result: { max_actions_per_hour: 250 }, logs: [] }); + renderWithProviders(, { initialEntries: ['/settings/autonomy'] }); + const input = (await screen.findByLabelText(/Max actions per hour/i)) as HTMLInputElement; + await waitFor(() => expect(input).toHaveValue(250)); + }); + + test('Save is disabled until the value changes', async () => { + mockGet.mockResolvedValue({ result: { max_actions_per_hour: 20 }, logs: [] }); + renderWithProviders(, { initialEntries: ['/settings/autonomy'] }); + const saveBtn = await screen.findByRole('button', { name: /^Save$/ }); + expect(saveBtn).toBeDisabled(); + + const input = await screen.findByDisplayValue('20'); + fireEvent.change(input, { target: { value: '100' } }); + expect(saveBtn).not.toBeDisabled(); + }); + + test('Save invokes the wrapper and shows confirmation', async () => { + mockGet.mockResolvedValue({ result: { max_actions_per_hour: 20 }, logs: [] }); + mockUpdate.mockResolvedValue({ + result: { config: {}, workspace_dir: '/tmp', config_path: '/tmp/cfg.toml' }, + logs: [], + }); + renderWithProviders(, { initialEntries: ['/settings/autonomy'] }); + const input = await screen.findByDisplayValue('20'); + fireEvent.change(input, { target: { value: '300' } }); + fireEvent.click(screen.getByRole('button', { name: /^Save$/ })); + await waitFor(() => + expect(mockUpdate).toHaveBeenCalledWith({ max_actions_per_hour: 300 }) + ); + await screen.findByText(/Saved\./i); + }); + + test('shows inline validation when the value is out of range', async () => { + mockGet.mockResolvedValue({ result: { max_actions_per_hour: 20 }, logs: [] }); + renderWithProviders(, { initialEntries: ['/settings/autonomy'] }); + const input = await screen.findByDisplayValue('20'); + fireEvent.change(input, { target: { value: '0' } }); + await screen.findByText(/Must be an integer between 1 and 10,000/i); + expect(screen.getByRole('button', { name: /^Save$/ })).toBeDisabled(); + }); + + test('surfaces RPC errors and reverts to the last committed value', async () => { + mockGet.mockResolvedValue({ result: { max_actions_per_hour: 50 }, logs: [] }); + mockUpdate.mockRejectedValue(new Error('disk full')); + renderWithProviders(, { initialEntries: ['/settings/autonomy'] }); + const input = (await screen.findByDisplayValue('50')) as HTMLInputElement; + fireEvent.change(input, { target: { value: '500' } }); + fireEvent.click(screen.getByRole('button', { name: /^Save$/ })); + await screen.findByText(/Failed: disk full/); + // Reverted to last committed value. + expect(input).toHaveValue(50); + }); +}); From ea6e5099be63f903507e345f76163f93caa2aafb Mon Sep 17 00:00:00 2001 From: Chen Qian Date: Fri, 22 May 2026 10:26:46 -0700 Subject: [PATCH 15/21] test(e2e): persist autonomy max_actions_per_hour through core RPC Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/settings-advanced-config.spec.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/app/test/e2e/specs/settings-advanced-config.spec.ts b/app/test/e2e/specs/settings-advanced-config.spec.ts index b870352dd..5de5201cb 100644 --- a/app/test/e2e/specs/settings-advanced-config.spec.ts +++ b/app/test/e2e/specs/settings-advanced-config.spec.ts @@ -98,6 +98,29 @@ describe('Settings - Advanced Config', () => { ); }); + it('persists autonomy max_actions_per_hour through core RPC', async function () { + this.timeout(60_000); + const before = await callOpenhumanRpc('openhuman.config_get_autonomy_settings', {}); + expect(before.ok).toBe(true); + + await navigateViaHash('/settings/autonomy'); + await waitForText('Agent autonomy', 15_000); + + const input = await browser.$('#autonomy-max-actions'); + await input.waitForExist({ timeout: 10_000 }); + await input.setValue('250'); + await clickText('Save', 10_000); + await waitForText('Saved.', 10_000); + + await browser.waitUntil( + async () => { + const after = await callOpenhumanRpc('openhuman.config_get_autonomy_settings', {}); + return after.ok && after.result?.result?.max_actions_per_hour === 250; + }, + { timeout: 15_000, interval: 500, timeoutMsg: 'autonomy setting did not persist' } + ); + }); + it('switches composio routing mode to direct and can return to backend mode', async function () { this.timeout(60_000); await navigateViaHash('/settings/composio-routing'); From ea06cafe3a4e9f50e9cb71690c2cf669bdd35483 Mon Sep 17 00:00:00 2001 From: Chen Qian Date: Fri, 22 May 2026 10:28:42 -0700 Subject: [PATCH 16/21] style: prettier + cargo fmt fixups for autonomy_settings files Co-Authored-By: Claude Opus 4.7 (1M context) --- .../settings/panels/AutonomyPanel.tsx | 21 ++++++---------- .../panels/__tests__/AutonomyPanel.test.tsx | 18 +++++++------- app/src/utils/tauriCommands/config.test.ts | 11 ++++----- src/openhuman/config/ops_tests.rs | 24 ++++++++++++++----- src/openhuman/config/schemas_tests.rs | 4 +--- 5 files changed, 38 insertions(+), 40 deletions(-) diff --git a/app/src/components/settings/panels/AutonomyPanel.tsx b/app/src/components/settings/panels/AutonomyPanel.tsx index c1b096681..f69f03c36 100644 --- a/app/src/components/settings/panels/AutonomyPanel.tsx +++ b/app/src/components/settings/panels/AutonomyPanel.tsx @@ -1,11 +1,11 @@ import { useEffect, useState } from 'react'; -import SettingsHeader from '../components/SettingsHeader'; -import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; import { openhumanGetAutonomySettings, openhumanUpdateAutonomySettings, } from '../../../utils/tauriCommands/config'; +import SettingsHeader from '../components/SettingsHeader'; +import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; const PRESETS = [ { label: '20 (default)', value: 20 }, @@ -42,10 +42,7 @@ const AutonomyPanel = () => { setStatus({ kind: 'idle' }); } catch (err) { if (cancelled) return; - setStatus({ - kind: 'error', - message: err instanceof Error ? err.message : String(err), - }); + setStatus({ kind: 'error', message: err instanceof Error ? err.message : String(err) }); } })(); return () => { @@ -54,8 +51,7 @@ const AutonomyPanel = () => { }, []); const parsed = Number.parseInt(draft, 10); - const isValid = - Number.isInteger(parsed) && parsed >= MIN && parsed <= MAX; + const isValid = Number.isInteger(parsed) && parsed >= MIN && parsed <= MAX; const isChanged = committed !== null && parsed !== committed; const canSave = isValid && isChanged && status.kind !== 'saving'; @@ -97,9 +93,8 @@ const AutonomyPanel = () => { Max actions per hour

- Maximum tool actions an agent can run per rolling hour. New value - applies to your next chat — running sessions keep their current - limit. + Maximum tool actions an agent can run per rolling hour. New value applies to your next + chat — running sessions keep their current limit.

@@ -152,9 +147,7 @@ const AutonomyPanel = () => { Saved. )} {status.kind === 'error' && ( - - Failed: {status.message} - + Failed: {status.message} )}
diff --git a/app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx b/app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx index 2eefbc2d7..eaceecdda 100644 --- a/app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx @@ -1,6 +1,13 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { renderWithProviders } from '../../../../test/test-utils'; +import { + openhumanGetAutonomySettings, + openhumanUpdateAutonomySettings, +} from '../../../../utils/tauriCommands/config'; +import AutonomyPanel from '../AutonomyPanel'; + vi.mock('../../hooks/useSettingsNavigation', () => ({ useSettingsNavigation: () => ({ navigateBack: vi.fn(), @@ -20,13 +27,6 @@ vi.mock('../../../../utils/tauriCommands/config', async () => { }; }); -import { - openhumanGetAutonomySettings, - openhumanUpdateAutonomySettings, -} from '../../../../utils/tauriCommands/config'; -import { renderWithProviders } from '../../../../test/test-utils'; -import AutonomyPanel from '../AutonomyPanel'; - const mockGet = vi.mocked(openhumanGetAutonomySettings); const mockUpdate = vi.mocked(openhumanUpdateAutonomySettings); @@ -64,9 +64,7 @@ describe('AutonomyPanel', () => { const input = await screen.findByDisplayValue('20'); fireEvent.change(input, { target: { value: '300' } }); fireEvent.click(screen.getByRole('button', { name: /^Save$/ })); - await waitFor(() => - expect(mockUpdate).toHaveBeenCalledWith({ max_actions_per_hour: 300 }) - ); + await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith({ max_actions_per_hour: 300 })); await screen.findByText(/Saved\./i); }); diff --git a/app/src/utils/tauriCommands/config.test.ts b/app/src/utils/tauriCommands/config.test.ts index ebcd12c76..6e45aab3a 100644 --- a/app/src/utils/tauriCommands/config.test.ts +++ b/app/src/utils/tauriCommands/config.test.ts @@ -104,9 +104,9 @@ describe('tauriCommands/config', () => { describe('openhumanUpdateAutonomySettings', () => { test('throws when not running in Tauri', async () => { mockIsTauri.mockReturnValue(false); - await expect( - openhumanUpdateAutonomySettings({ max_actions_per_hour: 100 }) - ).rejects.toThrow('Not running in Tauri'); + await expect(openhumanUpdateAutonomySettings({ max_actions_per_hour: 100 })).rejects.toThrow( + 'Not running in Tauri' + ); expect(mockCallCoreRpc).not.toHaveBeenCalled(); }); @@ -131,10 +131,7 @@ describe('tauriCommands/config', () => { }); test('reads via openhuman.config_get_autonomy_settings', async () => { - mockCallCoreRpc.mockResolvedValue({ - result: { max_actions_per_hour: 250 }, - logs: [], - }); + mockCallCoreRpc.mockResolvedValue({ result: { max_actions_per_hour: 250 }, logs: [] }); const out = await openhumanGetAutonomySettings(); expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.config_get_autonomy_settings', diff --git a/src/openhuman/config/ops_tests.rs b/src/openhuman/config/ops_tests.rs index 3cc485f7a..1589946c2 100644 --- a/src/openhuman/config/ops_tests.rs +++ b/src/openhuman/config/ops_tests.rs @@ -1036,7 +1036,9 @@ async fn apply_autonomy_settings_no_op_when_patch_empty() { let prior = cfg.autonomy.max_actions_per_hour; let _ = apply_autonomy_settings( &mut cfg, - AutonomySettingsPatch { max_actions_per_hour: None }, + AutonomySettingsPatch { + max_actions_per_hour: None, + }, ) .await .expect("apply noop"); @@ -1049,7 +1051,9 @@ async fn apply_autonomy_settings_rejects_zero() { let mut cfg = tmp_config(&tmp); let err = apply_autonomy_settings( &mut cfg, - AutonomySettingsPatch { max_actions_per_hour: Some(0) }, + AutonomySettingsPatch { + max_actions_per_hour: Some(0), + }, ) .await .unwrap_err(); @@ -1065,7 +1069,9 @@ async fn apply_autonomy_settings_rejects_above_cap() { let mut cfg = tmp_config(&tmp); let err = apply_autonomy_settings( &mut cfg, - AutonomySettingsPatch { max_actions_per_hour: Some(10_001) }, + AutonomySettingsPatch { + max_actions_per_hour: Some(10_001), + }, ) .await .unwrap_err(); @@ -1080,13 +1086,19 @@ async fn load_and_apply_autonomy_settings_roundtrip() { std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path()); } - let patch = AutonomySettingsPatch { max_actions_per_hour: Some(500) }; - let outcome = load_and_apply_autonomy_settings(patch).await.expect("apply"); + let patch = AutonomySettingsPatch { + max_actions_per_hour: Some(500), + }; + let outcome = load_and_apply_autonomy_settings(patch) + .await + .expect("apply"); assert!(outcome.value.get("config").is_some()); // Reload from scratch and confirm the saved value sticks. let reloaded = load_config_with_timeout().await.expect("reload"); assert_eq!(reloaded.autonomy.max_actions_per_hour, 500); - unsafe { std::env::remove_var("OPENHUMAN_WORKSPACE"); } + unsafe { + std::env::remove_var("OPENHUMAN_WORKSPACE"); + } } diff --git a/src/openhuman/config/schemas_tests.rs b/src/openhuman/config/schemas_tests.rs index 19d13a3d7..12d4947e1 100644 --- a/src/openhuman/config/schemas_tests.rs +++ b/src/openhuman/config/schemas_tests.rs @@ -245,9 +245,7 @@ async fn handle_get_autonomy_settings_returns_current_value() { .expect("handler"); // into_cli_compatible_json wraps data under "result" when logs are present. let inner = out.get("result").unwrap_or(&out); - let value = inner - .get("max_actions_per_hour") - .and_then(|v| v.as_u64()); + let value = inner.get("max_actions_per_hour").and_then(|v| v.as_u64()); assert_eq!(value, Some(123)); unsafe { From d6f88b1362f2b564e770b4089f72c43530045274 Mon Sep 17 00:00:00 2001 From: Chen Qian Date: Fri, 22 May 2026 10:48:58 -0700 Subject: [PATCH 17/21] i18n: add autonomy keys to all locale-5 chunks for parity Adds settings.developerMenu.autonomy.title and settings.developerMenu.autonomy.desc to all 11 non-English locale-5 chunk files with English placeholder values, restoring coverage test parity after the keys were added to en-5.ts. Co-Authored-By: Claude Sonnet 4.6 --- app/src/lib/i18n/chunks/ar-5.ts | 2 ++ app/src/lib/i18n/chunks/bn-5.ts | 2 ++ app/src/lib/i18n/chunks/de-5.ts | 2 ++ app/src/lib/i18n/chunks/es-5.ts | 2 ++ app/src/lib/i18n/chunks/fr-5.ts | 2 ++ app/src/lib/i18n/chunks/hi-5.ts | 2 ++ app/src/lib/i18n/chunks/id-5.ts | 2 ++ app/src/lib/i18n/chunks/it-5.ts | 2 ++ app/src/lib/i18n/chunks/pt-5.ts | 2 ++ app/src/lib/i18n/chunks/ru-5.ts | 2 ++ app/src/lib/i18n/chunks/zh-CN-5.ts | 2 ++ 11 files changed, 22 insertions(+) diff --git a/app/src/lib/i18n/chunks/ar-5.ts b/app/src/lib/i18n/chunks/ar-5.ts index 4967ddf89..85aa60bf9 100644 --- a/app/src/lib/i18n/chunks/ar-5.ts +++ b/app/src/lib/i18n/chunks/ar-5.ts @@ -476,6 +476,8 @@ const ar5: TranslationMap = { 'settings.mascot.title': 'OpenHuman', 'settings.developerMenu.mcpServer.title': 'MCP Server', 'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman', + 'settings.developerMenu.autonomy.title': 'Agent autonomy', + 'settings.developerMenu.autonomy.desc': 'Tool action rate limits and safety thresholds.', 'settings.mcpServer.title': 'MCP Server', 'settings.mcpServer.toolsSectionTitle': 'Available Tools', 'settings.mcpServer.toolsSectionDesc': diff --git a/app/src/lib/i18n/chunks/bn-5.ts b/app/src/lib/i18n/chunks/bn-5.ts index 9d8c80e93..cc1edc99d 100644 --- a/app/src/lib/i18n/chunks/bn-5.ts +++ b/app/src/lib/i18n/chunks/bn-5.ts @@ -482,6 +482,8 @@ const bn5: TranslationMap = { 'settings.mascot.title': 'OpenHuman', 'settings.developerMenu.mcpServer.title': 'MCP Server', 'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman', + 'settings.developerMenu.autonomy.title': 'Agent autonomy', + 'settings.developerMenu.autonomy.desc': 'Tool action rate limits and safety thresholds.', 'settings.mcpServer.title': 'MCP Server', 'settings.mcpServer.toolsSectionTitle': 'Available Tools', 'settings.mcpServer.toolsSectionDesc': diff --git a/app/src/lib/i18n/chunks/de-5.ts b/app/src/lib/i18n/chunks/de-5.ts index c9a3abf88..52ea9e55d 100644 --- a/app/src/lib/i18n/chunks/de-5.ts +++ b/app/src/lib/i18n/chunks/de-5.ts @@ -211,6 +211,8 @@ const de5: TranslationMap = { 'settings.developerMenu.mcpServer.title': 'MCP Server', 'settings.developerMenu.mcpServer.desc': 'Konfiguriere externe MCP-Clients für die Verbindung mit OpenHuman', + 'settings.developerMenu.autonomy.title': 'Agent autonomy', + 'settings.developerMenu.autonomy.desc': 'Tool action rate limits and safety thresholds.', 'settings.developerMenu.integrationTriggers.title': 'Integrationsauslöser', 'settings.developerMenu.integrationTriggers.desc': 'Konfiguriere KI-Triage-Einstellungen für Composio-Integrationsauslöser', diff --git a/app/src/lib/i18n/chunks/es-5.ts b/app/src/lib/i18n/chunks/es-5.ts index 85a2e41f1..e5637192b 100644 --- a/app/src/lib/i18n/chunks/es-5.ts +++ b/app/src/lib/i18n/chunks/es-5.ts @@ -487,6 +487,8 @@ const es5: TranslationMap = { 'settings.mascot.title': 'OpenHuman', 'settings.developerMenu.mcpServer.title': 'MCP Server', 'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman', + 'settings.developerMenu.autonomy.title': 'Agent autonomy', + 'settings.developerMenu.autonomy.desc': 'Tool action rate limits and safety thresholds.', 'settings.mcpServer.title': 'MCP Server', 'settings.mcpServer.toolsSectionTitle': 'Available Tools', 'settings.mcpServer.toolsSectionDesc': diff --git a/app/src/lib/i18n/chunks/fr-5.ts b/app/src/lib/i18n/chunks/fr-5.ts index ff051e8ec..1105703b8 100644 --- a/app/src/lib/i18n/chunks/fr-5.ts +++ b/app/src/lib/i18n/chunks/fr-5.ts @@ -491,6 +491,8 @@ const fr5: TranslationMap = { 'settings.mascot.title': 'OpenHuman', 'settings.developerMenu.mcpServer.title': 'MCP Server', 'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman', + 'settings.developerMenu.autonomy.title': 'Agent autonomy', + 'settings.developerMenu.autonomy.desc': 'Tool action rate limits and safety thresholds.', 'settings.mcpServer.title': 'MCP Server', 'settings.mcpServer.toolsSectionTitle': 'Available Tools', 'settings.mcpServer.toolsSectionDesc': diff --git a/app/src/lib/i18n/chunks/hi-5.ts b/app/src/lib/i18n/chunks/hi-5.ts index 06b3db21f..6367c08cb 100644 --- a/app/src/lib/i18n/chunks/hi-5.ts +++ b/app/src/lib/i18n/chunks/hi-5.ts @@ -484,6 +484,8 @@ const hi5: TranslationMap = { 'settings.mascot.title': 'OpenHuman', 'settings.developerMenu.mcpServer.title': 'MCP Server', 'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman', + 'settings.developerMenu.autonomy.title': 'Agent autonomy', + 'settings.developerMenu.autonomy.desc': 'Tool action rate limits and safety thresholds.', 'settings.mcpServer.title': 'MCP Server', 'settings.mcpServer.toolsSectionTitle': 'Available Tools', 'settings.mcpServer.toolsSectionDesc': diff --git a/app/src/lib/i18n/chunks/id-5.ts b/app/src/lib/i18n/chunks/id-5.ts index 36aefb885..67f5ea6f7 100644 --- a/app/src/lib/i18n/chunks/id-5.ts +++ b/app/src/lib/i18n/chunks/id-5.ts @@ -484,6 +484,8 @@ const id5: TranslationMap = { 'settings.mascot.title': 'OpenHuman', 'settings.developerMenu.mcpServer.title': 'MCP Server', 'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman', + 'settings.developerMenu.autonomy.title': 'Agent autonomy', + 'settings.developerMenu.autonomy.desc': 'Tool action rate limits and safety thresholds.', 'settings.mcpServer.title': 'MCP Server', 'settings.mcpServer.toolsSectionTitle': 'Available Tools', 'settings.mcpServer.toolsSectionDesc': diff --git a/app/src/lib/i18n/chunks/it-5.ts b/app/src/lib/i18n/chunks/it-5.ts index efd5f22a2..d6fec48e6 100644 --- a/app/src/lib/i18n/chunks/it-5.ts +++ b/app/src/lib/i18n/chunks/it-5.ts @@ -488,6 +488,8 @@ const it5: TranslationMap = { 'settings.mascot.title': 'OpenHuman', 'settings.developerMenu.mcpServer.title': 'MCP Server', 'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman', + 'settings.developerMenu.autonomy.title': 'Agent autonomy', + 'settings.developerMenu.autonomy.desc': 'Tool action rate limits and safety thresholds.', 'settings.mcpServer.title': 'MCP Server', 'settings.mcpServer.toolsSectionTitle': 'Available Tools', 'settings.mcpServer.toolsSectionDesc': diff --git a/app/src/lib/i18n/chunks/pt-5.ts b/app/src/lib/i18n/chunks/pt-5.ts index be3abfe5b..696e7f4be 100644 --- a/app/src/lib/i18n/chunks/pt-5.ts +++ b/app/src/lib/i18n/chunks/pt-5.ts @@ -488,6 +488,8 @@ const pt5: TranslationMap = { 'settings.mascot.title': 'OpenHuman', 'settings.developerMenu.mcpServer.title': 'MCP Server', 'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman', + 'settings.developerMenu.autonomy.title': 'Agent autonomy', + 'settings.developerMenu.autonomy.desc': 'Tool action rate limits and safety thresholds.', 'settings.mcpServer.title': 'MCP Server', 'settings.mcpServer.toolsSectionTitle': 'Available Tools', 'settings.mcpServer.toolsSectionDesc': diff --git a/app/src/lib/i18n/chunks/ru-5.ts b/app/src/lib/i18n/chunks/ru-5.ts index acb245950..ffbd83794 100644 --- a/app/src/lib/i18n/chunks/ru-5.ts +++ b/app/src/lib/i18n/chunks/ru-5.ts @@ -485,6 +485,8 @@ const ru5: TranslationMap = { 'settings.mascot.title': 'OpenHuman', 'settings.developerMenu.mcpServer.title': 'MCP Server', 'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman', + 'settings.developerMenu.autonomy.title': 'Agent autonomy', + 'settings.developerMenu.autonomy.desc': 'Tool action rate limits and safety thresholds.', 'settings.mcpServer.title': 'MCP Server', 'settings.mcpServer.toolsSectionTitle': 'Available Tools', 'settings.mcpServer.toolsSectionDesc': diff --git a/app/src/lib/i18n/chunks/zh-CN-5.ts b/app/src/lib/i18n/chunks/zh-CN-5.ts index 8c6a4f068..1207c8404 100644 --- a/app/src/lib/i18n/chunks/zh-CN-5.ts +++ b/app/src/lib/i18n/chunks/zh-CN-5.ts @@ -457,6 +457,8 @@ const zhCN5: TranslationMap = { 'settings.mascot.title': 'OpenHuman', 'settings.developerMenu.mcpServer.title': 'MCP 服务器', 'settings.developerMenu.mcpServer.desc': '配置外部 MCP 客户端以连接到 OpenHuman', + 'settings.developerMenu.autonomy.title': 'Agent autonomy', + 'settings.developerMenu.autonomy.desc': 'Tool action rate limits and safety thresholds.', 'settings.mcpServer.title': 'MCP 服务器', 'settings.mcpServer.toolsSectionTitle': '可用工具', 'settings.mcpServer.toolsSectionDesc': From c9d679b068b2a87ef575f0d12e41dc04f4a84321 Mon Sep 17 00:00:00 2001 From: Chen Qian Date: Fri, 22 May 2026 10:49:04 -0700 Subject: [PATCH 18/21] =?UTF-8?q?fix(app):=20clarify=20autonomy=20helper?= =?UTF-8?q?=20text=20=E2=80=94=20cron=20+=20channels=20need=20restart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous wording said "running sessions keep their current limit" which was only half correct. The cron scheduler and channel listeners (Telegram/Slack/Discord) also cache the SecurityPolicy at startup and continue using the old limit until core restart. Updated helper text makes this explicit. Co-Authored-By: Claude Sonnet 4.6 --- app/src/components/settings/panels/AutonomyPanel.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/components/settings/panels/AutonomyPanel.tsx b/app/src/components/settings/panels/AutonomyPanel.tsx index f69f03c36..05d2cbb68 100644 --- a/app/src/components/settings/panels/AutonomyPanel.tsx +++ b/app/src/components/settings/panels/AutonomyPanel.tsx @@ -94,7 +94,8 @@ const AutonomyPanel = () => {

Maximum tool actions an agent can run per rolling hour. New value applies to your next - chat — running sessions keep their current limit. + chat. Cron jobs and channel listeners keep their current limit until you restart + OpenHuman.

From 59c8e5d51d9b7b630587cdddb48badab2c3160f7 Mon Sep 17 00:00:00 2001 From: Chen Qian Date: Fri, 22 May 2026 10:58:28 -0700 Subject: [PATCH 19/21] chore: stop tracking superpowers-generated spec + plan These were local planning artifacts that shouldn't have been committed. The files remain on disk via .git/info/exclude. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-22-max-actions-per-hour-ui.md | 1340 ----------------- ...26-05-22-max-actions-per-hour-ui-design.md | 216 --- 2 files changed, 1556 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-22-max-actions-per-hour-ui.md delete mode 100644 docs/superpowers/specs/2026-05-22-max-actions-per-hour-ui-design.md diff --git a/docs/superpowers/plans/2026-05-22-max-actions-per-hour-ui.md b/docs/superpowers/plans/2026-05-22-max-actions-per-hour-ui.md deleted file mode 100644 index 520eaf568..000000000 --- a/docs/superpowers/plans/2026-05-22-max-actions-per-hour-ui.md +++ /dev/null @@ -1,1340 +0,0 @@ -# Max Actions Per Hour UI Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make the agent's `max_actions_per_hour` rate limit editable from Settings instead of requiring hand-edits to `config.toml`. - -**Architecture:** Add a new `config_get_autonomy_settings` / `config_update_autonomy_settings` RPC pair (mirroring the existing `config_*_meet_settings` pair). Persist the new value to the user's `config.toml`. Surface it through a new `AutonomyPanel` linked from `DeveloperOptionsPanel`. Effect takes hold on the next `SecurityPolicy::from_config(...)` call (next chat / cron tick); running policies keep their existing limit — documented in helper text. - -**Tech Stack:** Rust (`openhuman-core` lib, tokio, serde, schemars), TypeScript / React (`app/` workspace, Vite, Tailwind, Vitest, WDIO). - -**Spec:** `docs/superpowers/specs/2026-05-22-max-actions-per-hour-ui-design.md` - -**Branch:** `feat/ui-max-actions-per-hour` (already created; spec already committed). - -**Note on scope refinement vs. spec**: the spec said "append an Agent autonomy subsection inside DeveloperOptionsPanel." On inspection, that panel is a list of `SettingsMenuItem` rows that each navigate to a dedicated subpanel; in-page form callouts (`CoreModeBadge`, `LogsFolderRow`, `SentryTestRow`) are reserved for tiny diagnostic widgets. A user-editable form belongs in its own subpanel — that also matches how every other autonomy/security knob added later would land. The plan therefore creates `AutonomyPanel.tsx` and adds a menu link in `DeveloperOptionsPanel`. Same UX intent, just plumbed via the standard pattern. - ---- - -## File Structure - -| File | Status | Responsibility | -| --- | --- | --- | -| `src/openhuman/config/ops.rs` | modify | Add `AutonomySettingsPatch` + `apply_autonomy_settings` + `load_and_apply_autonomy_settings` | -| `src/openhuman/config/ops_tests.rs` | modify | Unit tests for the new ops | -| `src/openhuman/config/schemas.rs` | modify | Add `AutonomySettingsUpdate` DTO, two `ControllerSchema` entries, two handlers, register both controllers | -| `src/openhuman/config/schemas_tests.rs` | modify | Handler-level tests through the controller registry | -| `tests/json_rpc_e2e.rs` | modify | New roundtrip test over real JSON-RPC | -| `app/src/services/rpcMethods.ts` | modify | Add two method-name constants | -| `app/src/utils/tauriCommands/config.ts` | modify | Add `openhumanGetAutonomySettings` + `openhumanUpdateAutonomySettings` wrappers | -| `app/src/utils/tauriCommands/config.test.ts` | modify | Unit tests for the two wrappers | -| `app/src/components/settings/panels/AutonomyPanel.tsx` | create | The form (number input + presets + save) | -| `app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx` | create | UI tests for the panel | -| `app/src/components/settings/panels/DeveloperOptionsPanel.tsx` | modify | Add menu item linking to the new panel | -| `app/src/components/settings/hooks/useSettingsNavigation.ts` | modify | Add `'autonomy'` to `SettingsRoute` union; add path detection | -| `app/src/pages/Settings.tsx` | modify | Register the `/settings/autonomy` route | -| `app/test/e2e/specs/settings-advanced-config.spec.ts` | modify | Add E2E case for save + persist | - -No new top-level RPC namespaces, no schema-breaking changes to existing handlers, no changes to `SecurityPolicy` / `from_config` / consumers. - ---- - -## Task 1: Rust — `AutonomySettingsPatch` + `apply_autonomy_settings` - -**Files:** -- Modify: `src/openhuman/config/ops.rs` (add struct + function after existing `MeetSettingsPatch` at line 384 area) -- Test: `src/openhuman/config/ops_tests.rs` - -- [ ] **Step 1: Write the failing test** - -Append to `src/openhuman/config/ops_tests.rs` (after the existing `apply_meet_settings_updates_handoff_flag` test): - -```rust -#[tokio::test] -async fn apply_autonomy_settings_persists_max_actions_per_hour() { - let tmp = tempdir().unwrap(); - let mut cfg = tmp_config(&tmp); - let outcome = apply_autonomy_settings( - &mut cfg, - AutonomySettingsPatch { - max_actions_per_hour: Some(200), - }, - ) - .await - .expect("apply"); - assert_eq!(cfg.autonomy.max_actions_per_hour, 200); - // Snapshot returned so the caller can echo the saved state. - assert!(outcome.value.get("config").is_some()); - // Round-trip from disk: reload the saved TOML and confirm. - let on_disk = tokio::fs::read_to_string(&cfg.config_path).await.unwrap(); - assert!( - on_disk.contains("max_actions_per_hour = 200"), - "expected TOML to contain max_actions_per_hour = 200, got:\n{on_disk}" - ); -} - -#[tokio::test] -async fn apply_autonomy_settings_no_op_when_patch_empty() { - let tmp = tempdir().unwrap(); - let mut cfg = tmp_config(&tmp); - let prior = cfg.autonomy.max_actions_per_hour; - let _ = apply_autonomy_settings( - &mut cfg, - AutonomySettingsPatch { max_actions_per_hour: None }, - ) - .await - .expect("apply noop"); - assert_eq!(cfg.autonomy.max_actions_per_hour, prior); -} - -#[tokio::test] -async fn apply_autonomy_settings_rejects_zero() { - let tmp = tempdir().unwrap(); - let mut cfg = tmp_config(&tmp); - let err = apply_autonomy_settings( - &mut cfg, - AutonomySettingsPatch { max_actions_per_hour: Some(0) }, - ) - .await - .unwrap_err(); - assert!( - err.contains("between 1 and 10000"), - "expected validation error, got: {err}" - ); -} - -#[tokio::test] -async fn apply_autonomy_settings_rejects_above_cap() { - let tmp = tempdir().unwrap(); - let mut cfg = tmp_config(&tmp); - let err = apply_autonomy_settings( - &mut cfg, - AutonomySettingsPatch { max_actions_per_hour: Some(10_001) }, - ) - .await - .unwrap_err(); - assert!(err.contains("between 1 and 10000")); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run from repo root: - -```bash -pnpm debug rust apply_autonomy_settings -``` - -Expected: 4 tests fail to compile — `cannot find type AutonomySettingsPatch in this scope`, `cannot find function apply_autonomy_settings`. That's the failing state for TDD. - -- [ ] **Step 3: Add the struct + function** - -In `src/openhuman/config/ops.rs`, immediately after the existing `MeetSettingsPatch` definition (around line 386), add: - -```rust -#[derive(Debug, Clone, Default)] -pub struct AutonomySettingsPatch { - pub max_actions_per_hour: Option, -} -``` - -Then add the apply function. Put it next to `apply_meet_settings` (around line 764) so it's discoverable with the other settings ops: - -```rust -/// Updates the autonomy policy settings in the configuration. -/// Validation: 1 <= max_actions_per_hour <= 10_000. -pub async fn apply_autonomy_settings( - config: &mut Config, - update: AutonomySettingsPatch, -) -> Result, String> { - if let Some(v) = update.max_actions_per_hour { - if v == 0 || v > 10_000 { - return Err(format!( - "max_actions_per_hour must be between 1 and 10000 (got {v})" - )); - } - config.autonomy.max_actions_per_hour = v; - } - config.save().await.map_err(|e| e.to_string())?; - let snapshot = snapshot_config_json(config)?; - Ok(RpcOutcome::new( - snapshot, - vec![format!( - "autonomy settings saved to {}", - config.config_path.display() - )], - )) -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -```bash -pnpm debug rust apply_autonomy_settings -``` - -Expected: all 4 tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add src/openhuman/config/ops.rs src/openhuman/config/ops_tests.rs -git commit -m "feat(config): add AutonomySettingsPatch + apply_autonomy_settings" -``` - ---- - -## Task 2: Rust — `load_and_apply_autonomy_settings` roundtrip - -**Files:** -- Modify: `src/openhuman/config/ops.rs` (add wrapper next to `load_and_apply_meet_settings` ~line 783) -- Test: `src/openhuman/config/ops_tests.rs` - -- [ ] **Step 1: Write the failing test** - -Append to `src/openhuman/config/ops_tests.rs`. Use the pattern from `load_and_apply_dictation_settings_rejects_invalid_activation_mode` at line 692 — that test shows how to set up `OPENHUMAN_WORKSPACE` so `load_config_with_timeout` reads the temp dir: - -```rust -#[tokio::test] -async fn load_and_apply_autonomy_settings_roundtrip() { - let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let tmp = tempdir().unwrap(); - unsafe { - std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path()); - } - - let patch = AutonomySettingsPatch { max_actions_per_hour: Some(500) }; - let outcome = load_and_apply_autonomy_settings(patch).await.expect("apply"); - assert!(outcome.value.get("config").is_some()); - - // Reload from scratch and confirm the saved value sticks. - let reloaded = load_config_with_timeout().await.expect("reload"); - assert_eq!(reloaded.autonomy.max_actions_per_hour, 500); - - unsafe { std::env::remove_var("OPENHUMAN_WORKSPACE"); } -} -``` - -- [ ] **Step 2: Run to verify it fails** - -```bash -pnpm debug rust load_and_apply_autonomy_settings_roundtrip -``` - -Expected: fails — `cannot find function load_and_apply_autonomy_settings`. - -- [ ] **Step 3: Add the wrapper** - -In `src/openhuman/config/ops.rs`, immediately after `load_and_apply_meet_settings` (~line 783): - -```rust -/// Loads the configuration, applies autonomy settings updates, and saves it. -pub async fn load_and_apply_autonomy_settings( - update: AutonomySettingsPatch, -) -> Result, String> { - let mut config = load_config_with_timeout().await?; - apply_autonomy_settings(&mut config, update).await -} -``` - -- [ ] **Step 4: Run to verify pass** - -```bash -pnpm debug rust load_and_apply_autonomy_settings_roundtrip -``` - -Expected: pass. - -- [ ] **Step 5: Commit** - -```bash -git add src/openhuman/config/ops.rs src/openhuman/config/ops_tests.rs -git commit -m "feat(config): add load_and_apply_autonomy_settings roundtrip" -``` - ---- - -## Task 3: Rust — `AutonomySettingsUpdate` DTO + schema entries - -**Files:** -- Modify: `src/openhuman/config/schemas.rs` - -This task is schema/registration plumbing — no test step yet. Tests come in Task 4 (handler) and Task 5 (E2E). - -- [ ] **Step 1: Add the DTO** - -In `src/openhuman/config/schemas.rs`, after the existing `MeetSettingsUpdate` struct (around line 120): - -```rust -#[derive(Debug, Deserialize)] -struct AutonomySettingsUpdate { - max_actions_per_hour: Option, -} -``` - -- [ ] **Step 2: Add schema definitions** - -Inside the `schemas(name)` match block. Insert immediately after the `"get_meet_settings"` arm (around line 694): - -```rust - "update_autonomy_settings" => ControllerSchema { - namespace: "config", - function: "update_autonomy_settings", - description: - "Update agent autonomy policy settings (currently the per-hour tool action ceiling).", - inputs: vec![FieldSchema { - name: "max_actions_per_hour", - ty: TypeSchema::Option(Box::new(TypeSchema::U64)), - comment: "Maximum tool actions an agent may run per rolling hour (1-10000).", - required: false, - }], - outputs: vec![json_output("snapshot", "Updated config snapshot.")], - }, - "get_autonomy_settings" => ControllerSchema { - namespace: "config", - function: "get_autonomy_settings", - description: "Read current agent autonomy policy settings.", - inputs: vec![], - outputs: vec![FieldSchema { - name: "max_actions_per_hour", - ty: TypeSchema::U64, - comment: "Current maximum tool actions per rolling hour.", - required: true, - }], - }, -``` - -Note: `TypeSchema::U32` does not exist (see `src/core/mod.rs:81`). Use `U64` for the schema (informational); the DTO still uses `u32` and serde narrows the JSON number — out-of-range values get rejected by the validation in `apply_autonomy_settings`. - -- [ ] **Step 3: Register in `all_controller_schemas`** - -In the `all_controller_schemas()` vec (around line 207), append after `schemas("get_meet_settings")`: - -```rust - schemas("update_autonomy_settings"), - schemas("get_autonomy_settings"), -``` - -- [ ] **Step 4: Verify it compiles** - -```bash -cargo check --manifest-path Cargo.toml 2>&1 | tail -20 -``` - -Expected: clean compile (or only an unused-function warning for the not-yet-wired handlers we'll add in Task 4). - -- [ ] **Step 5: Commit** - -```bash -git add src/openhuman/config/schemas.rs -git commit -m "feat(config): add autonomy_settings schemas + DTO" -``` - ---- - -## Task 4: Rust — handlers + controller registration - -**Files:** -- Modify: `src/openhuman/config/schemas.rs` -- Test: `src/openhuman/config/schemas_tests.rs` - -- [ ] **Step 1: Write the failing tests** - -Look at `src/openhuman/config/schemas_tests.rs` for the testing convention used for other handlers — find an existing handler test (search for `handle_update_meet_settings` or `handle_get_meet_settings` in that file) and mirror the pattern. If no such test exists for meet, fall back to the analytics one (`handle_get_analytics_settings`). Append: - -```rust -#[tokio::test] -async fn handle_get_autonomy_settings_returns_current_value() { - let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let tmp = tempfile::tempdir().unwrap(); - unsafe { - std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path()); - } - // Apply a known value first. - let _ = crate::openhuman::config::ops::load_and_apply_autonomy_settings( - crate::openhuman::config::ops::AutonomySettingsPatch { - max_actions_per_hour: Some(123), - }, - ) - .await - .expect("seed"); - - let out = super::handle_get_autonomy_settings(serde_json::Map::new()) - .await - .expect("handler"); - let value = out.get("max_actions_per_hour").and_then(|v| v.as_u64()); - assert_eq!(value, Some(123)); - - unsafe { std::env::remove_var("OPENHUMAN_WORKSPACE"); } -} - -#[tokio::test] -async fn handle_update_autonomy_settings_rejects_invalid_value() { - let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let tmp = tempfile::tempdir().unwrap(); - unsafe { - std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path()); - } - let mut params = serde_json::Map::new(); - params.insert("max_actions_per_hour".into(), serde_json::json!(0)); - - let err = super::handle_update_autonomy_settings(params).await.unwrap_err(); - assert!(err.contains("between 1 and 10000"), "got: {err}"); - - unsafe { std::env::remove_var("OPENHUMAN_WORKSPACE"); } -} -``` - -If `TEST_ENV_LOCK` isn't already imported at the top of `schemas_tests.rs`, mirror what `ops_tests.rs` does (`use crate::openhuman::config::TEST_ENV_LOCK;`). If `handle_get_autonomy_settings` / `handle_update_autonomy_settings` aren't visible (they're private fns in `schemas.rs`), use the controller-registry route shown in the alternative below. - -**Alternative if private-fn access blocks compilation**: invoke through the registered controller dispatcher. Find an existing test in `schemas_tests.rs` that calls a controller by method name (`grep -n 'handle_' schemas_tests.rs`) and adapt it. The handler functions are `pub(super) fn handle_*` or `fn handle_*` — if they're not in scope, dispatching through `crate::core::dispatch::try_invoke_registered_rpc("openhuman.config_get_autonomy_settings", Map::new())` is the canonical alternative (this is what `src/core/all_tests.rs:436` does for `security_policy_info`). - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -pnpm debug rust handle_get_autonomy_settings -``` - -Expected: fail — handlers don't exist / aren't registered. - -- [ ] **Step 3: Add the handlers** - -In `src/openhuman/config/schemas.rs`, immediately after `handle_get_meet_settings` (around line 1154-1176), add: - -```rust -fn handle_update_autonomy_settings(params: Map) -> ControllerFuture { - Box::pin(async move { - log::debug!("[config][rpc] update_autonomy_settings enter"); - let update = match deserialize_params::(params) { - Ok(u) => u, - Err(err) => { - log::warn!("[config][rpc] update_autonomy_settings invalid params: {err}"); - return Err(err); - } - }; - log::debug!( - "[config][rpc] update_autonomy_settings patch max_actions_per_hour={:?}", - update.max_actions_per_hour - ); - let patch = config_rpc::AutonomySettingsPatch { - max_actions_per_hour: update.max_actions_per_hour, - }; - match config_rpc::load_and_apply_autonomy_settings(patch).await { - Ok(outcome) => { - log::debug!("[config][rpc] update_autonomy_settings ok"); - to_json(outcome) - } - Err(err) => { - log::warn!("[config][rpc] update_autonomy_settings failed: {err}"); - Err(err) - } - } - }) -} - -fn handle_get_autonomy_settings(_params: Map) -> ControllerFuture { - Box::pin(async { - log::debug!("[config][rpc] get_autonomy_settings enter"); - let config = match config_rpc::load_config_with_timeout().await { - Ok(c) => c, - Err(err) => { - log::warn!("[config][rpc] get_autonomy_settings load failed: {err}"); - return Err(err); - } - }; - let max_actions_per_hour = config.autonomy.max_actions_per_hour; - log::debug!( - "[config][rpc] get_autonomy_settings ok max_actions_per_hour={max_actions_per_hour}" - ); - let result = serde_json::json!({ - "max_actions_per_hour": max_actions_per_hour, - }); - to_json(RpcOutcome::new( - result, - vec!["autonomy settings read".to_string()], - )) - }) -} -``` - -`config_rpc` here is the existing alias for `crate::openhuman::config::ops` — confirm by grepping (`grep -n 'config_rpc' src/openhuman/config/schemas.rs | head`). - -- [ ] **Step 4: Register in `all_registered_controllers`** - -In `src/openhuman/config/schemas.rs` `all_registered_controllers()` vec (around line 289-292), append after the `get_meet_settings` entry: - -```rust - RegisteredController { - schema: schemas("update_autonomy_settings"), - handler: handle_update_autonomy_settings, - }, - RegisteredController { - schema: schemas("get_autonomy_settings"), - handler: handle_get_autonomy_settings, - }, -``` - -- [ ] **Step 5: Run tests to verify they pass** - -```bash -pnpm debug rust handle_get_autonomy_settings handle_update_autonomy_settings -``` - -Expected: both tests pass. - -- [ ] **Step 6: Commit** - -```bash -git add src/openhuman/config/schemas.rs src/openhuman/config/schemas_tests.rs -git commit -m "feat(config): add autonomy_settings handlers + register controllers" -``` - ---- - -## Task 5: Rust — JSON-RPC E2E roundtrip - -**Files:** -- Test: `tests/json_rpc_e2e.rs` - -- [ ] **Step 1: Write the failing test** - -Append to `tests/json_rpc_e2e.rs` (the file ends around line 3100; append after the last `#[tokio::test]`). Pattern adapted from the existing `json_rpc_web_chat_*` tests' setup: - -```rust -#[tokio::test] -async fn json_rpc_config_autonomy_settings_roundtrip() { - 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; - - // GET → expect the default (20). - let initial = post_json_rpc( - &rpc_base, - 7001, - "openhuman.config_get_autonomy_settings", - json!({}), - ) - .await; - let initial_result = assert_no_jsonrpc_error(&initial, "get_autonomy_settings initial"); - let initial_value = initial_result - .get("result") - .and_then(|r| r.get("max_actions_per_hour")) - .and_then(Value::as_u64); - assert_eq!(initial_value, Some(20), "expected default 20, got: {initial_result}"); - - // UPDATE → 250. - let update = post_json_rpc( - &rpc_base, - 7002, - "openhuman.config_update_autonomy_settings", - json!({ "max_actions_per_hour": 250 }), - ) - .await; - assert_no_jsonrpc_error(&update, "update_autonomy_settings"); - - // GET again → expect 250. - let after = post_json_rpc( - &rpc_base, - 7003, - "openhuman.config_get_autonomy_settings", - json!({}), - ) - .await; - let after_result = assert_no_jsonrpc_error(&after, "get_autonomy_settings after"); - let after_value = after_result - .get("result") - .and_then(|r| r.get("max_actions_per_hour")) - .and_then(Value::as_u64); - assert_eq!(after_value, Some(250)); - - // Invalid value rejected. - let bad = post_json_rpc( - &rpc_base, - 7004, - "openhuman.config_update_autonomy_settings", - json!({ "max_actions_per_hour": 99999 }), - ) - .await; - let err = bad.get("error").cloned().unwrap_or_else(|| bad.clone()); - let err_str = err.to_string(); - assert!( - err_str.contains("between 1 and 10000"), - "expected validation error in: {err_str}" - ); - - mock_join.abort(); - rpc_join.abort(); -} -``` - -- [ ] **Step 2: Run to verify it fails initially** (sanity — should fail if anything's mis-wired) - -```bash -pnpm debug rust json_rpc_config_autonomy_settings_roundtrip -``` - -If Tasks 1-4 are all done correctly, this should already pass on first run. If it fails, follow the debug-log output and re-check the controller registration in Task 4 Step 4. - -- [ ] **Step 3: Commit** - -```bash -git add tests/json_rpc_e2e.rs -git commit -m "test(rpc): roundtrip for config_*_autonomy_settings" -``` - ---- - -## Task 6: TS — RPC method constants - -**Files:** -- Modify: `app/src/services/rpcMethods.ts` - -- [ ] **Step 1: Add constants** - -In `app/src/services/rpcMethods.ts`, inside the `CORE_RPC_METHODS` object (keep alphabetical order — insert after `configGetAnalyticsSettings` at line 3): - -```ts - configGetAutonomySettings: 'openhuman.config_get_autonomy_settings', -``` - -and inside the update-settings group (after `configUpdateAnalyticsSettings` at line 7): - -```ts - configUpdateAutonomySettings: 'openhuman.config_update_autonomy_settings', -``` - -- [ ] **Step 2: Typecheck** - -```bash -pnpm typecheck 2>&1 | tail -10 -``` - -Expected: clean. - -- [ ] **Step 3: Commit** - -```bash -git add app/src/services/rpcMethods.ts -git commit -m "feat(app): add autonomy_settings RPC method constants" -``` - ---- - -## Task 7: TS — wrapper functions - -**Files:** -- Modify: `app/src/utils/tauriCommands/config.ts` - -- [ ] **Step 1: Add the wrappers** - -In `app/src/utils/tauriCommands/config.ts`, immediately after `openhumanGetMeetSettings` (around line 356, before the `ComposioTriggerSettingsUpdate` interface), add: - -```ts -export async function openhumanUpdateAutonomySettings(update: { - max_actions_per_hour?: number; -}): Promise> { - if (!isTauri()) { - throw new Error('Not running in Tauri'); - } - return await callCoreRpc>({ - method: CORE_RPC_METHODS.configUpdateAutonomySettings, - params: update, - }); -} - -export async function openhumanGetAutonomySettings(): Promise< - CommandResponse<{ max_actions_per_hour: number }> -> { - if (!isTauri()) { - throw new Error('Not running in Tauri'); - } - return await callCoreRpc>({ - method: CORE_RPC_METHODS.configGetAutonomySettings, - }); -} -``` - -- [ ] **Step 2: Typecheck** - -```bash -pnpm typecheck 2>&1 | tail -10 -``` - -Expected: clean. - -- [ ] **Step 3: Commit** - -```bash -git add app/src/utils/tauriCommands/config.ts -git commit -m "feat(app): add openhuman{Get,Update}AutonomySettings wrappers" -``` - ---- - -## Task 8: TS — wrapper unit tests - -**Files:** -- Test: `app/src/utils/tauriCommands/config.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Append to `app/src/utils/tauriCommands/config.test.ts` (after the meet-settings describe blocks, around line 98). Pattern is the same as `openhumanUpdateMeetSettings` (lines 61-98): - -```ts - describe('openhumanUpdateAutonomySettings', () => { - test('throws when not running in Tauri', async () => { - mockIsTauri.mockReturnValue(false); - await expect( - openhumanUpdateAutonomySettings({ max_actions_per_hour: 100 }) - ).rejects.toThrow('Not running in Tauri'); - expect(mockCallCoreRpc).not.toHaveBeenCalled(); - }); - - test('forwards the patch to openhuman.config_update_autonomy_settings', async () => { - mockCallCoreRpc.mockResolvedValue({ - result: { config: {}, workspace_dir: '/tmp', config_path: '/tmp/cfg.toml' }, - logs: [], - }); - await openhumanUpdateAutonomySettings({ max_actions_per_hour: 100 }); - expect(mockCallCoreRpc).toHaveBeenCalledWith({ - method: 'openhuman.config_update_autonomy_settings', - params: { max_actions_per_hour: 100 }, - }); - }); - }); - - describe('openhumanGetAutonomySettings', () => { - test('throws when not running in Tauri', async () => { - mockIsTauri.mockReturnValue(false); - await expect(openhumanGetAutonomySettings()).rejects.toThrow('Not running in Tauri'); - expect(mockCallCoreRpc).not.toHaveBeenCalled(); - }); - - test('reads via openhuman.config_get_autonomy_settings', async () => { - mockCallCoreRpc.mockResolvedValue({ - result: { max_actions_per_hour: 250 }, - logs: [], - }); - const out = await openhumanGetAutonomySettings(); - expect(mockCallCoreRpc).toHaveBeenCalledWith({ - method: 'openhuman.config_get_autonomy_settings', - }); - expect(out.result.max_actions_per_hour).toBe(250); - }); - }); -``` - -Add the imports at the top of the file (find the existing `openhumanUpdateMeetSettings` import and add the new ones alongside it): - -```ts -import { - // ... existing imports ... - openhumanGetAutonomySettings, - openhumanUpdateAutonomySettings, -} from './config'; -``` - -- [ ] **Step 2: Run to verify they pass** - -```bash -pnpm debug unit app/src/utils/tauriCommands/config.test.ts -t "AutonomySettings" -``` - -Expected: 4 tests pass. - -- [ ] **Step 3: Commit** - -```bash -git add app/src/utils/tauriCommands/config.test.ts -git commit -m "test(app): cover openhuman{Get,Update}AutonomySettings wrappers" -``` - ---- - -## Task 9: New `AutonomyPanel.tsx` - -**Files:** -- Create: `app/src/components/settings/panels/AutonomyPanel.tsx` - -- [ ] **Step 1: Create the panel** - -Write `app/src/components/settings/panels/AutonomyPanel.tsx`: - -```tsx -import { useEffect, useState } from 'react'; - -import SettingsHeader from '../components/SettingsHeader'; -import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; -import { - openhumanGetAutonomySettings, - openhumanUpdateAutonomySettings, -} from '../../../utils/tauriCommands/config'; - -const PRESETS = [ - { label: '20 (default)', value: 20 }, - { label: '100', value: 100 }, - { label: '500', value: 500 }, - { label: '1000', value: 1000 }, -]; - -const MIN = 1; -const MAX = 10_000; - -type Status = - | { kind: 'idle' } - | { kind: 'loading' } - | { kind: 'saving' } - | { kind: 'saved' } - | { kind: 'error'; message: string }; - -const AutonomyPanel = () => { - const { navigateBack, breadcrumbs } = useSettingsNavigation(); - const [committed, setCommitted] = useState(null); - const [draft, setDraft] = useState(''); - const [status, setStatus] = useState({ kind: 'loading' }); - - useEffect(() => { - let cancelled = false; - (async () => { - try { - const res = await openhumanGetAutonomySettings(); - if (cancelled) return; - const value = res.result.max_actions_per_hour; - setCommitted(value); - setDraft(String(value)); - setStatus({ kind: 'idle' }); - } catch (err) { - if (cancelled) return; - setStatus({ - kind: 'error', - message: err instanceof Error ? err.message : String(err), - }); - } - })(); - return () => { - cancelled = true; - }; - }, []); - - const parsed = Number.parseInt(draft, 10); - const isValid = - Number.isInteger(parsed) && parsed >= MIN && parsed <= MAX; - const isChanged = committed !== null && parsed !== committed; - const canSave = isValid && isChanged && status.kind !== 'saving'; - - const applyPreset = (value: number) => { - setDraft(String(value)); - if (status.kind === 'saved' || status.kind === 'error') { - setStatus({ kind: 'idle' }); - } - }; - - const onSave = async () => { - if (!canSave) return; - setStatus({ kind: 'saving' }); - try { - await openhumanUpdateAutonomySettings({ max_actions_per_hour: parsed }); - setCommitted(parsed); - setStatus({ kind: 'saved' }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - // Revert UI to last committed value, then surface the error. - if (committed !== null) setDraft(String(committed)); - setStatus({ kind: 'error', message }); - } - }; - - return ( -
- -
-
- -

- Maximum tool actions an agent can run per rolling hour. New value - applies to your next chat — running sessions keep their current - limit. -

- -
- { - setDraft(e.target.value); - if (status.kind === 'saved' || status.kind === 'error') { - setStatus({ kind: 'idle' }); - } - }} - disabled={status.kind === 'loading' || status.kind === 'saving'} - className="w-32 px-3 py-1.5 rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-sm font-mono" - /> - -
- -
- {PRESETS.map(p => ( - - ))} -
- -
- {!isValid && draft.trim() !== '' && ( - - Must be an integer between {MIN} and {MAX.toLocaleString()}. - - )} - {status.kind === 'saved' && ( - Saved. - )} - {status.kind === 'error' && ( - - Failed: {status.message} - - )} -
-
-
-
- ); -}; - -export default AutonomyPanel; -``` - -- [ ] **Step 2: Typecheck** - -```bash -pnpm typecheck 2>&1 | tail -10 -``` - -Expected: clean. If `useT` or i18n keys are missing for "Agent autonomy" / helper text, that's fine — strings are inline for now (i18n can be added later; matches the inline-string style used in `SentryTestRow`). - -- [ ] **Step 3: Commit** - -```bash -git add app/src/components/settings/panels/AutonomyPanel.tsx -git commit -m "feat(app): add AutonomyPanel for max_actions_per_hour control" -``` - ---- - -## Task 10: Wire `AutonomyPanel` into routing + Developer Options menu - -**Files:** -- Modify: `app/src/components/settings/hooks/useSettingsNavigation.ts` -- Modify: `app/src/pages/Settings.tsx` -- Modify: `app/src/components/settings/panels/DeveloperOptionsPanel.tsx` - -- [ ] **Step 1: Extend `SettingsRoute` union** - -In `app/src/components/settings/hooks/useSettingsNavigation.ts`, find the `SettingsRoute` type (top of file, around line 5-40). Add `'autonomy'` to the union. Pick a logical spot — e.g. right after `'developer-options'`. - -```ts -export type SettingsRoute = - // ... existing variants ... - | 'developer-options' - | 'autonomy' - // ... rest ... -``` - -Then add path detection in `getCurrentRoute()` (around line 94). Place it next to `'developer-options'`: - -```ts - if (path.includes('/settings/autonomy')) return 'autonomy'; -``` - -- [ ] **Step 2: Register the route** - -In `app/src/pages/Settings.tsx`: - -1. Add import (alphabetical-ish with the other panel imports near the top): - -```ts -import AutonomyPanel from '../components/settings/panels/AutonomyPanel'; -``` - -2. Inside the `` block (around line 355 next to the `developer-options` route), add: - -```tsx - )} /> -``` - -- [ ] **Step 3: Add menu link in `DeveloperOptionsPanel`** - -In `app/src/components/settings/panels/DeveloperOptionsPanel.tsx`, append to the `developerItems` array (after the `mcp-server` entry at line 240-256, before the closing `];`): - -```tsx - { - id: 'autonomy', - titleKey: 'settings.developerMenu.autonomy.title', - descriptionKey: 'settings.developerMenu.autonomy.desc', - route: 'autonomy', - icon: ( - - - - ), - }, -``` - -(SVG path is the standard "padlock" icon — fits the safety/autonomy framing.) - -The `titleKey` and `descriptionKey` will fall back to the literal key strings if no translation is registered yet — that's fine for now (other entries use the same pattern; i18n can be added in a follow-up commit if needed). To avoid raw keys in the UI, use literal strings instead: - -```tsx - { - id: 'autonomy', - titleKey: undefined, - descriptionKey: undefined, - title: 'Agent autonomy', - description: 'Tool action rate limits and safety thresholds.', - route: 'autonomy', - // ... icon as above ... - }, -``` - -BUT the existing render block (line 498-508) calls `t(item.titleKey)` directly — so the cleanest path is to register the i18n keys. Open `app/src/lib/i18n/locales/en.json` (or whichever file holds the existing `settings.developerMenu.*` keys — find it via `grep -rn 'settings.developerMenu.mcpServer' app/src/lib/i18n`) and add: - -```json -"settings.developerMenu.autonomy.title": "Agent autonomy", -"settings.developerMenu.autonomy.desc": "Tool action rate limits and safety thresholds." -``` - -If the i18n file uses nested JSON, mirror the existing structure (drill into `settings → developerMenu → mcpServer` and add `autonomy` as a sibling object with `title` + `desc` keys). - -- [ ] **Step 4: Typecheck + lint** - -```bash -pnpm typecheck 2>&1 | tail -10 -pnpm lint 2>&1 | tail -10 -``` - -Expected: clean. - -- [ ] **Step 5: Commit** - -```bash -git add app/src/components/settings/hooks/useSettingsNavigation.ts app/src/pages/Settings.tsx app/src/components/settings/panels/DeveloperOptionsPanel.tsx app/src/lib/i18n -git commit -m "feat(app): route + menu link for AutonomyPanel" -``` - ---- - -## Task 11: UI tests for `AutonomyPanel` - -**Files:** -- Test: `app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx` - -- [ ] **Step 1: Inspect a reference test for setup conventions** - -Run: - -```bash -ls app/src/components/settings/panels/__tests__/ -``` - -Open one of the simpler existing panel tests (e.g. `MessagingPanel.test.tsx` or `NotificationsPanel.test.tsx`) to copy the mocking pattern for `tauriCommands/config`. Look for the `vi.mock('../../../../utils/tauriCommands/...')` setup at the top. - -- [ ] **Step 2: Write the failing tests** - -Create `app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx`: - -```tsx -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; - -vi.mock('../../../../utils/tauriCommands/config', () => ({ - openhumanGetAutonomySettings: vi.fn(), - openhumanUpdateAutonomySettings: vi.fn(), -})); - -import AutonomyPanel from '../AutonomyPanel'; -import { - openhumanGetAutonomySettings, - openhumanUpdateAutonomySettings, -} from '../../../../utils/tauriCommands/config'; - -const mockGet = vi.mocked(openhumanGetAutonomySettings); -const mockUpdate = vi.mocked(openhumanUpdateAutonomySettings); - -const renderPanel = () => - render( - - - - ); - -describe('AutonomyPanel', () => { - beforeEach(() => { - mockGet.mockReset(); - mockUpdate.mockReset(); - }); - - test('loads the current value on mount', async () => { - mockGet.mockResolvedValue({ result: { max_actions_per_hour: 250 }, logs: [] }); - renderPanel(); - const input = (await screen.findByLabelText(/Max actions per hour/i)) as HTMLInputElement; - await waitFor(() => expect(input).toHaveValue(250)); - }); - - test('Save is disabled until the value changes', async () => { - mockGet.mockResolvedValue({ result: { max_actions_per_hour: 20 }, logs: [] }); - renderPanel(); - const saveBtn = await screen.findByRole('button', { name: /^Save$/ }); - expect(saveBtn).toBeDisabled(); - - const input = await screen.findByDisplayValue('20'); - fireEvent.change(input, { target: { value: '100' } }); - expect(saveBtn).not.toBeDisabled(); - }); - - test('Save invokes the wrapper and shows confirmation', async () => { - mockGet.mockResolvedValue({ result: { max_actions_per_hour: 20 }, logs: [] }); - mockUpdate.mockResolvedValue({ - result: { config: {}, workspace_dir: '/tmp', config_path: '/tmp/cfg.toml' }, - logs: [], - }); - renderPanel(); - const input = await screen.findByDisplayValue('20'); - fireEvent.change(input, { target: { value: '300' } }); - fireEvent.click(screen.getByRole('button', { name: /^Save$/ })); - await waitFor(() => - expect(mockUpdate).toHaveBeenCalledWith({ max_actions_per_hour: 300 }) - ); - await screen.findByText(/Saved\./i); - }); - - test('shows inline validation when the value is out of range', async () => { - mockGet.mockResolvedValue({ result: { max_actions_per_hour: 20 }, logs: [] }); - renderPanel(); - const input = await screen.findByDisplayValue('20'); - fireEvent.change(input, { target: { value: '0' } }); - await screen.findByText(/Must be an integer between 1 and 10,000/i); - expect(screen.getByRole('button', { name: /^Save$/ })).toBeDisabled(); - }); - - test('surfaces RPC errors and reverts to the last committed value', async () => { - mockGet.mockResolvedValue({ result: { max_actions_per_hour: 50 }, logs: [] }); - mockUpdate.mockRejectedValue(new Error('disk full')); - renderPanel(); - const input = await screen.findByDisplayValue('50'); - fireEvent.change(input, { target: { value: '500' } }); - fireEvent.click(screen.getByRole('button', { name: /^Save$/ })); - await screen.findByText(/Failed: disk full/); - // Reverted to last committed value. - expect(input).toHaveValue(50); - }); -}); -``` - -- [ ] **Step 3: Run to verify they pass** - -```bash -pnpm debug unit app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx -``` - -Expected: 5 tests pass. If `findByLabelText` fails, the test falls back to `findByDisplayValue`. If a different test helper is conventional in this codebase, check a neighbouring panel's test for the right imports — `app/src/test/setup.ts` may register `@testing-library/jest-dom`. - -- [ ] **Step 4: Commit** - -```bash -git add app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx -git commit -m "test(app): cover AutonomyPanel load/save/validate/error paths" -``` - ---- - -## Task 12: E2E case — persist through real core RPC - -**Files:** -- Modify: `app/test/e2e/specs/settings-advanced-config.spec.ts` - -- [ ] **Step 1: Add the E2E case** - -Append inside the existing `describe('Settings - Advanced Config', …)` block, after the `'persists composio trigger triage settings'` test (around line 99): - -```ts - it('persists autonomy max_actions_per_hour through core RPC', async function () { - this.timeout(60_000); - const before = await callOpenhumanRpc('openhuman.config_get_autonomy_settings', {}); - expect(before.ok).toBe(true); - - await navigateViaHash('/settings/autonomy'); - await waitForText('Agent autonomy', 15_000); - - const input = await browser.$('#autonomy-max-actions'); - await input.waitForExist({ timeout: 10_000 }); - await input.setValue('250'); - await clickText('Save', 10_000); - await waitForText('Saved', 10_000); - - await browser.waitUntil( - async () => { - const after = await callOpenhumanRpc('openhuman.config_get_autonomy_settings', {}); - return after.ok && after.result?.result?.max_actions_per_hour === 250; - }, - { timeout: 15_000, interval: 500, timeoutMsg: 'autonomy setting did not persist' } - ); - }); -``` - -- [ ] **Step 2: Build the bundle, then run just this spec** - -```bash -pnpm test:e2e:build -bash app/scripts/e2e-run-spec.sh test/e2e/specs/settings-advanced-config.spec.ts settings-advanced-config -``` - -Expected: all cases in this spec pass, including the new one. If the new case fails because the input id mismatches, check the `id="autonomy-max-actions"` attribute on the `` in Task 9. - -- [ ] **Step 3: Commit** - -```bash -git add app/test/e2e/specs/settings-advanced-config.spec.ts -git commit -m "test(e2e): persist autonomy max_actions_per_hour through core RPC" -``` - ---- - -## Task 13: Final integration — coverage, full test sweep, manual smoke - -- [ ] **Step 1: Run the changed-file unit test suites + Rust tests** - -```bash -pnpm debug unit app/src/utils/tauriCommands/config.test.ts -pnpm debug unit app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx -pnpm debug rust autonomy -pnpm debug rust json_rpc_config_autonomy_settings_roundtrip -``` - -Expected: all pass. - -- [ ] **Step 2: Lint + format** - -```bash -pnpm lint 2>&1 | tail -pnpm format:check 2>&1 | tail -cargo fmt --manifest-path Cargo.toml -- --check 2>&1 | tail -``` - -If `format:check` complains, run `pnpm format` and amend the fixup into a single trailing commit. - -- [ ] **Step 3: Coverage on changed lines** - -The PR coverage gate is `≥80% on changed lines`. Quickly sanity-check: - -```bash -pnpm test:coverage 2>&1 | tail -20 -``` - -If a changed line in `AutonomyPanel.tsx` or `config.ts` isn't covered, add a focused test rather than padding existing ones. - -- [ ] **Step 4: Manual smoke (HUMAN-IN-THE-LOOP)** - -Don't skip this — the spec calls for it. - -```bash -pnpm dev:app -``` - -In the running app: -1. Open `Settings → Developer Options → Agent autonomy`. -2. Confirm the current value loads (default 20 on a fresh workspace). -3. Change to 300, click Save → confirm "Saved" appears. -4. Reopen the panel → confirm 300 is shown. -5. Try entering 0 → confirm validation message, Save disabled. -6. Try entering 99999 → confirm validation message client-side. - -Then verify the new value actually changes agent behavior: -1. Open a fresh chat with the agent and trigger more than 20 tool calls (e.g. a multi-step task). -2. With the limit at 300, the agent should not hit the "Rate limit exceeded" error. - -Document the smoke result in the PR description. - -- [ ] **Step 5: Push branch and open PR** - -```bash -git push -u origin feat/ui-max-actions-per-hour -gh pr create --repo tinyhumansai/openhuman --head EvanCarson:feat/ui-max-actions-per-hour --base main \ - --title "feat(app): UI control for max_actions_per_hour (#2486)" \ - --body "$(cat <<'EOF' -## Summary -- Adds `config_get_autonomy_settings` / `config_update_autonomy_settings` JSON-RPC methods (mirrors the existing `config_*_meet_settings` pair). -- Surfaces them through a new `AutonomyPanel` linked from `Settings → Developer Options`. Number input with presets (20/100/500/1000); validates 1–10000. -- Persists to the user's `config.toml`. Takes effect on the next agent session — running sessions keep their current limit (documented in helper text). - -Scoped from #2486 to the single `max_actions_per_hour` knob; the new panel is shaped so follow-up PRs can add `allowed_commands`, `auto_approve`, etc. - -Pre-existing `openhuman.security_policy_info` bug (returns `SecurityPolicy::default()` instead of loaded config) is **not** fixed here — UI sidesteps it by reading from the new dedicated RPC. Separate follow-up. - -## Test plan -- [ ] `pnpm debug rust autonomy` — Rust unit + roundtrip -- [ ] `pnpm debug rust json_rpc_config_autonomy_settings_roundtrip` — JSON-RPC E2E -- [ ] `pnpm debug unit app/src/utils/tauriCommands/config.test.ts` — TS wrapper unit tests -- [ ] `pnpm debug unit app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx` — UI unit tests -- [ ] `bash app/scripts/e2e-run-spec.sh test/e2e/specs/settings-advanced-config.spec.ts settings-advanced-config` — WDIO E2E -- [ ] Manual smoke in `pnpm dev:app` — load value, save, restart-free verify via new chat - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - -(Replace `EvanCarson:` if the user's fork remote is named differently — check `git remote -v`.) - ---- - -## Self-Review Notes - -- **Spec coverage**: every "In scope" bullet has at least one task. Out-of-scope items (other autonomy fields, hot-reload, usage display, `security_policy_info` bug) are explicitly excluded in PR body + helper text. -- **Type consistency**: `max_actions_per_hour` is the field name everywhere — Rust struct field, JSON property, RPC param, TS wrapper arg, input id. RPC method names use the `config_*_autonomy_settings` shape consistently. -- **Schema gap noted**: the spec called for `TypeSchema::U32` implicitly but the type system has no `U32` variant — Task 3 documents the `U64` fallback (informational schema only; serde narrows on the DTO side). -- **Spec deviation called out**: the spec described the UI as "a subsection inside DeveloperOptionsPanel"; the plan creates a dedicated subpanel + route instead, matching every other entry in DeveloperOptionsPanel. Same UX intent; better extensibility for the follow-up autonomy fields. diff --git a/docs/superpowers/specs/2026-05-22-max-actions-per-hour-ui-design.md b/docs/superpowers/specs/2026-05-22-max-actions-per-hour-ui-design.md deleted file mode 100644 index 74d72950b..000000000 --- a/docs/superpowers/specs/2026-05-22-max-actions-per-hour-ui-design.md +++ /dev/null @@ -1,216 +0,0 @@ -# Design: UI-configurable `max_actions_per_hour` - -**Issue:** [tinyhumansai/openhuman#2486](https://github.com/tinyhumansai/openhuman/issues/2486) (scoped to one item). -**Date:** 2026-05-22. -**Status:** Draft (pre-implementation). - -## Problem - -The agent's tool action ceiling defaults to `max_actions_per_hour = 20`. Once exhausted, all subsequent tool calls are silently denied (`"Rate limit exceeded: action budget exhausted"`). For non-trivial sessions this is too low. Today, raising it requires hand-editing `~/.openhuman/.../config.toml` and restarting the core. There is no UI control. - -The backend already supports the field — `AutonomyConfig.max_actions_per_hour` is loaded from TOML, defaults to 20, and threaded through `SecurityPolicy::from_config()` at every site that builds a policy (session builder, cron scheduler, channels runtime, MCP server, node runtime, local CLI). What's missing is a way to change it from the app. - -## Scope - -**In scope** -- A user-editable `max_actions_per_hour` field, persisted to the user's `config.toml`. -- A pair of JSON-RPC methods to read and write the value. -- A new "Agent autonomy" subsection inside the existing `DeveloperOptionsPanel`. -- Validation: `1 <= value <= 10_000`. - -**Out of scope (deliberate)** -- Other autonomy fields raised in issue #2486 — `allowed_commands`, `auto_approve`, `block_high_risk_commands`, `max_cost_per_day_cents`. The new RPC and panel are shaped so these can be added later by extending the same patch struct + panel section. -- Hot-reload of running sessions / cron jobs / channels. New value applies to the *next* session; running policies keep their existing limit. -- Aggregated per-user usage display (`"X / Y used this hour"`). The action counter lives inside per-session `SecurityPolicy` instances, so there is no single number to display without first building one. -- Fixing the pre-existing `openhuman.security_policy_info` bug (returns `SecurityPolicy::default()` instead of the loaded config). Filed as a separate follow-up; this PR sidesteps it by not reading from that endpoint. - -## Architecture - -Four thin layers following the established `config_*_settings` pattern in this repo (e.g. `config_get_meet_settings` / `config_update_meet_settings`): - -``` -UI (React) - DeveloperOptionsPanel — new "Agent autonomy" subsection - │ - ▼ coreRpcClient → invoke('core_rpc_relay', …) -JSON-RPC controllers (src/openhuman/config/schemas.rs) - handle_get_autonomy_settings → openhuman.config_get_autonomy_settings - handle_update_autonomy_settings → openhuman.config_update_autonomy_settings - │ - ▼ -Domain ops (src/openhuman/config/ops.rs) - apply_autonomy_settings(&mut Config, AutonomySettingsPatch) - load_and_apply_autonomy_settings(AutonomySettingsPatch) - │ - ▼ config.save() → user TOML -Existing readers (unchanged) - SecurityPolicy::from_config() in: - - agent/harness/session/builder.rs - - cron/scheduler.rs, cron/ops.rs - - channels/runtime/startup.rs - - mcp_server/tools.rs - - runtime_node/ops.rs - - tools/local_cli.rs -``` - -Each new construction of `SecurityPolicy` reads the current `Config`, so a saved change takes effect on the next session / cron tick / channel pickup without any propagation work. - -## Components - -### Rust core - -**`src/openhuman/config/ops.rs`** — add (mirror `MeetSettingsPatch` / `apply_meet_settings`): - -```rust -#[derive(Debug, Default, Deserialize, JsonSchema)] -pub struct AutonomySettingsPatch { - pub max_actions_per_hour: Option, -} - -pub async fn apply_autonomy_settings( - config: &mut Config, - update: AutonomySettingsPatch, -) -> Result, String> { - if let Some(v) = update.max_actions_per_hour { - if v == 0 || v > 10_000 { - return Err("max_actions_per_hour must be between 1 and 10000".into()); - } - config.autonomy.max_actions_per_hour = v; - } - config.save().await.map_err(|e| e.to_string())?; - let snapshot = snapshot_config_json(config)?; - Ok(RpcOutcome::new( - snapshot, - vec![format!("autonomy settings saved to {}", config.config_path.display())], - )) -} - -pub async fn load_and_apply_autonomy_settings( - update: AutonomySettingsPatch, -) -> Result, String> { - let mut config = load_config_with_timeout().await?; - apply_autonomy_settings(&mut config, update).await -} -``` - -**`src/openhuman/config/schemas.rs`** — add: - -- `ControllerSchema` entries for `get_autonomy_settings` and `update_autonomy_settings`, registered in the controller list near the existing meet entries (~line 286). -- Schema definitions in the `schemas(name)` match block (~line 672) — `get_autonomy_settings` takes no params; `update_autonomy_settings` takes `{ max_actions_per_hour?: u32 }`. -- `handle_get_autonomy_settings` returns `{ "max_actions_per_hour": }` from a loaded `Config`. -- `handle_update_autonomy_settings` deserialises into an `AutonomySettingsUpdate` DTO, builds the patch, calls `load_and_apply_autonomy_settings`. - -Both handlers follow the existing `debug!("[config][rpc] X enter") / ok / failed` logging pattern. - -Resulting RPC method names: -- `openhuman.config_get_autonomy_settings` -- `openhuman.config_update_autonomy_settings` - -### Tauri / TypeScript - -**`app/src/utils/tauriCommands/config.ts`** — add `getAutonomySettings()` and `updateAutonomySettings(patch)` wrappers, mirroring the meet-settings wrappers. - -**`app/src/services/rpcMethods.ts`** — add the two new method constants. - -**`app/src/components/settings/panels/DeveloperOptionsPanel.tsx`** — append an "Agent autonomy" subsection: -- Heading + helper text: *"Maximum tool actions an agent can run per hour. New value applies to your next chat — running sessions keep their current limit."* -- Number `` with `min=1`, `max=10000`, integer step. -- Preset chips: `20 (default)`, `100`, `500`, `1000`. -- Save button — disabled when value is unchanged or invalid. -- Inline confirmation on save; inline error message on failure. - -The panel fetches the current value via `getAutonomySettings()` on mount; on save, calls `updateAutonomySettings({ max_actions_per_hour })`. On success, keeps the edited value as the new committed state and shows confirmation; on error, reverts the UI to the last committed value and shows the error message. - -## Data flow - -**Save** -1. User edits → clicks Save in `DeveloperOptionsPanel`. -2. `updateAutonomySettings({ max_actions_per_hour: 200 })` → `core_rpc_relay` → core. -3. `handle_update_autonomy_settings` → `load_and_apply_autonomy_settings` → mutates `config.autonomy.max_actions_per_hour` → `config.save()` writes user TOML. -4. RPC returns `RpcOutcome { value: snapshot_json, logs: ["autonomy settings saved to "] }`. -5. UI shows inline "Saved" confirmation. - -**Read** -1. Panel mounts → `getAutonomySettings()` → `openhuman.config_get_autonomy_settings`. -2. `handle_get_autonomy_settings` calls `load_config_with_timeout()` → returns `{ max_actions_per_hour: config.autonomy.max_actions_per_hour }`. -3. UI initialises field state with returned value. - -## Error handling - -- **Invalid input** (≤0, >10000, non-integer): rejected client-side first via `min`/`max` attributes; re-validated in the handler — returns `Err("max_actions_per_hour must be between 1 and 10000")`. UI surfaces the message inline. -- **Config load timeout / disk write failure**: propagates as RPC error; panel shows the message inline; existing TOML on disk is unchanged. -- **Core not yet ready**: panel handles this the same way other panels do — loading skeleton, retry on RPC error. -- **Edits do not affect running sessions**: documented in the panel's helper text. This is expected behavior, not a failure mode — no warning surfaced. - -## Logging - -Per repo rule (verbose diagnostics on new/changed flows, stable grep-friendly prefixes): - -- `[config][rpc] update_autonomy_settings enter max_actions_per_hour=` -- `[config][rpc] update_autonomy_settings ok` / `... failed: ` -- `[config][rpc] get_autonomy_settings enter` -- `[config][rpc] get_autonomy_settings ok max_actions_per_hour=` / `... failed: ` - -## Testing - -**Rust unit (`src/openhuman/config/ops_tests.rs`)** — alongside the `apply_meet_settings` tests: -- `apply_autonomy_settings_persists_max_actions_per_hour` — assert config mutated + saved to disk. -- `apply_autonomy_settings_no_op_when_patch_empty` — `None` patch leaves the value unchanged. -- `apply_autonomy_settings_rejects_zero` and `_rejects_above_cap` — validation works at both bounds. -- `load_and_apply_autonomy_settings_roundtrip` — load → apply → reload → value matches. - -**Rust handler (`src/openhuman/config/schemas_tests.rs`)**: -- `update_autonomy_settings` and `get_autonomy_settings` route through the controller registry and return the expected JSON shape. -- Invalid params return `Err`. - -**Rust E2E (`tests/json_rpc_e2e.rs`)**: -- New test: post `openhuman.config_update_autonomy_settings` then `openhuman.config_get_autonomy_settings`; assert the round-trip over actual JSON-RPC. - -**TypeScript unit (`app/src/utils/tauriCommands/config.test.ts`)**: -- `getAutonomySettings` invokes the correct method. -- `updateAutonomySettings` passes the patch correctly. - -**UI (`app/src/components/settings/panels/__tests__/DeveloperOptionsPanel.test.tsx`)**: -- Loads current value on mount. -- Save button disabled until value changes and is valid. -- Save calls the wrapper with the new value, shows confirmation. -- Validation error surfaces inline. - -**E2E (`app/test/e2e/specs/settings-advanced-config.spec.ts`)** — add a case to the existing spec rather than a new file: -- Open Developer Options, change the rate-limit field, save, reopen, confirm persisted value. - -Coverage on changed lines must meet the repo's ≥80% merge gate. - -## File touch list - -``` -src/openhuman/config/ops.rs (+ ~30 lines, mirror meet pattern) -src/openhuman/config/ops_tests.rs (+ ~80 lines, new tests) -src/openhuman/config/schemas.rs (+ ~60 lines: schema, registration, handlers) -src/openhuman/config/schemas_tests.rs (+ ~40 lines) -tests/json_rpc_e2e.rs (+ ~30 lines, round-trip test) - -app/src/utils/tauriCommands/config.ts (+ ~25 lines, two wrappers) -app/src/utils/tauriCommands/config.test.ts (+ ~30 lines) -app/src/services/rpcMethods.ts (+ 2 lines, method constants) -app/src/components/settings/panels/DeveloperOptionsPanel.tsx (+ ~80 lines, new section) -app/src/components/settings/panels/__tests__/DeveloperOptionsPanel.test.tsx (+ ~60 lines) -app/test/e2e/specs/settings-advanced-config.spec.ts (+ ~30 lines, new case) -``` - -No new files; no schema-breaking changes to existing handlers; no changes to `SecurityPolicy`, `from_config`, or any consumer of those. - -## Risks & open questions - -- **Stale running sessions** — a user who hits the ceiling, raises the limit, and expects the *current* chat to recover will be confused. Mitigated by helper text. If this turns out to be a common complaint, Approach C (live propagation via event bus) is the follow-up. -- **`security_policy_info` returns defaults** — pre-existing bug, deferred. The UI does not read from it. -- **Cap of 10,000** — chosen as "effectively unlimited for human use" while bounding the field against typos. Easy to lift if needed. - -## Sequencing - -1. Rust ops + schemas + their unit tests. -2. Rust E2E round-trip test. -3. TS wrappers + their unit tests. -4. UI panel section + UI tests. -5. E2E spec case. -6. Manual smoke (start dev app, change value, restart-free verify by starting a new agent session). From 99886daeb045c18382e6a8571b75eca3f189f0d8 Mon Sep 17 00:00:00 2001 From: Chen Qian Date: Fri, 22 May 2026 11:52:49 -0700 Subject: [PATCH 20/21] fix(app): address CodeRabbit review feedback - Strict integer validation: reject '1.5', '1e2', '-5', '0.0' (now uses regex + Number() + Number.isInteger instead of parseInt, which would truncate decimals to a valid-looking int). - Add regression test.each cases for non-integer input shapes. - E2E spec: derive target value from current state so the test exercises a real mutation instead of potentially no-op'ing on 250. - Drop trailing period from autonomy.desc to match en.ts sibling-key convention. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/components/settings/panels/AutonomyPanel.tsx | 6 ++++-- .../settings/panels/__tests__/AutonomyPanel.test.tsx | 12 ++++++++++++ app/src/lib/i18n/chunks/en-5.ts | 2 +- app/src/lib/i18n/en.ts | 2 +- app/test/e2e/specs/settings-advanced-config.spec.ts | 7 +++++-- 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/app/src/components/settings/panels/AutonomyPanel.tsx b/app/src/components/settings/panels/AutonomyPanel.tsx index 05d2cbb68..104e58970 100644 --- a/app/src/components/settings/panels/AutonomyPanel.tsx +++ b/app/src/components/settings/panels/AutonomyPanel.tsx @@ -50,8 +50,10 @@ const AutonomyPanel = () => { }; }, []); - const parsed = Number.parseInt(draft, 10); - const isValid = Number.isInteger(parsed) && parsed >= MIN && parsed <= MAX; + const trimmed = draft.trim(); + const parsed = Number(trimmed); + const isValid = + /^\d+$/.test(trimmed) && Number.isInteger(parsed) && parsed >= MIN && parsed <= MAX; const isChanged = committed !== null && parsed !== committed; const canSave = isValid && isChanged && status.kind !== 'saving'; diff --git a/app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx b/app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx index eaceecdda..b10b6f558 100644 --- a/app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx @@ -77,6 +77,18 @@ describe('AutonomyPanel', () => { expect(screen.getByRole('button', { name: /^Save$/ })).toBeDisabled(); }); + // Note: '12abc' is omitted because filters non-numeric + // characters before React sees the change event — there's no way the panel + // can receive that input through normal UI flow. + test.each(['1.5', '1e2', '-5', '0.0'])('rejects non-integer input %s', async value => { + mockGet.mockResolvedValue({ result: { max_actions_per_hour: 20 }, logs: [] }); + renderWithProviders(, { initialEntries: ['/settings/autonomy'] }); + const input = await screen.findByDisplayValue('20'); + fireEvent.change(input, { target: { value } }); + await screen.findByText(/Must be an integer between 1 and 10,000/i); + expect(screen.getByRole('button', { name: /^Save$/ })).toBeDisabled(); + }); + test('surfaces RPC errors and reverts to the last committed value', async () => { mockGet.mockResolvedValue({ result: { max_actions_per_hour: 50 }, logs: [] }); mockUpdate.mockRejectedValue(new Error('disk full')); diff --git a/app/src/lib/i18n/chunks/en-5.ts b/app/src/lib/i18n/chunks/en-5.ts index 3f57027a7..de0e4a548 100644 --- a/app/src/lib/i18n/chunks/en-5.ts +++ b/app/src/lib/i18n/chunks/en-5.ts @@ -483,7 +483,7 @@ const en5: TranslationMap = { 'settings.developerMenu.mcpServer.title': 'MCP Server', 'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman', 'settings.developerMenu.autonomy.title': 'Agent autonomy', - 'settings.developerMenu.autonomy.desc': 'Tool action rate limits and safety thresholds.', + 'settings.developerMenu.autonomy.desc': 'Tool action rate limits and safety thresholds', 'settings.mcpServer.title': 'MCP Server', 'settings.mcpServer.toolsSectionTitle': 'Available Tools', 'settings.mcpServer.toolsSectionDesc': diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index f303dc111..b37340956 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1959,7 +1959,7 @@ const en: TranslationMap = { 'settings.developerMenu.mcpServer.title': 'MCP Server', 'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman', 'settings.developerMenu.autonomy.title': 'Agent autonomy', - 'settings.developerMenu.autonomy.desc': 'Tool action rate limits and safety thresholds.', + 'settings.developerMenu.autonomy.desc': 'Tool action rate limits and safety thresholds', 'settings.mcpServer.title': 'MCP Server', 'settings.mcpServer.toolsSectionTitle': 'Available Tools', 'settings.mcpServer.toolsSectionDesc': diff --git a/app/test/e2e/specs/settings-advanced-config.spec.ts b/app/test/e2e/specs/settings-advanced-config.spec.ts index 5de5201cb..572e13ef1 100644 --- a/app/test/e2e/specs/settings-advanced-config.spec.ts +++ b/app/test/e2e/specs/settings-advanced-config.spec.ts @@ -102,20 +102,23 @@ describe('Settings - Advanced Config', () => { this.timeout(60_000); const before = await callOpenhumanRpc('openhuman.config_get_autonomy_settings', {}); expect(before.ok).toBe(true); + const current = before.result?.result?.max_actions_per_hour ?? 20; + // Pick a value different from the current one so the save actually mutates state. + const target = current === 250 ? 251 : 250; await navigateViaHash('/settings/autonomy'); await waitForText('Agent autonomy', 15_000); const input = await browser.$('#autonomy-max-actions'); await input.waitForExist({ timeout: 10_000 }); - await input.setValue('250'); + await input.setValue(String(target)); await clickText('Save', 10_000); await waitForText('Saved.', 10_000); await browser.waitUntil( async () => { const after = await callOpenhumanRpc('openhuman.config_get_autonomy_settings', {}); - return after.ok && after.result?.result?.max_actions_per_hour === 250; + return after.ok && after.result?.result?.max_actions_per_hour === target; }, { timeout: 15_000, interval: 500, timeoutMsg: 'autonomy setting did not persist' } ); From cf55cf7653b7232ff1840b6b20ea67103c2b6675 Mon Sep 17 00:00:00 2001 From: Chen Qian Date: Fri, 22 May 2026 12:39:30 -0700 Subject: [PATCH 21/21] chore: retrigger CI to confirm core_process test flake under llvm-cov The Rust Tauri Coverage job hit a flake in core_process::tests::ensure_running_falls_back_for_unknown_listener_on_port (file unmodified by this branch; same test passes in non-coverage sibling job).