diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index 2e7980517..f53698a12 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -46,12 +46,12 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil ### 0.3 Updates & Reinstallation -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ----- | ----------------------------- | ----- | -------------------------------------------------- | ------ | ------------------------------------- | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ----- | ----------------------------- | -------- | --------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------- | | 0.3.1 | Auto Update Check | RU+RI+MS | `src/openhuman/update/` (Rust unit), `tests/json_rpc_e2e.rs`, release smoke | 🟡 | Core check/update policy covered; desktop prompt + release upgrade still manual | -| 0.3.2 | Forced Update Handling | MS | release-manual-smoke | 🚫 | End-to-end gating verified at release | -| 0.3.3 | Reinstall with Existing State | MS | release-manual-smoke | 🚫 | Workspace persistence on reinstall | -| 0.3.4 | Clean Uninstall | MS | release-manual-smoke | 🚫 | OS removal paths | +| 0.3.2 | Forced Update Handling | MS | release-manual-smoke | 🚫 | End-to-end gating verified at release | +| 0.3.3 | Reinstall with Existing State | MS | release-manual-smoke | 🚫 | Workspace persistence on reinstall | +| 0.3.4 | Clean Uninstall | MS | release-manual-smoke | 🚫 | OS removal paths | --- @@ -118,22 +118,22 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil ### 3.1 Model Management -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ----- | ----------------------------- | ----- | -------------------------------------------------------- | ------ | ----- | -| 3.1.1 | Model Detection | RU+WD | `src/openhuman/local_ai/`, `local-model-runtime.spec.ts` | ✅ | | -| 3.1.2 | Model Download & Installation | WD | `local-model-runtime.spec.ts` | ✅ | | -| 3.1.3 | Model Version Handling | RU | `src/openhuman/local_ai/model_ids.rs` | ✅ | | -| 3.1.4 | LM Studio Model Discovery | RU+RI | `src/openhuman/local_ai/service/ollama_admin_tests.rs`, `tests/json_rpc_e2e.rs` | ✅ | Uses LM Studio's OpenAI-compatible `/v1/models` surface | -| 3.1.5 | Model Context-Window Requirement Gate | RU+VU | `src/openhuman/inference/local/model_requirements.rs`, `src/openhuman/inference/local/ollama.rs`, `src/openhuman/inference/local/service/ollama_admin_tests.rs`, `app/src/components/settings/panels/local-model/ModelStatusSection.test.tsx` | ✅ | Rejects Ollama models whose native context window is below the memory-layer minimum (`local_ai.model_context_check`) | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ----- | ------------------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------- | +| 3.1.1 | Model Detection | RU+WD | `src/openhuman/local_ai/`, `local-model-runtime.spec.ts` | ✅ | | +| 3.1.2 | Model Download & Installation | WD | `local-model-runtime.spec.ts` | ✅ | | +| 3.1.3 | Model Version Handling | RU | `src/openhuman/local_ai/model_ids.rs` | ✅ | | +| 3.1.4 | LM Studio Model Discovery | RU+RI | `src/openhuman/local_ai/service/ollama_admin_tests.rs`, `tests/json_rpc_e2e.rs` | ✅ | Uses LM Studio's OpenAI-compatible `/v1/models` surface | +| 3.1.5 | Model Context-Window Requirement Gate | RU+VU | `src/openhuman/inference/local/model_requirements.rs`, `src/openhuman/inference/local/ollama.rs`, `src/openhuman/inference/local/service/ollama_admin_tests.rs`, `app/src/components/settings/panels/local-model/ModelStatusSection.test.tsx` | ✅ | Rejects Ollama models whose native context window is below the memory-layer minimum (`local_ai.model_context_check`) | ### 3.2 Runtime Execution -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ----- | ---------------------------------- | ----- | ---------------------------------- | ------ | ----------------------------------------- | -| 3.2.1 | Local Inference Execution | WD | `local-model-runtime.spec.ts` | ✅ | | -| 3.2.2 | Resource Handling (CPU/GPU/Memory) | RU | `src/openhuman/local_ai/device.rs` | 🟡 | Detection unit; runtime constraint manual | -| 3.2.3 | Runtime Failure Handling | RU+WD | `local-model-runtime.spec.ts` | ✅ | | -| 3.2.4 | LM Studio Chat Completions | RU+RI | `src/openhuman/local_ai/service/public_infer_tests.rs`, `tests/json_rpc_e2e.rs` | ✅ | Covers prompt/chat success and non-success status errors | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ----- | ---------------------------------- | ----- | ------------------------------------------------------------------------------- | ------ | -------------------------------------------------------- | +| 3.2.1 | Local Inference Execution | WD | `local-model-runtime.spec.ts` | ✅ | | +| 3.2.2 | Resource Handling (CPU/GPU/Memory) | RU | `src/openhuman/local_ai/device.rs` | 🟡 | Detection unit; runtime constraint manual | +| 3.2.3 | Runtime Failure Handling | RU+WD | `local-model-runtime.spec.ts` | ✅ | | +| 3.2.4 | LM Studio Chat Completions | RU+RI | `src/openhuman/local_ai/service/public_infer_tests.rs`, `tests/json_rpc_e2e.rs` | ✅ | Covers prompt/chat success and non-success status errors | ### 3.3 Runtime Configuration @@ -155,12 +155,12 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil #### 3.3.3 Configuration Persistence -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ------- | ----------------- | ----- | ----------------------------- | ------ | --------------------- | -| 3.3.3.1 | Save RAM Settings | VU | _missing_ | ❌ | Settings slice | -| 3.3.3.2 | Apply on Restart | WD | `local-model-runtime.spec.ts` | 🟡 | Restart not exercised | -| 3.3.3.3 | Reset to Default | VU | _missing_ | ❌ | | -| 3.3.3.4 | Provider Selection Persistence | RU+RI+VU | `src/openhuman/config/ops_tests.rs`, `tests/json_rpc_e2e.rs`, `app/src/utils/tauriCommands/config.test.ts` | ✅ | Covers `lm_studio` normalization and config round-trip | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ------- | ------------------------------ | -------- | ---------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------ | +| 3.3.3.1 | Save RAM Settings | VU | _missing_ | ❌ | Settings slice | +| 3.3.3.2 | Apply on Restart | WD | `local-model-runtime.spec.ts` | 🟡 | Restart not exercised | +| 3.3.3.3 | Reset to Default | VU | _missing_ | ❌ | | +| 3.3.3.4 | Provider Selection Persistence | RU+RI+VU | `src/openhuman/config/ops_tests.rs`, `tests/json_rpc_e2e.rs`, `app/src/utils/tauriCommands/config.test.ts` | ✅ | Covers `lm_studio` normalization and config round-trip | --- @@ -176,24 +176,24 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil ### 4.2 Messaging -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ----- | ---------------------- | ----- | ----------------------------------------------------------------- | ------ | --------------------------- | -| 4.2.1 | User Message Handling | WD+RI | `conversations-web-channel-flow.spec.ts`, `tests/json_rpc_e2e.rs` | ✅ | | -| 4.2.2 | AI Response Generation | WD | `agent-review.spec.ts` | ✅ | Mock LLM | -| 4.2.3 | Streaming Responses | RI | `tests/json_rpc_e2e.rs` | 🟡 | UI streaming assertion thin | -| 4.2.4 | Parallel inference (cross-thread + within-thread forked turns) | RU+VU | `src/openhuman/channels/providers/web_tests.rs`, `app/src/store/__tests__/chatRuntimeSlice.test.ts`, `app/src/providers/__tests__/ChatRuntimeProvider.test.tsx` | 🟡 | Concurrent same-/cross-thread dispatch, cooperative `CancellationToken` teardown, and parallel-lane stream routing covered; dedicated WD E2E is a follow-up | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ----- | -------------------------------------------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 4.2.1 | User Message Handling | WD+RI | `conversations-web-channel-flow.spec.ts`, `tests/json_rpc_e2e.rs` | ✅ | | +| 4.2.2 | AI Response Generation | WD | `agent-review.spec.ts` | ✅ | Mock LLM | +| 4.2.3 | Streaming Responses | RI | `tests/json_rpc_e2e.rs` | 🟡 | UI streaming assertion thin | +| 4.2.4 | Parallel inference (cross-thread + within-thread forked turns) | RU+VU | `src/openhuman/channels/providers/web_tests.rs`, `app/src/store/__tests__/chatRuntimeSlice.test.ts`, `app/src/providers/__tests__/ChatRuntimeProvider.test.tsx` | 🟡 | Concurrent same-/cross-thread dispatch, cooperative `CancellationToken` teardown, and parallel-lane stream routing covered; dedicated WD E2E is a follow-up | ### 4.3 Tool Invocation -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ----- | -------------------------- | ----- | ----------------------------------------------------------- | ------ | ----- | -| 4.3.1 | Tool Trigger via Chat | WD | `skill-execution-flow.spec.ts`, `skill-multi-round.spec.ts` | ✅ | | -| 4.3.2 | Permission-Based Execution | RU+WD | `src/openhuman/tools/`, `skill-execution-flow.spec.ts` | ✅ | | -| 4.3.3 | Tool Failure Handling | WD | `skill-execution-flow.spec.ts` | ✅ | | -| 4.3.4 | Subagent Mascot Visualization | VU | `app/src/features/human/SubMascotLayer.test.tsx`, `app/src/features/human/HumanPage.test.tsx` | ✅ | Renders spawned/completed/failed subagent timeline rows as colored companion mascots with activity bubbles | -| 4.3.5 | Image Tool Contracts | RU | `src/openhuman/image/` | ✅ | High-level `image_generation` / `view_image` schema, gating, serialization, prompt guidance, and contract e2e coverage for #2984 | -| 4.3.6 | Background Monitor Tools | RU+RI | `src/openhuman/monitor/`, `src/openhuman/tools/ops_tests.rs`, `tests/json_rpc_e2e.rs` | ✅ | First-class monitor domain covers command denial, line streaming, timeout, stop, bounded output, registry exposure, and JSON-RPC list/read surface for #3371 | -| 4.3.7 | Mascot Avatar Animation | VU | `app/src/features/human/Mascot/RiveMascot.test.tsx`, `app/src/features/human/Mascot/riveMaps.test.ts` | ✅ | Rive `MascotSM` state machine: face→pose mapping, Oculus→`visme_codes` viseme normalization, and idle random pose rotation for the `tiny_mascot.riv` upgrade | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ----- | ----------------------------- | ----- | ----------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 4.3.1 | Tool Trigger via Chat | WD | `skill-execution-flow.spec.ts`, `skill-multi-round.spec.ts` | ✅ | | +| 4.3.2 | Permission-Based Execution | RU+WD | `src/openhuman/tools/`, `skill-execution-flow.spec.ts` | ✅ | | +| 4.3.3 | Tool Failure Handling | WD | `skill-execution-flow.spec.ts` | ✅ | | +| 4.3.4 | Subagent Mascot Visualization | VU | `app/src/features/human/SubMascotLayer.test.tsx`, `app/src/features/human/HumanPage.test.tsx` | ✅ | Renders spawned/completed/failed subagent timeline rows as colored companion mascots with activity bubbles | +| 4.3.5 | Image Tool Contracts | RU | `src/openhuman/image/` | ✅ | High-level `image_generation` / `view_image` schema, gating, serialization, prompt guidance, and contract e2e coverage for #2984 | +| 4.3.6 | Background Monitor Tools | RU+RI | `src/openhuman/monitor/`, `src/openhuman/tools/ops_tests.rs`, `tests/json_rpc_e2e.rs` | ✅ | First-class monitor domain covers command denial, line streaming, timeout, stop, bounded output, registry exposure, and JSON-RPC list/read surface for #3371 | +| 4.3.7 | Mascot Avatar Animation | VU | `app/src/features/human/Mascot/RiveMascot.test.tsx`, `app/src/features/human/Mascot/riveMaps.test.ts` | ✅ | Rive `MascotSM` state machine: face→pose mapping, Oculus→`visme_codes` viseme normalization, and idle random pose rotation for the `tiny_mascot.riv` upgrade | --- @@ -201,36 +201,36 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil ### 5.1 Screen Intelligence -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ----- | ------------------ | ----- | ------------------------------------------------------------------------ | ------ | ----- | -| 5.1.1 | Screen Capture | RI | `tests/screen_intelligence_vision_e2e.rs` | ✅ | | -| 5.1.2 | Context Extraction | RI | `tests/screen_intelligence_vision_e2e.rs` | ✅ | | -| 5.1.3 | Memory Injection | RI | `tests/memory_graph_sync_e2e.rs` | ✅ | | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ----- | ------------------ | ----- | ----------------------------------------- | ------ | ----- | +| 5.1.1 | Screen Capture | RI | `tests/screen_intelligence_vision_e2e.rs` | ✅ | | +| 5.1.2 | Context Extraction | RI | `tests/screen_intelligence_vision_e2e.rs` | ✅ | | +| 5.1.3 | Memory Injection | RI | `tests/memory_graph_sync_e2e.rs` | ✅ | | ### 5.2 Text Autocomplete -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ----- | ---------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------- | -| 5.2.1 | Inline Suggestion Generation | MS+WD | `app/test/e2e/specs/autocomplete-flow.spec.ts` (settings surface only); release-manual-smoke for real inline-gen | 🟡 | Settings panel mounts; inline-gen requires macOS TCC grants — manual only | -| 5.2.2 | Debounce Handling | VU | `app/src/features/autocomplete/__tests__/useAutocompleteSkillStatus.test.tsx` (status surface); core debounce timing is Rust-side | ✅ | Was ❌ — status branches now covered | -| 5.2.3 | Acceptance Trigger | MS | release-manual-smoke (#971) | 🟡 | Real keypress acceptance into a third-party text field — not driver-automatable | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ----- | ---------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------- | +| 5.2.1 | Inline Suggestion Generation | MS+WD | `app/test/e2e/specs/autocomplete-flow.spec.ts` (settings surface only); release-manual-smoke for real inline-gen | 🟡 | Settings panel mounts; inline-gen requires macOS TCC grants — manual only | +| 5.2.2 | Debounce Handling | VU | `app/src/features/autocomplete/__tests__/useAutocompleteSkillStatus.test.tsx` (status surface); core debounce timing is Rust-side | ✅ | Was ❌ — status branches now covered | +| 5.2.3 | Acceptance Trigger | MS | release-manual-smoke (#971) | 🟡 | Real keypress acceptance into a third-party text field — not driver-automatable | ### 5.3 Voice Intelligence -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ----- | ------------------------- | ----- | -------------------- | ------ | ----- | -| 5.3.1 | Voice Input Capture | WD | `voice-mode.spec.ts` | ✅ | | -| 5.3.2 | Speech-to-Text Processing | WD | `voice-mode.spec.ts` | ✅ | | -| 5.3.3 | Voice Command Execution | WD | `voice-mode.spec.ts` | ✅ | | -| 5.3.4 | Mascot Voice Selection | VU | `app/src/store/__tests__/mascotSlice.test.ts`, `app/src/components/settings/panels/__tests__/VoicePanel.test.tsx`, `app/src/features/human/useHumanMascot.test.ts` | ✅ | Slice validation + persist REHYDRATE, Settings picker UI (#1762), `synthesizeSpeech` voiceId override propagation | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ----- | ------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | ----------------------------------------------------------------------------------------------------------------- | +| 5.3.1 | Voice Input Capture | WD | `voice-mode.spec.ts` | ✅ | | +| 5.3.2 | Speech-to-Text Processing | WD | `voice-mode.spec.ts` | ✅ | | +| 5.3.3 | Voice Command Execution | WD | `voice-mode.spec.ts` | ✅ | | +| 5.3.4 | Mascot Voice Selection | VU | `app/src/store/__tests__/mascotSlice.test.ts`, `app/src/components/settings/panels/__tests__/VoicePanel.test.tsx`, `app/src/features/human/useHumanMascot.test.ts` | ✅ | Slice validation + persist REHYDRATE, Settings picker UI (#1762), `synthesizeSpeech` voiceId override propagation | ### 5.4 Persona -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ----- | ----------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------- | -| 5.4.1 | Persona Name & Description | VU | `app/src/store/personaSlice.test.ts`, `app/src/components/settings/panels/PersonaPanel.test.tsx` | ✅ | Slice validation + persist REHYDRATE scrub; Settings identity fields persist on save (#2345) | -| 5.4.2 | SOUL.md Edit & Reset | RU+VU | `src/openhuman/workspace/rpc.rs`, `app/src/components/settings/panels/PersonaPanel.test.tsx` | ✅ | Core read/write/reset with allowlist + size cap; panel loads, saves, resets over RPC (#2345) | -| 5.4.3 | Persona Settings Surface | VU | `app/src/components/settings/panels/PersonaPanel.test.tsx` | ✅ | Bundles identity + SOUL.md + link to Mascot avatar/voice (#2345) | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ----- | -------------------------- | ----- | ------------------------------------------------------------------------------------------------ | ------ | -------------------------------------------------------------------------------------------- | +| 5.4.1 | Persona Name & Description | VU | `app/src/store/personaSlice.test.ts`, `app/src/components/settings/panels/PersonaPanel.test.tsx` | ✅ | Slice validation + persist REHYDRATE scrub; Settings identity fields persist on save (#2345) | +| 5.4.2 | SOUL.md Edit & Reset | RU+VU | `src/openhuman/workspace/rpc.rs`, `app/src/components/settings/panels/PersonaPanel.test.tsx` | ✅ | Core read/write/reset with allowlist + size cap; panel loads, saves, resets over RPC (#2345) | +| 5.4.3 | Persona Settings Surface | VU | `app/src/components/settings/panels/PersonaPanel.test.tsx` | ✅ | Bundles identity + SOUL.md + link to Mascot avatar/voice (#2345) | --- @@ -238,39 +238,47 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil ### 6.1 File System -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ----- | ---------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------- | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ----- | ---------------------------- | ----- | ------------------------------------------------------------------------------------------------------ | ------ | -------------------------------------------------------------------- | | 6.1.1 | File Read Access | RU+WD | `src/openhuman/tools/impl/filesystem/file_read.rs`, `app/test/e2e/specs/tool-filesystem-flow.spec.ts` | ✅ | Was 🟡 — WDIO drives memory_read_file + asserts via Node fs | | 6.1.2 | File Write Access | RU+WD | `src/openhuman/tools/impl/filesystem/file_write.rs`, `app/test/e2e/specs/tool-filesystem-flow.spec.ts` | ✅ | Was 🟡 — WDIO drives memory_write_file + asserts bytes match on disk | | 6.1.3 | Path Restriction Enforcement | RU+WD | `src/openhuman/tools/impl/filesystem/file_read.rs`, `app/test/e2e/specs/tool-filesystem-flow.spec.ts` | ✅ | Was 🟡 — WDIO asserts traversal + absolute-path denial envelope | ### 6.2 Shell & Git -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ----- | ---------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------ | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ----- | ---------------------------- | ----- | --------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------ | | 6.2.1 | Shell Command Execution | RU+WD | `src/openhuman/tools/impl/system/shell.rs`, `app/test/e2e/specs/tool-shell-git-flow.spec.ts` | ✅ | Was 🟡 — WDIO asserts agent runtime + `tools_agent` registry contract; full LLM path tracked #68 | | 6.2.2 | Command Restriction Handling | RU+WD | `src/openhuman/security/policy_tests.rs`, `app/test/e2e/specs/tool-shell-git-flow.spec.ts` | ✅ | Was 🟡 — WDIO locks denial envelope shape `{ ok:false, error }` consumed by the React UI | | 6.2.3 | Git Read Operations | RU+WD | `src/openhuman/tools/impl/filesystem/git_operations_tests.rs`, `app/test/e2e/specs/tool-shell-git-flow.spec.ts` | ✅ | Was 🟡 — WDIO seeds a fixture repo in OPENHUMAN_WORKSPACE and asserts read ops succeed | | 6.2.4 | Git Write Operations | RU+WD | `src/openhuman/tools/impl/filesystem/git_operations_tests.rs`, `app/test/e2e/specs/tool-shell-git-flow.spec.ts` | ✅ | Was 🟡 — WDIO commits into the same fixture and asserts log advances | +### 6.3 Sub-agent Orchestration + +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ----- | ---------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| 6.3.1 | Steer a running sub-agent | RU | `src/openhuman/agent_orchestration/running_subagents.rs`, `src/openhuman/agent_orchestration/tools/steer_subagent.rs` | ✅ | `steer_subagent` injects a steer/collect message into a running async sub-agent's run-queue; registry enforces parent ownership + terminal guard. | +| 6.3.2 | Wait for a sub-agent result | RU | `src/openhuman/agent_orchestration/running_subagents.rs`, `src/openhuman/agent_orchestration/tools/wait_subagent.rs` | ✅ | `wait_subagent` blocks on the completion `watch` with a timeout; prunes terminal entries, leaves entries intact on timeout. | +| 6.3.3 | Steer lands in child history | RU | `src/openhuman/agent/harness/subagent_runner/ops_tests.rs::run_queue_steer_lands_in_subagent_history` | ✅ | End-to-end: a queued steer is drained by the child `run_turn_engine` and appears as a `[User steering message]` user turn in the provider request. | + --- ## 7. Web & Network Capabilities ### 7.1 Browser -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ----- | ------------------ | ----- | ------------------------------------------------------------------------------------------------------------------ | ------ | --------------------------------------------------------------------------------------------------- | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ----- | ------------------ | ----- | -------------------------------------------------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------- | | 7.1.1 | Open URL | RU+WD | `src/openhuman/tools/impl/browser/browser_open_tests.rs`, `app/test/e2e/specs/tool-browser-flow.spec.ts` | ✅ | Was ❌ — WDIO asserts agent runtime + browser-bearing registry; mock backend captures HTTP shape | | 7.1.2 | Browser Automation | RU+WD | `src/openhuman/tools/impl/browser/browser_tests.rs`, `app/test/e2e/specs/tool-browser-flow.spec.ts` | ✅ | Was ❌ — WDIO locks tools_agent wildcard scope (exposes the 22-action automation schema to the LLM) | ### 7.2 Network -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ----- | -------------------- | ----- | ----------------------------------- | ------ | ------------------ | -| 7.2.1 | HTTP / API Requests | RU+WD | `service-connectivity-flow.spec.ts` | ✅ | | -| 7.2.2 | Web Search Execution | WD | `skill-execution-flow.spec.ts` | 🟡 | Generic skill path | -| 7.2.3 | TinyFish Integration Tools | RU | `src/openhuman/integrations/tinyfish_tests.rs`, `src/openhuman/tools/ops_tests.rs::all_tools_executes_tinyfish_family_against_fake_backend` | ✅ | Backend-proxied Search, Fetch, and Agent run tools covered with fake backend | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ----- | -------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------- | +| 7.2.1 | HTTP / API Requests | RU+WD | `service-connectivity-flow.spec.ts` | ✅ | | +| 7.2.2 | Web Search Execution | WD | `skill-execution-flow.spec.ts` | 🟡 | Generic skill path | +| 7.2.3 | TinyFish Integration Tools | RU | `src/openhuman/integrations/tinyfish_tests.rs`, `src/openhuman/tools/ops_tests.rs::all_tools_executes_tinyfish_family_against_fake_backend` | ✅ | Backend-proxied Search, Fetch, and Agent run tools covered with fake backend | --- @@ -278,41 +286,41 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil ### 8.1 Memory Operations -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ----- | ------------- | ----- | -------------------------------------------------------------------------------------------------- | ------ | ------ | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ----- | ------------- | ----- | ------------------------------------------------------------------------------ | ------ | ------ | | 8.1.1 | Store Memory | RI+WD | `tests/memory_roundtrip_e2e.rs`, `app/test/e2e/specs/memory-roundtrip.spec.ts` | ✅ | Was ❌ | -| 8.1.2 | Recall Memory | RI+WD | same | ✅ | Was ❌ | -| 8.1.3 | Forget Memory | RI+WD | same | ✅ | Was ❌ | +| 8.1.2 | Recall Memory | RI+WD | same | ✅ | Was ❌ | +| 8.1.3 | Forget Memory | RI+WD | same | ✅ | Was ❌ | ### 8.2 Memory Handling -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ----- | ------------------ | ----- | ----------------------------------------- | ------ | --------------------------------- | -| 8.2.1 | Context Injection | RI | `tests/autocomplete_memory_e2e.rs` | ✅ | | -| 8.2.2 | Memory Consistency | RI | `tests/memory_graph_sync_e2e.rs`, `tests/worker_c_modules_e2e.rs` | ✅ | Worker C RPC E2E verifies memory-tree ingest is reflected by `memory_sync_status_list` | -| 8.2.3 | Memory Scaling | RU | `src/openhuman/memory/ingestion_tests.rs` | 🟡 | Soak/scale benchmark not asserted | -| 8.2.4 | Raw-archive sync reconcile | RU+RI | `src/openhuman/memory_sync/sources/rebuild.rs`, `src/openhuman/memory_sync/workspace/periodic.rs`, `tests/json_rpc_e2e.rs` (`json_rpc_memory_sources_reconcile_reports_pending_raw_files`), `tests/memory_sync_pipeline_e2e.rs` | ✅ | Coverage gate + incremental rebuild + workspace periodic scheduler + `memory_sources_reconcile` RPC | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ----- | -------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------- | +| 8.2.1 | Context Injection | RI | `tests/autocomplete_memory_e2e.rs` | ✅ | | +| 8.2.2 | Memory Consistency | RI | `tests/memory_graph_sync_e2e.rs`, `tests/worker_c_modules_e2e.rs` | ✅ | Worker C RPC E2E verifies memory-tree ingest is reflected by `memory_sync_status_list` | +| 8.2.3 | Memory Scaling | RU | `src/openhuman/memory/ingestion_tests.rs` | 🟡 | Soak/scale benchmark not asserted | +| 8.2.4 | Raw-archive sync reconcile | RU+RI | `src/openhuman/memory_sync/sources/rebuild.rs`, `src/openhuman/memory_sync/workspace/periodic.rs`, `tests/json_rpc_e2e.rs` (`json_rpc_memory_sources_reconcile_reports_pending_raw_files`), `tests/memory_sync_pipeline_e2e.rs` | ✅ | Coverage gate + incremental rebuild + workspace periodic scheduler + `memory_sources_reconcile` RPC | ### 8.3 Memory Retrieval Benchmarks -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ----- | ---------------------------------------- | ----- | ---------------------------------------------------------------------------------- | ------ | ----- | -| 8.3.2 | Cross-Chat Entity Discoverability | RU | `src/openhuman/memory/tree/retrieval/benchmarks.rs::bench_cross_chat_entity_discoverable` | ✅ | Verifies entity canonicalisation across multiple chats | -| 8.3.3 | Citation Bundle Provenance | RU | `src/openhuman/memory/tree/retrieval/benchmarks.rs::bench_citation_bundle_provenance` | ✅ | Verifies source_ref and tree_scope are populated in retrieval hits | -| 8.3.4 | Citation Fetch Leaves Hydration | RU | `src/openhuman/memory/tree/retrieval/benchmarks.rs::bench_citation_fetch_leaves_hydrates` | ✅ | Verifies fetch_leaves returns content for exact chunk IDs | -| 8.3.7 | Long-Source Exact Leaf Retrieval | RU | `src/openhuman/memory/tree/retrieval/benchmarks.rs::bench_long_source_retrieves_exact_leaf` | 🟡 | Embedder required for seal + chunking; test runs in inert mode but assertions are conditional | -| 8.3.9 | Scale Ingest 20 Sources No Real Data | RU | `src/openhuman/memory/tree/retrieval/benchmarks.rs::bench_scale_ingest_20_sources_no_real_data` | ✅ | Verifies retrieval correctness at scale with synthetic data | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ----- | ------------------------------------ | ----- | ----------------------------------------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------- | +| 8.3.2 | Cross-Chat Entity Discoverability | RU | `src/openhuman/memory/tree/retrieval/benchmarks.rs::bench_cross_chat_entity_discoverable` | ✅ | Verifies entity canonicalisation across multiple chats | +| 8.3.3 | Citation Bundle Provenance | RU | `src/openhuman/memory/tree/retrieval/benchmarks.rs::bench_citation_bundle_provenance` | ✅ | Verifies source_ref and tree_scope are populated in retrieval hits | +| 8.3.4 | Citation Fetch Leaves Hydration | RU | `src/openhuman/memory/tree/retrieval/benchmarks.rs::bench_citation_fetch_leaves_hydrates` | ✅ | Verifies fetch_leaves returns content for exact chunk IDs | +| 8.3.7 | Long-Source Exact Leaf Retrieval | RU | `src/openhuman/memory/tree/retrieval/benchmarks.rs::bench_long_source_retrieves_exact_leaf` | 🟡 | Embedder required for seal + chunking; test runs in inert mode but assertions are conditional | +| 8.3.9 | Scale Ingest 20 Sources No Real Data | RU | `src/openhuman/memory/tree/retrieval/benchmarks.rs::bench_scale_ingest_20_sources_no_real_data` | ✅ | Verifies retrieval correctness at scale with synthetic data | ### 8.4 Explicit User Preferences (Two-Lane) -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ----- | ------------------------------------------ | ----- | ----------------------------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------- | -| 8.4.1 | Save Preference (general / situational) | RU | `src/openhuman/agent/tools/save_preference_tests.rs` | ✅ | `save_preference` tool → `user_pref_{general,situational}`, topic-keyed | -| 8.4.2 | Lane A — Standing Prefs in System Prompt | RU | `src/openhuman/learning/prompt_sections.rs`, `src/openhuman/agent/harness/session/turn_tests.rs` | ✅ | General prefs rendered into the system prompt at thread start | -| 8.4.3 | Lane B — Situational Recall (vector-gated) | RU | `src/openhuman/memory/store/unified/query_tests.rs::recall_relevant_by_vector_gates_on_similarity` | ✅ | Per-turn; relevant query injects, unrelated suppresses | -| 8.4.4 | Same-Topic Contradiction (replace) | RU | `src/openhuman/agent/tools/save_preference_tests.rs::recategorising_moves_pref_between_namespaces` | ✅ | `ON CONFLICT REPLACE`; a topic lives in exactly one scope | -| 8.4.5 | Cross-Topic Contradiction Surfacing | RU | `src/openhuman/agent/tools/save_preference_tests.rs::save_surfaces_related_preference_for_contradiction_check` | ✅ | Related prefs surfaced in the tool result for the chat agent to resolve | -| 8.4.6 | vector_chunks Model-Signature Recall Guard | RU | `src/openhuman/memory/store/unified/query_tests.rs::vector_recall_excludes_other_model_signature` | ✅ | Excludes cross-model vectors; dim-guards legacy rows | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ----- | ------------------------------------------ | ----- | -------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------- | +| 8.4.1 | Save Preference (general / situational) | RU | `src/openhuman/agent/tools/save_preference_tests.rs` | ✅ | `save_preference` tool → `user_pref_{general,situational}`, topic-keyed | +| 8.4.2 | Lane A — Standing Prefs in System Prompt | RU | `src/openhuman/learning/prompt_sections.rs`, `src/openhuman/agent/harness/session/turn_tests.rs` | ✅ | General prefs rendered into the system prompt at thread start | +| 8.4.3 | Lane B — Situational Recall (vector-gated) | RU | `src/openhuman/memory/store/unified/query_tests.rs::recall_relevant_by_vector_gates_on_similarity` | ✅ | Per-turn; relevant query injects, unrelated suppresses | +| 8.4.4 | Same-Topic Contradiction (replace) | RU | `src/openhuman/agent/tools/save_preference_tests.rs::recategorising_moves_pref_between_namespaces` | ✅ | `ON CONFLICT REPLACE`; a topic lives in exactly one scope | +| 8.4.5 | Cross-Topic Contradiction Surfacing | RU | `src/openhuman/agent/tools/save_preference_tests.rs::save_surfaces_related_preference_for_contradiction_check` | ✅ | Related prefs surfaced in the tool result for the chat agent to resolve | +| 8.4.6 | vector_chunks Model-Signature Recall Guard | RU | `src/openhuman/memory/store/unified/query_tests.rs::vector_recall_excludes_other_model_signature` | ✅ | Excludes cross-model vectors; dim-guards legacy rows | --- @@ -347,30 +355,30 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil ### 10.1 Integration Setup -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ------ | ------------------- | ----- | ---------------------------------------------------- | ------ | ------ | -| 10.1.1 | Telegram Connection | WD | `telegram-flow.spec.ts` | ✅ | | -| 10.1.2 | WhatsApp Connection | WD | `app/test/e2e/specs/whatsapp-flow.spec.ts` | ✅ | Was ❌ | -| 10.1.3 | Gmail Connection | WD | `gmail-flow.spec.ts` | ✅ | | -| 10.1.4 | Slack Connection | WD | `app/test/e2e/specs/slack-flow.spec.ts` | ✅ | Was ❌ | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ------ | ------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 10.1.1 | Telegram Connection | WD | `telegram-flow.spec.ts` | ✅ | | +| 10.1.2 | WhatsApp Connection | WD | `app/test/e2e/specs/whatsapp-flow.spec.ts` | ✅ | Was ❌ | +| 10.1.3 | Gmail Connection | WD | `gmail-flow.spec.ts` | ✅ | | +| 10.1.4 | Slack Connection | WD | `app/test/e2e/specs/slack-flow.spec.ts` | ✅ | Was ❌ | | 10.1.5 | Yuanbao Connection | RU | `src/openhuman/channels/providers/yuanbao/`, `src/openhuman/channels/controllers/ops.rs::tests::connect_yuanbao_*`, `src/openhuman/channels/runtime/startup.rs::yuanbao_secret_tests` | 🟡 | New API-key channel for Tencent Yuanbao. RU covers sign-token preflight (valid/invalid creds, env-override cluster routing), credentials store hydration (incl. stale app_key guard), and WS reconnect/shutdown. No WDIO spec yet — connect-flow UI is rendered via the generic `ChannelSetupModal` already exercised by other channel flow specs. | ### 10.2 Authentication & Authorization -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ------ | ------------------------------------- | ----- | --------------------------------------------------------- | ------ | --------------------------------- | -| 10.2.1 | OAuth / API Token Handling | WD | `skill-oauth.spec.ts` | ✅ | | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ------ | ------------------------------------- | ----- | ----------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------- | +| 10.2.1 | OAuth / API Token Handling | WD | `skill-oauth.spec.ts` | ✅ | | | 10.2.2 | Scope Selection (Read/Write/Initiate) | WD | `gmail-flow.spec.ts`, `skill-oauth.spec.ts`, `composio-triggers-flow.spec.ts` | 🟡 | Multi-scope matrix not exhaustive; Gmail trigger OAuth read scope covered | -| 10.2.3 | Token Storage & Encryption | RU | `src/openhuman/encryption/`, `src/openhuman/credentials/` | ✅ | | +| 10.2.3 | Token Storage & Encryption | RU | `src/openhuman/encryption/`, `src/openhuman/credentials/` | ✅ | | ### 10.3 Message Sync & Ingestion -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ------ | ------------------------- | ----- | ----------------------------------------------------- | ------ | ----- | -| 10.3.1 | Incoming Message Sync | RU+WD | `src/openhuman/channels/tests/`, `gmail-flow.spec.ts` | ✅ | | -| 10.3.2 | Message Deduplication | RU | `src/openhuman/channels/tests/` | ✅ | | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ------ | ------------------------- | ----- | ------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| 10.3.1 | Incoming Message Sync | RU+WD | `src/openhuman/channels/tests/`, `gmail-flow.spec.ts` | ✅ | | +| 10.3.2 | Message Deduplication | RU | `src/openhuman/channels/tests/` | ✅ | | | 10.3.3 | WhatsApp Agent Retrieval | RU | `src/openhuman/whatsapp_data/tools/`, `tests/json_rpc_e2e.rs::whatsapp_data_agent_tools_e2e_1341` | ✅ | Three read-only agent tools wrap the local SQLite store; ingest stays internal-only. See [`docs/whatsapp-data-flow.md`](whatsapp-data-flow.md). | -| 10.3.4 | Real-Time vs Delayed Sync | RU | `src/openhuman/channels/tests/runtime_dispatch.rs` | ✅ | | +| 10.3.4 | Real-Time vs Delayed Sync | RU | `src/openhuman/channels/tests/runtime_dispatch.rs` | ✅ | | ### 10.4 Messaging Operations @@ -383,11 +391,11 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil ### 10.5 Cross-Channel Behavior -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ------ | ---------------------- | ----- | ------------------------------------------ | ------ | -------------------- | -| 10.5.1 | Channel Isolation | RU | `src/openhuman/channels/tests/identity.rs` | ✅ | | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ------ | ---------------------- | ----- | --------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------- | +| 10.5.1 | Channel Isolation | RU | `src/openhuman/channels/tests/identity.rs` | ✅ | | | 10.5.2 | Unified Inbox Handling | WD+RI | `channels-smoke.spec.ts`, `tests/worker_c_modules_e2e.rs` | 🟡 | UI assertion shallow; RI covers config-only channel status after connect/disconnect | -| 10.5.3 | Context Preservation | RU | `src/openhuman/channels/tests/context.rs` | ✅ | | +| 10.5.3 | Context Preservation | RU | `src/openhuman/channels/tests/context.rs` | ✅ | | ### 10.6 Permission Enforcement @@ -399,12 +407,12 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil ### 10.7 Disconnect & Re-Setup -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ------ | ---------------------- | ----- | ------------------------------------------- | ------ | -------------------------------- | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ------ | ---------------------- | ----- | ----------------------------------------------------- | ------ | ------------------------------------------------------------------- | | 10.7.1 | Integration Disconnect | WD+RI | `gmail-flow.spec.ts`, `tests/worker_c_modules_e2e.rs` | ✅ | RI covers `channels_disconnect` clearing config-only iMessage state | -| 10.7.2 | Token Revocation | RU | `src/openhuman/credentials/` | ✅ | | -| 10.7.3 | Re-Authorization Flow | WD | `skill-oauth.spec.ts` | 🟡 | Re-auth post-revoke not asserted | -| 10.7.4 | Permission Re-Sync | WD | _missing_ — tracked #968 | ❌ | | +| 10.7.2 | Token Revocation | RU | `src/openhuman/credentials/` | ✅ | | +| 10.7.3 | Re-Authorization Flow | WD | `skill-oauth.spec.ts` | 🟡 | Re-auth post-revoke not asserted | +| 10.7.4 | Permission Re-Sync | WD | _missing_ — tracked #968 | ❌ | | --- @@ -412,26 +420,26 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil ### 11.1 Analysis Engine -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ------ | -------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------- | -| 11.1.1 | Multi-Source Analysis | RI | `tests/memory_graph_sync_e2e.rs` | 🟡 | Frontend trigger untested | -| 11.1.2 | Actionable Item Extraction | VU | `app/src/components/intelligence/__tests__/utils.test.ts` | ✅ | Was ❌ | -| 11.1.3 | Analyze Trigger | WD | `app/test/e2e/specs/insights-dashboard.spec.ts` mounts the route; explicit analyze-handler invocation TBD | 🟡 | Route mounts and search/filter UI assert — full analyze trigger flow tracked as follow-up | -| 11.1.4 | MCP server (stdio + HTTP) | RU | `src/openhuman/mcp_server/` | ✅ | Stdio framing plus Streamable HTTP/SSE session lifecycle; `McpHttpClient` round-trip tests | -| 11.1.5 | Global tool registry | RI | `src/openhuman/tool_registry/`, `tests/json_rpc_e2e.rs`, `tests/domain_modules_e2e.rs`, `tests/worker_b_domain_e2e.rs` | ✅ | Read-only MCP/controller discovery with routes, schemas, version, allowed agents, and health | -| 11.1.6 | SearXNG MCP search | RU | `src/openhuman/integrations/searxng.rs`, `src/openhuman/mcp_server/tools.rs`, `src/openhuman/tools/schemas.rs` | ✅ | Self-hosted search config, normalized results, MCP argument validation, and mocked HTTP execution | -| 11.1.7 | Bundled prompt resources | RU | `src/openhuman/mcp_server/resources.rs`, `src/openhuman/mcp_server/protocol.rs` | ✅ | `resources/list` catalog + `resources/read` happy path, -32002 unknown URI, -32602 missing param, catalog-mirrors-BUILTINS parity test | -| 11.1.8 | Resource templates list | RU | `src/openhuman/mcp_server/resources.rs`, `src/openhuman/mcp_server/protocol.rs` | ✅ | `resources/templates/list` returns `{resourceTemplates: []}` (static catalog), tolerates unknown/cursor params | -| 11.1.10 | MCP registry install→connect→tool_call | RI | `tests/json_rpc_e2e.rs` (`mcp_clients_install_connect_tool_call_happy_path`), `tests/mcp_registry_e2e.rs`, `src/openhuman/mcp_registry/setup_ops.rs` (#3039) | ✅ | HTTP-RPC happy path install→connect→tool_call→update_env against `test-mcp-stub`; transport-aware install (stdio + http_remote) via `build_install_transport` | -| 11.1.11 | MCP env reconfigure + registry creds | RI/VU | `tests/json_rpc_e2e.rs` (`mcp_clients_registry_settings_roundtrip`), `src/openhuman/mcp_registry/registries/mcp_official.rs`, `app/src/components/channels/mcp/InstalledServerDetail.test.tsx` (#3039) | ✅ | `update_env` persist+reconnect; `registry_settings` get/set with secrets write-only (config-first, env-fallback); reconfigure form validation | -| 11.1.12 | MCP UI surface + setup-agent client | VU | `app/src/components/channels/mcp/InstallDialog.test.tsx`, `app/src/services/api/mcpClientsApi.test.ts`, `app/src/services/api/mcpSetupApi.test.ts` (#3039) | ✅ | Skills `?tab=mcp` renders `McpServersTab` (not Coming Soon); auto-connect on install (best-effort); typed `mcpSetupApi` wrapper | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ------- | -------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 11.1.1 | Multi-Source Analysis | RI | `tests/memory_graph_sync_e2e.rs` | 🟡 | Frontend trigger untested | +| 11.1.2 | Actionable Item Extraction | VU | `app/src/components/intelligence/__tests__/utils.test.ts` | ✅ | Was ❌ | +| 11.1.3 | Analyze Trigger | WD | `app/test/e2e/specs/insights-dashboard.spec.ts` mounts the route; explicit analyze-handler invocation TBD | 🟡 | Route mounts and search/filter UI assert — full analyze trigger flow tracked as follow-up | +| 11.1.4 | MCP server (stdio + HTTP) | RU | `src/openhuman/mcp_server/` | ✅ | Stdio framing plus Streamable HTTP/SSE session lifecycle; `McpHttpClient` round-trip tests | +| 11.1.5 | Global tool registry | RI | `src/openhuman/tool_registry/`, `tests/json_rpc_e2e.rs`, `tests/domain_modules_e2e.rs`, `tests/worker_b_domain_e2e.rs` | ✅ | Read-only MCP/controller discovery with routes, schemas, version, allowed agents, and health | +| 11.1.6 | SearXNG MCP search | RU | `src/openhuman/integrations/searxng.rs`, `src/openhuman/mcp_server/tools.rs`, `src/openhuman/tools/schemas.rs` | ✅ | Self-hosted search config, normalized results, MCP argument validation, and mocked HTTP execution | +| 11.1.7 | Bundled prompt resources | RU | `src/openhuman/mcp_server/resources.rs`, `src/openhuman/mcp_server/protocol.rs` | ✅ | `resources/list` catalog + `resources/read` happy path, -32002 unknown URI, -32602 missing param, catalog-mirrors-BUILTINS parity test | +| 11.1.8 | Resource templates list | RU | `src/openhuman/mcp_server/resources.rs`, `src/openhuman/mcp_server/protocol.rs` | ✅ | `resources/templates/list` returns `{resourceTemplates: []}` (static catalog), tolerates unknown/cursor params | +| 11.1.10 | MCP registry install→connect→tool_call | RI | `tests/json_rpc_e2e.rs` (`mcp_clients_install_connect_tool_call_happy_path`), `tests/mcp_registry_e2e.rs`, `src/openhuman/mcp_registry/setup_ops.rs` (#3039) | ✅ | HTTP-RPC happy path install→connect→tool_call→update_env against `test-mcp-stub`; transport-aware install (stdio + http_remote) via `build_install_transport` | +| 11.1.11 | MCP env reconfigure + registry creds | RI/VU | `tests/json_rpc_e2e.rs` (`mcp_clients_registry_settings_roundtrip`), `src/openhuman/mcp_registry/registries/mcp_official.rs`, `app/src/components/channels/mcp/InstalledServerDetail.test.tsx` (#3039) | ✅ | `update_env` persist+reconnect; `registry_settings` get/set with secrets write-only (config-first, env-fallback); reconfigure form validation | +| 11.1.12 | MCP UI surface + setup-agent client | VU | `app/src/components/channels/mcp/InstallDialog.test.tsx`, `app/src/services/api/mcpClientsApi.test.ts`, `app/src/services/api/mcpSetupApi.test.ts` (#3039) | ✅ | Skills `?tab=mcp` renders `McpServersTab` (not Coming Soon); auto-connect on install (best-effort); typed `mcpSetupApi` wrapper | ### 11.2 Insights Dashboard -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ------ | ------------------ | ----- | -------------------------------------- | ------ | ------ | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ------ | ------------------ | ----- | ---------------------------- | ------ | ------ | | 11.2.1 | Memory View | WD | `insights-dashboard.spec.ts` | ✅ | Was ❌ | | 11.2.2 | Source Filtering | WD | `insights-dashboard.spec.ts` | ✅ | Was ❌ | | 11.2.3 | Search & Retrieval | WD | `insights-dashboard.spec.ts` | ✅ | Was ❌ | @@ -448,19 +456,19 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil ### 12.1 Role Unlocking -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ------ | ------------------------ | ----- | --------------------------------------------------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------- | -| 12.1.1 | Activity-Based Unlock | VU+WD | `app/src/store/__tests__/rewardsSlice.test.ts`, `app/test/e2e/specs/rewards-unlock-flow.spec.ts` | ✅ | Was ❌ — streak/feature-driven unlock branch | -| 12.1.2 | Integration-Based Unlock | VU+WD | same | ✅ | Was ❌ — Discord membership → role assignment branch | -| 12.1.3 | Plan-Based Unlock | VU+WD | same | ✅ | Was ❌ — plan tier + active subscription branch | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ------ | ------------------------ | ----- | ------------------------------------------------------------------------------------------------ | ------ | ---------------------------------------------------- | +| 12.1.1 | Activity-Based Unlock | VU+WD | `app/src/store/__tests__/rewardsSlice.test.ts`, `app/test/e2e/specs/rewards-unlock-flow.spec.ts` | ✅ | Was ❌ — streak/feature-driven unlock branch | +| 12.1.2 | Integration-Based Unlock | VU+WD | same | ✅ | Was ❌ — Discord membership → role assignment branch | +| 12.1.3 | Plan-Based Unlock | VU+WD | same | ✅ | Was ❌ — plan tier + active subscription branch | ### 12.2 Progress Tracking -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ------ | ---------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------- | -| 12.2.1 | Message Count Tracking | VU+WD | `rewardsSlice.test.ts`, `rewards-progression-persistence.spec.ts` | ✅ | Was ❌ — message-driven progress proxied by `metrics.featuresUsedCount` (no literal field) | -| 12.2.2 | Usage Metrics | VU+WD | same | ✅ | Was ❌ — current streak + cumulative tokens | -| 12.2.3 | State Persistence | VU+WD | same | ✅ | Was ❌ — restart-equivalent (page unmount + remount + re-fetch); admin request log asserts re-fetch | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ------ | ---------------------- | ----- | ----------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------- | +| 12.2.1 | Message Count Tracking | VU+WD | `rewardsSlice.test.ts`, `rewards-progression-persistence.spec.ts` | ✅ | Was ❌ — message-driven progress proxied by `metrics.featuresUsedCount` (no literal field) | +| 12.2.2 | Usage Metrics | VU+WD | same | ✅ | Was ❌ — current streak + cumulative tokens | +| 12.2.3 | State Persistence | VU+WD | same | ✅ | Was ❌ — restart-equivalent (page unmount + remount + re-fetch); admin request log asserts re-fetch | --- @@ -468,45 +476,45 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil ### 13.1 Account & Security -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ------ | ------------------ | ----- | -------------------------------------------------------------------- | ------ | --------------------- | -| 13.1.1 | Profile Management | VU | `app/src/components/settings/panels/__tests__/PrivacyPanel.test.tsx` | 🟡 | | -| 13.1.2 | Linked Accounts | WD | `auth-access-control.spec.ts` | 🟡 | UI surface unasserted | -| 13.1.3 | Meet Handoff Prompt-Injection Guard | VU | `app/src/services/__tests__/webviewAccountService.meetPromptInjection.test.ts` | ✅ | Was ❌ — guard blocks handoff on hostile transcripts and wraps non-blocked transcripts in `` delimiters (#1920) | -| 13.1.4 | Wallet Balances Panel | VU | `app/src/components/settings/panels/__tests__/WalletBalancesPanel.test.tsx`, `app/src/services/walletApi.test.ts` | ✅ | Loading/error/empty/loaded states; Retry + Refresh re-invocation; chain badges; truncated address; providerStatus chip | -| 13.1.5 | Approval History | VU | `app/src/components/settings/panels/__tests__/ApprovalHistoryPanel.test.tsx`, `app/src/services/api/approvalApi.test.ts` | ✅ | Was ❌ — read-only audit surface over `approval_list_recent_decisions`; covers loaded/empty/error/refresh states, per-decision badge, and the bare-array vs `{result,logs}` envelope normalization | -| 13.1.6 | Action Timeout | VU, RU, RI | `app/src/components/settings/panels/__tests__/AgentAccessPanel.test.tsx`, `src/openhuman/tool_timeout/mod.rs`, `src/openhuman/config/ops_tests.rs`, `tests/json_rpc_e2e.rs` | ✅ | New (#3100) — UI control over `config_get/update_agent_settings` for `[agent].agent_timeout_secs`; covers load/persist-on-blur/range-rejection/no-op/env-override-disable, the runtime-mutable `tool_timeout` resolver + env precedence, ops apply/reject, and the RPC roundtrip | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ------ | ----------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 13.1.1 | Profile Management | VU | `app/src/components/settings/panels/__tests__/PrivacyPanel.test.tsx` | 🟡 | | +| 13.1.2 | Linked Accounts | WD | `auth-access-control.spec.ts` | 🟡 | UI surface unasserted | +| 13.1.3 | Meet Handoff Prompt-Injection Guard | VU | `app/src/services/__tests__/webviewAccountService.meetPromptInjection.test.ts` | ✅ | Was ❌ — guard blocks handoff on hostile transcripts and wraps non-blocked transcripts in `` delimiters (#1920) | +| 13.1.4 | Wallet Balances Panel | VU | `app/src/components/settings/panels/__tests__/WalletBalancesPanel.test.tsx`, `app/src/services/walletApi.test.ts` | ✅ | Loading/error/empty/loaded states; Retry + Refresh re-invocation; chain badges; truncated address; providerStatus chip | +| 13.1.5 | Approval History | VU | `app/src/components/settings/panels/__tests__/ApprovalHistoryPanel.test.tsx`, `app/src/services/api/approvalApi.test.ts` | ✅ | Was ❌ — read-only audit surface over `approval_list_recent_decisions`; covers loaded/empty/error/refresh states, per-decision badge, and the bare-array vs `{result,logs}` envelope normalization | +| 13.1.6 | Action Timeout | VU, RU, RI | `app/src/components/settings/panels/__tests__/AgentAccessPanel.test.tsx`, `src/openhuman/tool_timeout/mod.rs`, `src/openhuman/config/ops_tests.rs`, `tests/json_rpc_e2e.rs` | ✅ | New (#3100) — UI control over `config_get/update_agent_settings` for `[agent].agent_timeout_secs`; covers load/persist-on-blur/range-rejection/no-op/env-override-disable, the runtime-mutable `tool_timeout` resolver + env precedence, ops apply/reject, and the RPC roundtrip | ### 13.2 Automation & Channels -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ------ | --------------------- | ----- | ----------------------------------------------------------- | ------ | ----- | -| 13.2.1 | Channel Configuration | WD | `app/test/e2e/specs/settings-channels-permissions.spec.ts` | ✅ | | -| 13.2.2 | Permission Settings | WD | `app/test/e2e/specs/settings-channels-permissions.spec.ts` | ✅ | | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ------ | --------------------- | ----- | ---------------------------------------------------------- | ------ | ----- | +| 13.2.1 | Channel Configuration | WD | `app/test/e2e/specs/settings-channels-permissions.spec.ts` | ✅ | | +| 13.2.2 | Permission Settings | WD | `app/test/e2e/specs/settings-channels-permissions.spec.ts` | ✅ | | ### 13.3 AI & Skills -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ------ | ------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------- | -| 13.3.1 | Model Configuration | VU+WD | `app/src/components/settings/panels/__tests__/AutocompletePanel.test.tsx`, `app/test/e2e/specs/settings-ai-skills.spec.ts` | ✅ | AI-model-switch covered | -| 13.3.2 | Skill Toggle | WD | `skill-lifecycle.spec.ts`, `app/test/e2e/specs/settings-ai-skills.spec.ts` | ✅ | | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ------ | ------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------- | ------ | ----------------------- | +| 13.3.1 | Model Configuration | VU+WD | `app/src/components/settings/panels/__tests__/AutocompletePanel.test.tsx`, `app/test/e2e/specs/settings-ai-skills.spec.ts` | ✅ | AI-model-switch covered | +| 13.3.2 | Skill Toggle | WD | `skill-lifecycle.spec.ts`, `app/test/e2e/specs/settings-ai-skills.spec.ts` | ✅ | | ### 13.4 Developer Options -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ------ | ------------------ | ----- | ---------------------------------------------------- | ------ | ----- | -| 13.4.1 | Webhook Inspection | WD | `app/test/e2e/specs/settings-dev-options.spec.ts` | ✅ | | -| 13.4.2 | Runtime Logs | WD | `app/test/e2e/specs/settings-dev-options.spec.ts` | ✅ | | -| 13.4.3 | Memory Debug | WD | `app/test/e2e/specs/settings-dev-options.spec.ts` | ✅ | | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ------ | ------------------ | ----- | ------------------------------------------------- | ------ | ----- | +| 13.4.1 | Webhook Inspection | WD | `app/test/e2e/specs/settings-dev-options.spec.ts` | ✅ | | +| 13.4.2 | Runtime Logs | WD | `app/test/e2e/specs/settings-dev-options.spec.ts` | ✅ | | +| 13.4.3 | Memory Debug | WD | `app/test/e2e/specs/settings-dev-options.spec.ts` | ✅ | | ### 13.5 Data Management -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ------ | ---------------- | ----- | ------------------------------------------------------- | ------ | -------------------------------------- | -| 13.5.1 | Clear App Data | WD | `app/test/e2e/specs/settings-data-management.spec.ts` | ✅ | Destructive — confirm-then-reset | -| 13.5.2 | Cache Reset | WD | `app/test/e2e/specs/settings-data-management.spec.ts` | ✅ | | -| 13.5.3 | Full State Reset | WD | `app/test/e2e/specs/settings-data-management.spec.ts` | ✅ | Restart-and-verify fresh-install state | -| 13.5.4 | Migration from another assistant (OpenClaw) | VU+RU | `app/src/components/settings/panels/__tests__/MigrationPanel.test.tsx`, `src/openhuman/migration/ops.rs` (existing) | ✅ | Was ❌ — UI now wraps the existing `openhuman.migrate_openclaw` RPC with preview-then-apply + confirm. Hermes tracked as follow-up under #1440 | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ------ | ------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| 13.5.1 | Clear App Data | WD | `app/test/e2e/specs/settings-data-management.spec.ts` | ✅ | Destructive — confirm-then-reset | +| 13.5.2 | Cache Reset | WD | `app/test/e2e/specs/settings-data-management.spec.ts` | ✅ | | +| 13.5.3 | Full State Reset | WD | `app/test/e2e/specs/settings-data-management.spec.ts` | ✅ | Restart-and-verify fresh-install state | +| 13.5.4 | Migration from another assistant (OpenClaw) | VU+RU | `app/src/components/settings/panels/__tests__/MigrationPanel.test.tsx`, `src/openhuman/migration/ops.rs` (existing) | ✅ | Was ❌ — UI now wraps the existing `openhuman.migrate_openclaw` RPC with preview-then-apply + confirm. Hermes tracked as follow-up under #1440 | --- diff --git a/src/openhuman/agent/harness/subagent_runner/ops/loop_.rs b/src/openhuman/agent/harness/subagent_runner/ops/loop_.rs index 0b38235ed..f6945a609 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops/loop_.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops/loop_.rs @@ -60,6 +60,10 @@ pub(super) async fn run_inner_loop( handoff_cache: Option<&ResultHandoffCache>, parent: &ParentExecutionContext, extended_policy: bool, + // Optional steering channel. When `Some`, the child engine drains + // steer/collect messages at iteration boundaries so the parent can + // `steer_subagent` a running async sub-agent. `None` = non-steerable. + run_queue: Option>, ) -> Result<(String, usize, AggregatedUsage, Option), SubagentRunError> { // An autonomous skill run (set via `with_autonomous_iter_cap`) lifts the // per-agent cap so sub-agents run until done / the circuit breaker trips. @@ -199,7 +203,7 @@ pub(super) async fn run_inner_loop( max_iterations, None, // sub-agents don't stream a draft &["ask_user_clarification"], - None, // sub-agents don't support run-queue steering + run_queue, // steering channel for `steer_subagent` (None = non-steerable) )), ) .await?; diff --git a/src/openhuman/agent/harness/subagent_runner/ops/runner.rs b/src/openhuman/agent/harness/subagent_runner/ops/runner.rs index 8bafc5fb6..3882b5a12 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops/runner.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops/runner.rs @@ -696,6 +696,7 @@ async fn run_typed_mode( handoff_cache.as_deref(), parent, definition.iteration_policy == IterationPolicy::Extended, + options.run_queue.clone(), )) .await?; diff --git a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs index d6a8d6322..0f6fe13d3 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs @@ -179,6 +179,7 @@ fn append_subagent_role_contract_is_idempotent() { // ── End-to-end runner tests with mock provider ──────────────────────── use crate::openhuman::agent::harness::fork_context::with_parent_context; +use crate::openhuman::agent::harness::run_queue::{QueueMode, QueuedMessage, RunQueue}; use crate::openhuman::inference::provider::{ ChatRequest as PChatRequest, ChatResponse, Provider, ProviderDelta, ToolCall, }; @@ -480,6 +481,7 @@ async fn typed_mode_returns_text_through_runner() { initial_history: None, checkpoint_dir: None, worktree_action_dir: None, + run_queue: None, }, ) .await @@ -493,6 +495,66 @@ async fn typed_mode_returns_text_through_runner() { assert_eq!(outcome.task_id, "t1"); } +#[tokio::test] +async fn run_queue_steer_lands_in_subagent_history() { + // End-to-end proof that flipping the subagent loop's run-queue arg from + // `None` to `Some(queue)` wires steering all the way through: a message + // pushed to the queue before the run is drained by the inner + // `run_turn_engine` and appears as a `[User steering message]:` user turn + // in the exact request sent to the provider. This is the mechanism behind + // the `steer_subagent` tool. + let provider = ScriptedProvider::new(vec![text_response("acknowledged")]); + let parent = make_parent(provider.clone(), vec![stub("file_read")]); + let def = make_def_named_tools(&[]); + + let run_queue = RunQueue::new(); + run_queue + .push(QueuedMessage { + text: "switch focus to memory safety".into(), + mode: QueueMode::Steer, + client_id: "steer_subagent".into(), + thread_id: "t-steer".into(), + queued_at_ms: 0, + model_override: None, + temperature: None, + profile_id: None, + locale: None, + }) + .await; + + let outcome = with_parent_context(parent, async { + run_subagent( + &def, + "investigate the bug", + SubagentRunOptions { + task_id: Some("t-steer".into()), + run_queue: Some(run_queue), + ..Default::default() + }, + ) + .await + }) + .await + .expect("runner should succeed"); + + assert_eq!(outcome.output, "acknowledged"); + + let captured = provider.captured.lock(); + let steered = captured[0] + .messages + .iter() + .any(|m| m.role == "user" && m.content.contains("switch focus to memory safety")); + assert!( + steered, + "steer message should be injected into the sub-agent's first request, got: {:?}", + captured[0] + .messages + .iter() + .map(|m| (&m.role, &m.content)) + .collect::>() + ); +} + #[tokio::test] async fn typed_mode_no_memory_context_in_user_message() { // Verifies that sub-agents skip memory loading entirely: the @@ -592,6 +654,7 @@ async fn typed_mode_filters_tools_by_skill_filter() { initial_history: None, checkpoint_dir: None, worktree_action_dir: None, + run_queue: None, }, ) .await diff --git a/src/openhuman/agent/harness/subagent_runner/types.rs b/src/openhuman/agent/harness/subagent_runner/types.rs index 267701978..a91e9dac3 100644 --- a/src/openhuman/agent/harness/subagent_runner/types.rs +++ b/src/openhuman/agent/harness/subagent_runner/types.rs @@ -66,6 +66,13 @@ pub struct SubagentRunOptions { /// When `None` (the default), behaviour is unchanged — tools fall through /// to `security.action_dir`. pub worktree_action_dir: Option, + + /// Steering channel for a running (typically async) sub-agent. When set, + /// the inner `run_turn_engine` drains steer/collect messages from this + /// queue at iteration boundaries — exactly like the main agent loop — so + /// the parent can `steer_subagent` mid-flight. `None` keeps today's + /// non-steerable behaviour. + pub run_queue: Option>, } /// Terminal status of a sub-agent run. diff --git a/src/openhuman/agent/tools/delegate_to_personality.rs b/src/openhuman/agent/tools/delegate_to_personality.rs index 84ede24f7..3627efa5c 100644 --- a/src/openhuman/agent/tools/delegate_to_personality.rs +++ b/src/openhuman/agent/tools/delegate_to_personality.rs @@ -218,6 +218,7 @@ impl Tool for DelegateToPersonalityTool { initial_history: None, checkpoint_dir: None, worktree_action_dir: None, + run_queue: None, }; match run_subagent(&definition, &prompt, options).await { diff --git a/src/openhuman/agent_orchestration/mod.rs b/src/openhuman/agent_orchestration/mod.rs index 208cf922a..04594da22 100644 --- a/src/openhuman/agent_orchestration/mod.rs +++ b/src/openhuman/agent_orchestration/mod.rs @@ -8,6 +8,7 @@ pub mod agent_teams; pub mod command_center; mod ops; +pub mod running_subagents; pub mod tools; pub mod types; pub mod workflow_runs; diff --git a/src/openhuman/agent_orchestration/ops.rs b/src/openhuman/agent_orchestration/ops.rs index 83e68e26e..d5ec9a99c 100644 --- a/src/openhuman/agent_orchestration/ops.rs +++ b/src/openhuman/agent_orchestration/ops.rs @@ -465,6 +465,7 @@ impl AgentOrchestrationSession { initial_history: None, checkpoint_dir: None, worktree_action_dir: None, + run_queue: None, }; let task_session = self.clone(); diff --git a/src/openhuman/agent_orchestration/running_subagents.rs b/src/openhuman/agent_orchestration/running_subagents.rs new file mode 100644 index 000000000..f805bf4e4 --- /dev/null +++ b/src/openhuman/agent_orchestration/running_subagents.rs @@ -0,0 +1,423 @@ +//! Registry of in-flight async sub-agents that can be **steered** mid-run. +//! +//! `spawn_async_subagent` runs a child as a detached `tokio` task. On its own +//! that task is opaque: the parent gets a `task_id` back but has no channel into +//! the running loop and no way to collect the result inline. This registry +//! closes both gaps. +//! +//! Each running async sub-agent registers, keyed by its `task_id`, with: +//! - an `Arc` — the same steering channel the inner `run_turn_engine` +//! drains at iteration boundaries, so `steer_subagent` can inject a message; +//! - a `watch::Receiver` — so `wait_subagent` can block until the +//! child reaches a terminal status; +//! - an `AbortHandle` — kept for a future `close_agent` tool. +//! +//! Ownership is enforced: only the spawning parent (matched by `parent_session`) +//! may steer or wait on a given sub-agent. Terminal entries are pruned on `wait`, +//! and swept on `register` only once the table passes a soft cap, so it can't +//! grow unbounded if a parent never waits (the Codex "spawn-slot leak" failure +//! mode — openai/codex#18335). + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +use tokio::sync::watch; +use tokio::task::AbortHandle; + +use crate::openhuman::agent::harness::run_queue::{QueueMode, QueuedMessage, RunQueue}; + +/// Terminal/transient state of a running async sub-agent, published by the +/// spawner's background task and observed by `wait_subagent`. +#[derive(Debug, Clone)] +pub enum SubagentStatus { + /// Still executing its inner tool-call loop. + Running, + /// Finished normally with a final response. + Completed { output: String, iterations: usize }, + /// Paused on `ask_user_clarification`; resume via `continue_subagent`. + AwaitingUser { question: String }, + /// The run errored out. + Failed { error: String }, +} + +impl SubagentStatus { + pub fn is_terminal(&self) -> bool { + !matches!(self, SubagentStatus::Running) + } +} + +struct RunningSubagentEntry { + #[allow(dead_code)] + agent_id: String, + parent_session: String, + run_queue: Arc, + abort: AbortHandle, + status: watch::Receiver, +} + +/// Soft cap on registry size. Terminal entries are only swept when the table +/// grows past this, so the common case (a handful of live sub-agents) never +/// evicts a still-uncollected terminal result out from under a `wait`/`steer`. +const REGISTRY_SOFT_CAP: usize = 256; + +static REGISTRY: OnceLock>> = OnceLock::new(); + +fn registry() -> &'static Mutex> { + REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Create the status channel a spawner threads into [`register`]. +/// +/// The spawner moves the [`watch::Sender`] into its detached task and `send`s a +/// terminal [`SubagentStatus`] on completion. Dropping the sender (e.g. a +/// panicked/aborted task) closes the channel, which `wait_subagent` surfaces as +/// a failure rather than hanging. +pub fn status_channel() -> ( + watch::Sender, + watch::Receiver, +) { + watch::channel(SubagentStatus::Running) +} + +/// Register a running async sub-agent so it can be steered and waited on. +/// +/// Call this *after* `tokio::spawn` so the [`AbortHandle`] is available; the +/// task owns the matching [`watch::Sender`] from [`status_channel`]. Once the +/// table passes [`REGISTRY_SOFT_CAP`], registration sweeps already-terminal +/// entries so it stays bounded even if a parent never calls `wait_subagent`. +pub fn register( + task_id: String, + agent_id: String, + parent_session: String, + run_queue: Arc, + abort: AbortHandle, + status: watch::Receiver, +) { + let entry = RunningSubagentEntry { + agent_id, + parent_session, + run_queue, + abort, + status, + }; + let mut map = registry().lock().expect("running_subagents mutex poisoned"); + if map.len() >= REGISTRY_SOFT_CAP { + // Only under genuine pressure: sweep collected/terminal entries so the + // table can't grow without bound when a parent never waits (the Codex + // spawn-slot leak). Live (Running) entries are always retained. + map.retain(|_, e| !e.status.borrow().is_terminal()); + } + map.insert(task_id.clone(), entry); + log::debug!( + "[running_subagents] registered task_id={} live_entries={}", + task_id, + map.len() + ); +} + +/// Why a steer could not be delivered. +#[derive(Debug, PartialEq, Eq)] +pub enum SteerError { + /// No such sub-agent — never existed, or already finished and pruned. + Unknown, + /// The caller's `parent_session` does not own this sub-agent. + NotOwned, + /// The sub-agent already reached a terminal status. + AlreadyDone, +} + +/// Inject a message into a running sub-agent's steering queue. The child's +/// `run_turn_engine` drains it at the next iteration boundary. +pub async fn steer( + task_id: &str, + parent_session: &str, + text: String, + mode: QueueMode, +) -> Result<(), SteerError> { + let run_queue = { + let map = registry().lock().expect("running_subagents mutex poisoned"); + let entry = map.get(task_id).ok_or(SteerError::Unknown)?; + if entry.parent_session != parent_session { + return Err(SteerError::NotOwned); + } + if entry.status.borrow().is_terminal() { + return Err(SteerError::AlreadyDone); + } + entry.run_queue.clone() + }; + + run_queue + .push(QueuedMessage { + text, + mode, + client_id: "steer_subagent".to_string(), + thread_id: task_id.to_string(), + queued_at_ms: now_ms(), + model_override: None, + temperature: None, + profile_id: None, + locale: None, + }) + .await; + log::info!( + "[running_subagents] steered task_id={} mode={}", + task_id, + mode + ); + Ok(()) +} + +/// Why a wait could not be set up. +#[derive(Debug, PartialEq, Eq)] +pub enum WaitError { + Unknown, + NotOwned, +} + +/// Result of waiting on a sub-agent. +#[derive(Debug)] +pub enum WaitOutcome { + /// The sub-agent reached a terminal status (entry pruned). + Terminal(SubagentStatus), + /// The timeout elapsed first; the entry is left intact so the parent can + /// wait again. Carries the latest (non-terminal) status snapshot. + TimedOut(SubagentStatus), +} + +/// Block until `task_id` reaches a terminal status or `timeout` elapses. +pub async fn wait( + task_id: &str, + parent_session: &str, + timeout: Duration, +) -> Result { + let mut rx = { + let map = registry().lock().expect("running_subagents mutex poisoned"); + let entry = map.get(task_id).ok_or(WaitError::Unknown)?; + if entry.parent_session != parent_session { + return Err(WaitError::NotOwned); + } + entry.status.clone() + }; + + // Fast path: already terminal. + let current = rx.borrow_and_update().clone(); + if current.is_terminal() { + prune(task_id); + return Ok(WaitOutcome::Terminal(current)); + } + + let waited = async { + loop { + if rx.changed().await.is_err() { + // Sender dropped without a terminal status (task aborted/panicked). + return SubagentStatus::Failed { + error: "sub-agent task ended without reporting a result".to_string(), + }; + } + let status = rx.borrow().clone(); + if status.is_terminal() { + return status; + } + } + }; + + match tokio::time::timeout(timeout, waited).await { + Ok(status) => { + prune(task_id); + Ok(WaitOutcome::Terminal(status)) + } + Err(_) => Ok(WaitOutcome::TimedOut(rx.borrow().clone())), + } +} + +/// Abort a running sub-agent and drop its registry entry. Kept for a future +/// `close_agent` tool; the abort handle is stored at spawn time. +pub fn close(task_id: &str, parent_session: &str) -> bool { + let mut map = registry().lock().expect("running_subagents mutex poisoned"); + match map.get(task_id) { + Some(entry) if entry.parent_session == parent_session => { + entry.abort.abort(); + map.remove(task_id); + true + } + _ => false, + } +} + +fn prune(task_id: &str) { + registry() + .lock() + .expect("running_subagents mutex poisoned") + .remove(task_id); +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dummy_abort() -> AbortHandle { + tokio::spawn(async {}).abort_handle() + } + + /// Register a sub-agent for tests, returning the status sender so the test + /// can drive completion. Keeping the sender alive keeps the channel open. + fn register_test( + task_id: &str, + parent_session: &str, + rq: Arc, + ) -> watch::Sender { + let (tx, rx) = status_channel(); + register( + task_id.into(), + "researcher".into(), + parent_session.into(), + rq, + dummy_abort(), + rx, + ); + tx + } + + #[tokio::test] + async fn steer_pushes_into_the_subagent_queue() { + let rq = RunQueue::new(); + let tx = register_test("task-steer", "session-A", rq.clone()); + + steer( + "task-steer", + "session-A", + "refocus on memory safety".into(), + QueueMode::Steer, + ) + .await + .expect("steer should succeed"); + + let status = rq.status().await; + assert_eq!(status.steers, 1, "steer should land in the steer lane"); + + // collect mode goes to the collect lane + steer( + "task-steer", + "session-A", + "extra context".into(), + QueueMode::Collect, + ) + .await + .unwrap(); + assert_eq!(rq.status().await.collects, 1); + + let _ = tx.send(SubagentStatus::Completed { + output: "done".into(), + iterations: 1, + }); + prune("task-steer"); + } + + #[tokio::test] + async fn steer_rejects_cross_parent_and_unknown() { + let rq = RunQueue::new(); + let _tx = register_test("task-owned", "session-owner", rq); + + assert_eq!( + steer( + "task-owned", + "session-intruder", + "x".into(), + QueueMode::Steer + ) + .await, + Err(SteerError::NotOwned) + ); + assert_eq!( + steer( + "task-missing", + "session-owner", + "x".into(), + QueueMode::Steer + ) + .await, + Err(SteerError::Unknown) + ); + prune("task-owned"); + } + + #[tokio::test] + async fn steer_after_terminal_is_rejected() { + let rq = RunQueue::new(); + let tx = register_test("task-term", "session-A", rq); + let _ = tx.send(SubagentStatus::Failed { + error: "boom".into(), + }); + + assert_eq!( + steer("task-term", "session-A", "x".into(), QueueMode::Steer).await, + Err(SteerError::AlreadyDone) + ); + prune("task-term"); + } + + #[tokio::test] + async fn wait_returns_completion_once_published() { + let rq = RunQueue::new(); + let tx = register_test("task-wait", "session-A", rq); + + tokio::spawn(async move { + let _ = tx.send(SubagentStatus::Completed { + output: "the answer".into(), + iterations: 3, + }); + // keep sender alive until after send + drop(tx); + }); + + let outcome = wait("task-wait", "session-A", Duration::from_secs(5)) + .await + .expect("wait should resolve"); + match outcome { + WaitOutcome::Terminal(SubagentStatus::Completed { output, iterations }) => { + assert_eq!(output, "the answer"); + assert_eq!(iterations, 3); + } + other => panic!("expected completed terminal, got {other:?}"), + } + + // pruned after a terminal wait + assert!(matches!( + wait("task-wait", "session-A", Duration::from_millis(10)).await, + Err(WaitError::Unknown) + )); + } + + #[tokio::test] + async fn wait_times_out_and_leaves_entry_intact() { + let rq = RunQueue::new(); + let _tx = register_test("task-slow", "session-A", rq); + + let outcome = wait("task-slow", "session-A", Duration::from_millis(20)) + .await + .expect("wait should resolve"); + assert!(matches!( + outcome, + WaitOutcome::TimedOut(SubagentStatus::Running) + )); + + // still steerable after a timed-out wait + assert!(steer( + "task-slow", + "session-A", + "still here".into(), + QueueMode::Steer + ) + .await + .is_ok()); + prune("task-slow"); + } +} diff --git a/src/openhuman/agent_orchestration/tools.rs b/src/openhuman/agent_orchestration/tools.rs index 5ec5c3d93..ab4c3dbca 100644 --- a/src/openhuman/agent_orchestration/tools.rs +++ b/src/openhuman/agent_orchestration/tools.rs @@ -14,9 +14,13 @@ mod spawn_parallel_agents; mod spawn_subagent; #[path = "tools/spawn_worker_thread.rs"] pub mod spawn_worker_thread; +#[path = "tools/steer_subagent.rs"] +mod steer_subagent; #[cfg(test)] #[path = "tools/tools_e2e_tests.rs"] mod tools_e2e_tests; +#[path = "tools/wait_subagent.rs"] +mod wait_subagent; #[path = "tools/worker_thread.rs"] mod worker_thread; @@ -29,3 +33,5 @@ pub use spawn_async_subagent::SpawnAsyncSubagentTool; pub use spawn_parallel_agents::SpawnParallelAgentsTool; pub use spawn_subagent::SpawnSubagentTool; pub use spawn_worker_thread::SpawnWorkerThreadTool; +pub use steer_subagent::SteerSubagentTool; +pub use wait_subagent::WaitSubagentTool; diff --git a/src/openhuman/agent_orchestration/tools/continue_subagent.rs b/src/openhuman/agent_orchestration/tools/continue_subagent.rs index 9aa42be8e..a79c1e9a0 100644 --- a/src/openhuman/agent_orchestration/tools/continue_subagent.rs +++ b/src/openhuman/agent_orchestration/tools/continue_subagent.rs @@ -229,6 +229,7 @@ impl Tool for ContinueSubagentTool { initial_history: Some(history), checkpoint_dir: Some(checkpoint_dir.clone()), worktree_action_dir: None, + run_queue: None, }; // Run the sub-agent from its checkpoint diff --git a/src/openhuman/agent_orchestration/tools/dispatch.rs b/src/openhuman/agent_orchestration/tools/dispatch.rs index 1179dcb6d..4e5b978d3 100644 --- a/src/openhuman/agent_orchestration/tools/dispatch.rs +++ b/src/openhuman/agent_orchestration/tools/dispatch.rs @@ -109,6 +109,7 @@ pub(crate) async fn dispatch_subagent( initial_history: None, checkpoint_dir: None, worktree_action_dir: None, + run_queue: None, }; match run_subagent(definition, prompt, options).await { diff --git a/src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs b/src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs index 60a3d94dc..3683e4919 100644 --- a/src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs +++ b/src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs @@ -7,10 +7,12 @@ use crate::core::event_bus::{publish_global, DomainEvent}; use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry; use crate::openhuman::agent::harness::fork_context::{current_parent, with_parent_context}; +use crate::openhuman::agent::harness::run_queue::RunQueue; use crate::openhuman::agent::harness::subagent_runner::{ run_subagent, SubagentRunOptions, SubagentRunStatus, }; use crate::openhuman::agent::progress::AgentProgress; +use crate::openhuman::agent_orchestration::running_subagents::{self, SubagentStatus}; use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; use async_trait::async_trait; use serde_json::json; @@ -225,6 +227,14 @@ impl Tool for SpawnAsyncSubagentTool { .await; } + // Steering channel + status channel so the parent can `steer_subagent` + // this run mid-flight and `wait_subagent` for its result. The engine + // drains `steer_queue` at iteration boundaries; `status_tx` publishes + // the terminal state to any waiter. + let steer_queue = RunQueue::new(); + let task_queue = steer_queue.clone(); + let (status_tx, status_rx) = running_subagents::status_channel(); + let background_parent = parent.clone(); let background_definition = definition.clone(); let background_agent_id = definition.id.clone(); @@ -234,7 +244,7 @@ impl Tool for SpawnAsyncSubagentTool { let background_worker_thread_id = worker_thread_id.clone(); let background_prompt = add_background_contract(&prompt); - tokio::spawn(async move { + let join = tokio::spawn(async move { let options = SubagentRunOptions { skill_filter_override: None, toolkit_override, @@ -245,6 +255,7 @@ impl Tool for SpawnAsyncSubagentTool { initial_history: None, checkpoint_dir: None, worktree_action_dir: None, + run_queue: Some(task_queue), }; let result = with_parent_context(background_parent, async move { @@ -255,6 +266,11 @@ impl Tool for SpawnAsyncSubagentTool { match result { Ok(outcome) => match outcome.status { SubagentRunStatus::Completed => { + // Unblock `wait_subagent` with the final output first. + let _ = status_tx.send(SubagentStatus::Completed { + output: outcome.output.clone(), + iterations: outcome.iterations, + }); publish_global(DomainEvent::SubagentCompleted { parent_session: background_parent_session, task_id: outcome.task_id.clone(), @@ -276,6 +292,9 @@ impl Tool for SpawnAsyncSubagentTool { } } SubagentRunStatus::AwaitingUser { question, .. } => { + let _ = status_tx.send(SubagentStatus::AwaitingUser { + question: question.clone(), + }); let error = format!( "async sub-agent requested user clarification and was not continued: {question}" ); @@ -298,6 +317,9 @@ impl Tool for SpawnAsyncSubagentTool { }, Err(err) => { let error = err.to_string(); + let _ = status_tx.send(SubagentStatus::Failed { + error: error.clone(), + }); publish_global(DomainEvent::SubagentFailed { parent_session: background_parent_session, task_id: background_task_id.clone(), @@ -317,6 +339,17 @@ impl Tool for SpawnAsyncSubagentTool { } }); + // Register *after* spawn so the AbortHandle is available. The task owns + // `status_tx`; this side holds `status_rx` for `wait_subagent`. + running_subagents::register( + task_id.clone(), + definition.id.clone(), + parent_session.clone(), + steer_queue, + join.abort_handle(), + status_rx, + ); + let payload = json!({ "task_id": task_id, "agent_id": definition.id, @@ -324,8 +357,11 @@ impl Tool for SpawnAsyncSubagentTool { "worker_thread_id": worker_thread_id, }); Ok(ToolResult::success(format!( - "Accepted background sub-agent `{}`. Do not wait for it before answering the user.\n\n[async_subagent_ref]\n{}\n[/async_subagent_ref]", + "Accepted background sub-agent `{}` (task_id `{}`). Do not block on it before answering the user. \ + You may redirect it mid-run with `steer_subagent {{ task_id, message }}` and collect its result \ + with `wait_subagent {{ task_id }}`.\n\n[async_subagent_ref]\n{}\n[/async_subagent_ref]", payload["agent_id"].as_str().unwrap_or("subagent"), + task_id, serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string()) ))) } diff --git a/src/openhuman/agent_orchestration/tools/spawn_parallel_agents.rs b/src/openhuman/agent_orchestration/tools/spawn_parallel_agents.rs index fcf262fe1..a58283cb5 100644 --- a/src/openhuman/agent_orchestration/tools/spawn_parallel_agents.rs +++ b/src/openhuman/agent_orchestration/tools/spawn_parallel_agents.rs @@ -682,6 +682,7 @@ async fn run_one_parallel_task( initial_history: None, checkpoint_dir: None, worktree_action_dir: worktree_path.clone(), + run_queue: None, }; let run_result = run_subagent(&definition, &prompt, options).await; diff --git a/src/openhuman/agent_orchestration/tools/spawn_subagent.rs b/src/openhuman/agent_orchestration/tools/spawn_subagent.rs index ead481bb4..cfe8cd400 100644 --- a/src/openhuman/agent_orchestration/tools/spawn_subagent.rs +++ b/src/openhuman/agent_orchestration/tools/spawn_subagent.rs @@ -459,6 +459,7 @@ impl Tool for SpawnSubagentTool { initial_history: None, checkpoint_dir: None, worktree_action_dir: None, + run_queue: None, }; let progress_sink = current_parent().and_then(|p| p.on_progress.clone()); diff --git a/src/openhuman/agent_orchestration/tools/spawn_worker_thread.rs b/src/openhuman/agent_orchestration/tools/spawn_worker_thread.rs index e836e8704..252a8376c 100644 --- a/src/openhuman/agent_orchestration/tools/spawn_worker_thread.rs +++ b/src/openhuman/agent_orchestration/tools/spawn_worker_thread.rs @@ -228,6 +228,7 @@ impl Tool for SpawnWorkerThreadTool { initial_history: None, checkpoint_dir: None, worktree_action_dir: None, + run_queue: None, }; tracing::debug!( diff --git a/src/openhuman/agent_orchestration/tools/steer_subagent.rs b/src/openhuman/agent_orchestration/tools/steer_subagent.rs new file mode 100644 index 000000000..baf4813a1 --- /dev/null +++ b/src/openhuman/agent_orchestration/tools/steer_subagent.rs @@ -0,0 +1,174 @@ +//! Tool: `steer_subagent` — inject a message into a running async sub-agent. +//! +//! Pairs with `spawn_async_subagent`: that tool returns a `task_id` for a child +//! running in the background. `steer_subagent` pushes a message into that child's +//! steering queue, which its inner `run_turn_engine` drains at the next iteration +//! boundary — so the parent can redirect or feed data to a running sub-agent +//! without waiting for it to finish or restarting it. Mirrors Codex `send_input`. + +use crate::openhuman::agent::harness::fork_context::current_parent; +use crate::openhuman::agent::harness::run_queue::QueueMode; +use crate::openhuman::agent_orchestration::running_subagents::{self, SteerError}; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; + +pub struct SteerSubagentTool; + +impl SteerSubagentTool { + pub fn new() -> Self { + Self + } +} + +impl Default for SteerSubagentTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for SteerSubagentTool { + fn name(&self) -> &str { + "steer_subagent" + } + + fn description(&self) -> &str { + "Send a message into a running async sub-agent (one you started with \ + spawn_async_subagent), redirecting or feeding it data mid-run without \ + restarting it. The sub-agent picks the message up at its next step. Use \ + `mode: steer` (default) for a new instruction it must address, or \ + `mode: collect` for silent extra context. Returns immediately; use \ + wait_subagent to collect the final result." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "required": ["task_id", "message"], + "properties": { + "task_id": { + "type": "string", + "description": "The task_id returned by spawn_async_subagent (see its [async_subagent_ref] block)." + }, + "message": { + "type": "string", + "description": "Instruction or data to inject into the running sub-agent." + }, + "mode": { + "type": "string", + "enum": ["steer", "collect"], + "default": "steer", + "description": "steer = a new instruction the sub-agent must address; collect = silent additional context." + } + } + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Execute + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let task_id = args + .get("task_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let message = args + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let mode = match args.get("mode").and_then(|v| v.as_str()).unwrap_or("steer") { + "collect" => QueueMode::Collect, + _ => QueueMode::Steer, + }; + + if task_id.is_empty() { + return Ok(ToolResult::error("steer_subagent: `task_id` is required")); + } + if message.is_empty() { + return Ok(ToolResult::error("steer_subagent: `message` is required")); + } + + let parent_session = match current_parent() { + Some(parent) => parent.session_id, + None => { + return Ok(ToolResult::error( + "steer_subagent called outside of an agent turn", + )); + } + }; + + log::info!( + "[steer_subagent] task_id={} mode={} chars={}", + task_id, + mode, + message.chars().count() + ); + + match running_subagents::steer(&task_id, &parent_session, message, mode).await { + Ok(()) => Ok(ToolResult::success(format!( + "Steered sub-agent `{task_id}` ({mode}). It will pick this up at its next step. \ + Use wait_subagent {{ task_id: \"{task_id}\" }} to collect its result." + ))), + Err(SteerError::Unknown) => Ok(ToolResult::error(format!( + "steer_subagent: no running sub-agent with task_id `{task_id}`. It may have already \ + finished — use wait_subagent to collect its result, or check the task_id." + ))), + Err(SteerError::NotOwned) => Ok(ToolResult::error(format!( + "steer_subagent: sub-agent `{task_id}` was not started by this agent and cannot be steered." + ))), + Err(SteerError::AlreadyDone) => Ok(ToolResult::error(format!( + "steer_subagent: sub-agent `{task_id}` has already finished. Use wait_subagent to collect its result." + ))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schema_requires_task_id_and_message() { + let schema = SteerSubagentTool::new().parameters_schema(); + let required = schema + .get("required") + .and_then(|v| v.as_array()) + .expect("required list"); + assert!(required.iter().any(|v| v.as_str() == Some("task_id"))); + assert!(required.iter().any(|v| v.as_str() == Some("message"))); + } + + #[tokio::test] + async fn missing_task_id_is_rejected() { + let tool = SteerSubagentTool::new(); + let res = tool.execute(json!({ "message": "go" })).await.unwrap(); + assert!(res.is_error); + assert!(res.output().contains("task_id")); + } + + #[tokio::test] + async fn missing_message_is_rejected() { + let tool = SteerSubagentTool::new(); + let res = tool.execute(json!({ "task_id": "sub-1" })).await.unwrap(); + assert!(res.is_error); + assert!(res.output().contains("message")); + } + + #[tokio::test] + async fn outside_agent_turn_is_rejected() { + // No PARENT_CONTEXT task-local installed in a bare test. + let tool = SteerSubagentTool::new(); + let res = tool + .execute(json!({ "task_id": "sub-1", "message": "go" })) + .await + .unwrap(); + assert!(res.is_error); + assert!(res.output().contains("outside of an agent turn")); + } +} diff --git a/src/openhuman/agent_orchestration/tools/wait_subagent.rs b/src/openhuman/agent_orchestration/tools/wait_subagent.rs new file mode 100644 index 000000000..a659b080a --- /dev/null +++ b/src/openhuman/agent_orchestration/tools/wait_subagent.rs @@ -0,0 +1,171 @@ +//! Tool: `wait_subagent` — block until a running async sub-agent finishes. +//! +//! Pairs with `spawn_async_subagent` / `steer_subagent`: once the parent has +//! fanned out background work, `wait_subagent` collects a child's final result +//! inline (with a timeout), instead of relying solely on lifecycle events. +//! Mirrors Codex `wait`. + +use std::time::Duration; + +use crate::openhuman::agent::harness::fork_context::current_parent; +use crate::openhuman::agent_orchestration::running_subagents::{ + self, SubagentStatus, WaitError, WaitOutcome, +}; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; + +const DEFAULT_TIMEOUT_SECS: u64 = 120; +const MAX_TIMEOUT_SECS: u64 = 600; + +pub struct WaitSubagentTool; + +impl WaitSubagentTool { + pub fn new() -> Self { + Self + } +} + +impl Default for WaitSubagentTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for WaitSubagentTool { + fn name(&self) -> &str { + "wait_subagent" + } + + fn description(&self) -> &str { + "Block until an async sub-agent (started with spawn_async_subagent) \ + finishes, then return its final result. Optionally bound the wait with \ + `timeout_secs` (default 120, max 600); on timeout it reports the \ + sub-agent is still running and you can call wait_subagent again." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "required": ["task_id"], + "properties": { + "task_id": { + "type": "string", + "description": "The task_id returned by spawn_async_subagent." + }, + "timeout_secs": { + "type": "integer", + "minimum": 1, + "maximum": MAX_TIMEOUT_SECS, + "description": "Max seconds to block before returning a 'still running' result. Default 120." + } + } + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Execute + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let task_id = args + .get("task_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + if task_id.is_empty() { + return Ok(ToolResult::error("wait_subagent: `task_id` is required")); + } + + let timeout_secs = args + .get("timeout_secs") + .and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_TIMEOUT_SECS) + .clamp(1, MAX_TIMEOUT_SECS); + + let parent_session = match current_parent() { + Some(parent) => parent.session_id, + None => { + return Ok(ToolResult::error( + "wait_subagent called outside of an agent turn", + )); + } + }; + + log::info!( + "[wait_subagent] task_id={} timeout_secs={}", + task_id, + timeout_secs + ); + + match running_subagents::wait( + &task_id, + &parent_session, + Duration::from_secs(timeout_secs), + ) + .await + { + Ok(WaitOutcome::Terminal(SubagentStatus::Completed { output, iterations })) => { + Ok(ToolResult::success(format!( + "Sub-agent `{task_id}` completed in {iterations} iteration(s):\n\n{output}" + ))) + } + Ok(WaitOutcome::Terminal(SubagentStatus::AwaitingUser { question })) => { + Ok(ToolResult::success(format!( + "Sub-agent `{task_id}` paused for clarification and did not finish: {question}\n\n\ + It cannot proceed unattended. Resume it with continue_subagent once you have an answer." + ))) + } + Ok(WaitOutcome::Terminal(SubagentStatus::Failed { error })) => Ok(ToolResult::error( + format!("Sub-agent `{task_id}` failed: {error}"), + )), + // `Running` is never terminal; treat defensively as a timeout-style result. + Ok(WaitOutcome::Terminal(SubagentStatus::Running)) + | Ok(WaitOutcome::TimedOut(_)) => Ok(ToolResult::success(format!( + "Sub-agent `{task_id}` is still running after {timeout_secs}s. \ + Continue with other work and call wait_subagent again later, or steer_subagent to redirect it." + ))), + Err(WaitError::Unknown) => Ok(ToolResult::error(format!( + "wait_subagent: no sub-agent with task_id `{task_id}`. It may have already finished and \ + been collected, or the task_id is wrong." + ))), + Err(WaitError::NotOwned) => Ok(ToolResult::error(format!( + "wait_subagent: sub-agent `{task_id}` was not started by this agent." + ))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schema_requires_task_id() { + let schema = WaitSubagentTool::new().parameters_schema(); + let required = schema + .get("required") + .and_then(|v| v.as_array()) + .expect("required list"); + assert!(required.iter().any(|v| v.as_str() == Some("task_id"))); + } + + #[tokio::test] + async fn missing_task_id_is_rejected() { + let res = WaitSubagentTool::new().execute(json!({})).await.unwrap(); + assert!(res.is_error); + assert!(res.output().contains("task_id")); + } + + #[tokio::test] + async fn outside_agent_turn_is_rejected() { + let res = WaitSubagentTool::new() + .execute(json!({ "task_id": "sub-1" })) + .await + .unwrap(); + assert!(res.is_error); + assert!(res.output().contains("outside of an agent turn")); + } +} diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 14ecbde7a..73df0702c 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -159,6 +159,13 @@ pub fn all_tools_with_runtime( // `agent::harness::subagent_runner` for the dispatch path. Box::new(SpawnSubagentTool::new()), Box::new(SpawnAsyncSubagentTool::new()), + // Steer a running async sub-agent mid-flight and collect its result: + // `steer_subagent { task_id, message }` injects into the child's + // run-queue (drained at its next iteration boundary), `wait_subagent + // { task_id }` blocks for the final output. See + // `agent_orchestration::running_subagents`. + Box::new(SteerSubagentTool::new()), + Box::new(WaitSubagentTool::new()), Box::new(ContinueSubagentTool::new()), Box::new(SpawnParallelAgentsTool::new()), Box::new(DelegateToPersonalityTool::new()), diff --git a/tests/inference_agent_raw_coverage_e2e.rs b/tests/inference_agent_raw_coverage_e2e.rs index f47aad56d..3d91bfa90 100644 --- a/tests/inference_agent_raw_coverage_e2e.rs +++ b/tests/inference_agent_raw_coverage_e2e.rs @@ -4642,6 +4642,7 @@ async fn agent_subagent_public_types_cover_task_local_and_error_display_paths() initial_history: None, checkpoint_dir: None, worktree_action_dir: None, + run_queue: None, }; assert_eq!(options.skill_filter_override.as_deref(), Some("docs")); assert_eq!(options.toolkit_override.as_deref(), Some("github"));