From 490111ef2c835a4600a7f1d6e41a95939fe664ff Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:41:54 +0530 Subject: [PATCH 01/18] fix(tinyflows): preserve explicit node timeouts, align discover timeout, log cap pins (#4877) --- .../EditableFlowCanvas.validation.test.tsx | 119 +++++++++++------- app/src/services/api/flowsApi.test.ts | 4 +- app/src/services/api/flowsApi.ts | 12 +- .../agent/task_dispatcher/executor.rs | 7 ++ src/openhuman/agent_registry/agents/loader.rs | 27 +++- src/openhuman/flows/ops.rs | 9 +- src/openhuman/flows/schemas.rs | 4 +- src/openhuman/skill_runtime/run_machinery.rs | 6 + src/openhuman/subconscious/profiles/memory.rs | 23 +++- src/openhuman/tinyflows/caps.rs | 66 +++++++++- 10 files changed, 214 insertions(+), 63 deletions(-) diff --git a/app/src/components/flows/canvas/__tests__/EditableFlowCanvas.validation.test.tsx b/app/src/components/flows/canvas/__tests__/EditableFlowCanvas.validation.test.tsx index 6636e9aa24..9a1a6450dc 100644 --- a/app/src/components/flows/canvas/__tests__/EditableFlowCanvas.validation.test.tsx +++ b/app/src/components/flows/canvas/__tests__/EditableFlowCanvas.validation.test.tsx @@ -1,18 +1,30 @@ /** * EditableFlowCanvas — validation UX (Phase 3c) + draft/dirty state (Phase 3d). * - * Drives the canvas through the public `FlowCanvas editable` entry point with a - * mocked `flowsApi` so `validateFlow` is deterministic. Covers: - * - an invalid graph shows the inline error banner, rings the offending node, - * and blocks Save; - * - a valid-with-warnings graph surfaces warnings distinctly and allows Save; - * - dirty tracking gates Save/Discard, Discard resets to baseline, and a - * successful Save clears the dirty flag. + * A canvas-refactor moved the Save / Discard / dirty-badge *buttons* out of + * this component and up into `FlowCanvasPage`'s header — the canvas now only + * exposes them through the `EditableFlowCanvasHandle` ref (`save()`/ + * `discard()`) and reports state up via `onSaveMetaChange` + * (`{ dirty, hasErrors, saving }`), same as `onDirtyChange`. See + * `FlowCanvasPage.test.tsx` for the header-button + confirm-dialog + RPC + * integration coverage (clicking `flow-editor-save`/`flow-editor-discard`). + * + * This file drives the canvas through the public `FlowCanvas editable` entry + * point with a mocked `flowsApi` so `validateFlow` is deterministic, and + * covers what `FlowCanvas`/`EditableFlowCanvas` itself still owns: + * - an invalid graph shows the inline error banner, rings the offending + * node, and the imperative `save()` handle refuses to fire `onSave`; + * - a valid-with-warnings graph surfaces warnings distinctly and still + * lets `save()` fire; + * - dirty tracking gates `save()`/`discard()`, `discard()` resets to + * baseline, and a successful `save()` clears the dirty flag. */ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { createRef } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { FlowNode } from '../../../../lib/flows/graphAdapter'; +import type { EditableFlowCanvasHandle, EditorSaveMeta } from '../EditableFlowCanvas'; import FlowCanvas from '../FlowCanvas'; const validateFlow = vi.hoisted(() => vi.fn()); @@ -38,16 +50,27 @@ function triggerNode(): FlowNode { const META = { schema_version: 1, id: 'wf_1', name: 'My flow' } as const; function renderCanvas(props: Partial> = {}) { - return render( + const ref = createRef(); + const onSaveMetaChange = vi.fn<(meta: EditorSaveMeta) => void>(); + const utils = render( ); + return { ...utils, ref, onSaveMetaChange }; +} + +/** Latest `{ dirty, hasErrors, saving }` the canvas reported to its host. */ +function lastSaveMeta(onSaveMetaChange: ReturnType void>>) { + const calls = onSaveMetaChange.mock.calls; + return calls[calls.length - 1][0]; } describe('EditableFlowCanvas — validation + dirty state', () => { @@ -57,24 +80,29 @@ describe('EditableFlowCanvas — validation + dirty state', () => { listFlowConnections.mockResolvedValue([]); }); - it('surfaces hard errors, rings the offending node, and blocks Save', async () => { + it('surfaces hard errors, rings the offending node, and blocks save()', async () => { validateFlow.mockResolvedValue({ valid: false, errors: ['invalid config for node t: missing schedule'], warnings: [], }); - const { container } = renderCanvas(); + const onSave = vi.fn().mockResolvedValue(undefined); + const { container, ref, onSaveMetaChange } = renderCanvas({ onSave }); - // Make an edit so the graph is dirty (Save is only ever enabled when dirty). + // Make an edit so the graph is dirty (Save is only ever attempted when + // dirty). Validation runs automatically on the debounce after the edit + // (the manual Validate button now lives on the selected node card). fireEvent.click(screen.getByTestId('flow-palette-item-agent')); - // Validation runs automatically on the debounce after the edit (the manual - // Validate button now lives on the selected node card). const errors = await screen.findByTestId('flow-editor-errors'); expect(errors).toHaveTextContent('invalid config for node t: missing schedule'); - // Save is blocked while there are hard errors, even though the graph is dirty. - expect(screen.getByTestId('flow-editor-save')).toBeDisabled(); + // The host header reads `hasErrors` off `onSaveMetaChange` to disable its + // Save button; the canvas itself also refuses to fire `onSave` through + // the imperative handle while hard errors exist, even though dirty. + expect(lastSaveMeta(onSaveMetaChange)).toMatchObject({ dirty: true, hasErrors: true }); + act(() => ref.current?.save()); + expect(onSave).not.toHaveBeenCalled(); // The named node ('t') is ringed with the error class on its RF wrapper. await waitFor(() => @@ -84,13 +112,14 @@ describe('EditableFlowCanvas — validation + dirty state', () => { ); }); - it('shows warnings distinctly from errors and allows Save', async () => { + it('shows warnings distinctly from errors and still lets save() fire', async () => { validateFlow.mockResolvedValue({ valid: true, errors: [], warnings: ['this trigger kind does not fire automatically yet'], }); - renderCanvas(); + const onSave = vi.fn().mockResolvedValue(undefined); + const { ref, onSaveMetaChange } = renderCanvas({ onSave }); fireEvent.click(screen.getByTestId('flow-palette-item-agent')); // Auto-validation (debounced) surfaces the warning. @@ -99,71 +128,77 @@ describe('EditableFlowCanvas — validation + dirty state', () => { expect(warnings).toHaveTextContent('does not fire automatically'); // A valid graph never renders the errors list… expect(screen.queryByTestId('flow-editor-errors')).not.toBeInTheDocument(); - // …and Save is allowed (warnings don't block). - expect(screen.getByTestId('flow-editor-save')).not.toBeDisabled(); + // …and warnings don't block Save. + expect(lastSaveMeta(onSaveMetaChange)).toMatchObject({ dirty: true, hasErrors: false }); + + act(() => ref.current?.save()); + expect(onSave).toHaveBeenCalledTimes(1); }); - it('tracks dirty state: Save/Discard gate on it, Discard resets, Save clears it', async () => { + it('tracks dirty state: save()/discard() gate on it, discard resets, save clears it', async () => { validateFlow.mockResolvedValue({ valid: true, errors: [], warnings: [] }); const onSave = vi.fn().mockResolvedValue(undefined); const onDirtyChange = vi.fn(); - renderCanvas({ onSave, onDirtyChange }); + const { ref, onSaveMetaChange } = renderCanvas({ onSave, onDirtyChange }); - // Pristine: no dirty badge, Save + Discard disabled. - expect(screen.queryByTestId('flow-editor-dirty')).not.toBeInTheDocument(); - expect(screen.getByTestId('flow-editor-save')).toBeDisabled(); - expect(screen.getByTestId('flow-editor-discard')).toBeDisabled(); + // Pristine: not dirty; save()/discard() both no-op through the imperative + // handle (the host header renders both buttons disabled in this state). + expect(lastSaveMeta(onSaveMetaChange)).toMatchObject({ dirty: false }); + act(() => ref.current?.discard()); + act(() => ref.current?.save()); + expect(onSave).not.toHaveBeenCalled(); // Edit → dirty. fireEvent.click(screen.getByTestId('flow-palette-item-agent')); - expect(screen.getByTestId('flow-editor-dirty')).toBeInTheDocument(); - expect(screen.getByTestId('flow-editor-save')).not.toBeDisabled(); - expect(screen.getByTestId('flow-editor-discard')).not.toBeDisabled(); expect(onDirtyChange).toHaveBeenLastCalledWith(true); + expect(lastSaveMeta(onSaveMetaChange)).toMatchObject({ dirty: true }); expect(screen.getAllByTestId('flow-node')).toHaveLength(2); // Discard → back to the single trigger, no longer dirty. - fireEvent.click(screen.getByTestId('flow-editor-discard')); + act(() => ref.current?.discard()); expect(screen.getAllByTestId('flow-node')).toHaveLength(1); - expect(screen.queryByTestId('flow-editor-dirty')).not.toBeInTheDocument(); expect(onDirtyChange).toHaveBeenLastCalledWith(false); + expect(lastSaveMeta(onSaveMetaChange)).toMatchObject({ dirty: false }); - // Edit again and Save → onSave called, dirty cleared once it resolves. + // Edit again and save() → onSave called, dirty cleared once it resolves. fireEvent.click(screen.getByTestId('flow-palette-item-agent')); - fireEvent.click(screen.getByTestId('flow-editor-save')); + act(() => ref.current?.save()); await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); const graph = onSave.mock.calls[0][0]; expect(graph.nodes.map((n: { kind: string }) => n.kind).sort()).toEqual(['agent', 'trigger']); - await waitFor(() => expect(screen.queryByTestId('flow-editor-dirty')).not.toBeInTheDocument()); - expect(onDirtyChange).toHaveBeenLastCalledWith(false); + await waitFor(() => expect(onDirtyChange).toHaveBeenLastCalledWith(false)); + await waitFor(() => expect(lastSaveMeta(onSaveMetaChange)).toMatchObject({ dirty: false })); }); - it('starts dirty when the host passes initialDirty (a remount carrying unsaved content)', async () => { + it('starts dirty when the host passes initialDirty (a remount carrying unsaved content)', () => { validateFlow.mockResolvedValue({ valid: true, errors: [], warnings: [] }); + const onSave = vi.fn().mockResolvedValue(undefined); const onDirtyChange = vi.fn(); // Mirrors `FlowCanvasPage` remounting the canvas (`key={canvasVersion}`) // after accepting a copilot proposal: the incoming nodes/edges ARE the // component's "initial" graph, so without `initialDirty` the canvas would // seed its baseline from them and instantly read as clean even though // nothing was persisted (the P1 this regression test guards against). - renderCanvas({ onDirtyChange, initialDirty: true }); + const { ref, onSaveMetaChange } = renderCanvas({ onSave, onDirtyChange, initialDirty: true }); - expect(screen.getByTestId('flow-editor-dirty')).toBeInTheDocument(); - expect(screen.getByTestId('flow-editor-save')).not.toBeDisabled(); expect(onDirtyChange).toHaveBeenLastCalledWith(true); + expect(lastSaveMeta(onSaveMetaChange)).toMatchObject({ dirty: true }); + act(() => ref.current?.save()); + expect(onSave).toHaveBeenCalledTimes(1); }); it('surfaces a Save failure inline and leaves the graph dirty', async () => { validateFlow.mockResolvedValue({ valid: true, errors: [], warnings: [] }); const onSave = vi.fn().mockRejectedValue(new Error('core unreachable')); - renderCanvas({ onSave }); + const onDirtyChange = vi.fn(); + const { ref } = renderCanvas({ onSave, onDirtyChange }); fireEvent.click(screen.getByTestId('flow-palette-item-agent')); - fireEvent.click(screen.getByTestId('flow-editor-save')); + act(() => ref.current?.save()); const saveError = await screen.findByTestId('flow-editor-save-error'); expect(saveError).toHaveTextContent('core unreachable'); // Still dirty — nothing persisted. - expect(screen.getByTestId('flow-editor-dirty')).toBeInTheDocument(); + expect(onDirtyChange).toHaveBeenLastCalledWith(true); }); }); diff --git a/app/src/services/api/flowsApi.test.ts b/app/src/services/api/flowsApi.test.ts index ad7af55e1e..9733aa1a96 100644 --- a/app/src/services/api/flowsApi.test.ts +++ b/app/src/services/api/flowsApi.test.ts @@ -340,7 +340,7 @@ describe('flowsApi', () => { expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.flows_discover', params: {}, - timeoutMs: 310_000, + timeoutMs: 610_000, }); expect(result).toEqual([suggestion]); }); @@ -353,7 +353,7 @@ describe('flowsApi', () => { expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.flows_discover', params: { thread_id: 'scout-thread-1' }, - timeoutMs: 310_000, + timeoutMs: 610_000, }); }); diff --git a/app/src/services/api/flowsApi.ts b/app/src/services/api/flowsApi.ts index f23f0eb8a3..c295251b84 100644 --- a/app/src/services/api/flowsApi.ts +++ b/app/src/services/api/flowsApi.ts @@ -799,12 +799,14 @@ export async function promoteDraft(id: string, requireApproval?: boolean): Promi /** * `openhuman.flows_discover` runs the read-only Flow Scout agent, which reasons - * over the user's memory/threads/connections/flows and can take up to ~300s - * server-side (`FLOW_DISCOVER_TIMEOUT_SECS` in `src/openhuman/flows/ops.rs`). - * Give the client a matching budget so a slow discovery run doesn't time out - * client-side while the agent is still thinking. + * over the user's memory/threads/connections/flows and can take up to ~600s + * server-side (`FLOW_DISCOVER_TIMEOUT_SECS` in `src/openhuman/flows/ops.rs`, + * raised to match `FLOW_BUILD_TIMEOUT_SECS` for the same iteration cap). Give + * the client a matching budget (mirrors {@link FLOW_BUILD_TIMEOUT_MS}) so a + * slow discovery run doesn't time out client-side while the agent is still + * thinking. */ -const FLOW_DISCOVER_TIMEOUT_MS = 310_000; +const FLOW_DISCOVER_TIMEOUT_MS = 610_000; /** * Run the Flow Scout discovery agent via `openhuman.flows_discover` and return diff --git a/src/openhuman/agent/task_dispatcher/executor.rs b/src/openhuman/agent/task_dispatcher/executor.rs index f002d8c14a..daabdc7d7a 100644 --- a/src/openhuman/agent/task_dispatcher/executor.rs +++ b/src/openhuman/agent/task_dispatcher/executor.rs @@ -157,6 +157,13 @@ pub(super) async fn run_autonomous( // post-construction override rather than a pre-set on `config` (which // the builder would otherwise clobber). agent.set_max_tool_iterations(TASK_RUN_MAX_ITERATIONS); + tracing::debug!( + agent_id = %executor.agent_id, + run_id = %run_id, + max_tool_iterations = TASK_RUN_MAX_ITERATIONS, + "[task_dispatcher] pinned autonomous task-run iteration budget post-construction \ + (overrides the session builder's per-definition cap)" + ); agent.set_event_context(run_id.to_string(), "task"); agent.set_agent_definition_name(format!( "task-{}-{}", diff --git a/src/openhuman/agent_registry/agents/loader.rs b/src/openhuman/agent_registry/agents/loader.rs index 7fd4eca00d..bcaa34a0cf 100644 --- a/src/openhuman/agent_registry/agents/loader.rs +++ b/src/openhuman/agent_registry/agents/loader.rs @@ -1039,19 +1039,41 @@ mod tests { ); match &def.tools { ToolScope::Named(names) => { + // Reconciled against `agent.toml`'s current `[tools].named` + // after the workflow-tools expansion PR widened the belt to + // agent-native editing/creation/run-control (`edit_workflow`, + // `validate_workflow`, `create_workflow`, `duplicate_flow`, + // `list_node_kinds`, `get_node_kind_contract`, + // `get_flow_history`, `list_flow_runs`, `resume_flow_run`, + // `cancel_flow_run`, `list_connectable_toolkits`) — these are + // the agent's own scoped tool surface, not the raw `flows_*` + // controller RPCs banned below, so the "no flow + // creation/enable via the raw controller" invariant still + // holds via the forbidden list. let expected = [ "propose_workflow", "revise_workflow", + "edit_workflow", + "validate_workflow", "save_workflow", "list_flows", "get_flow", + "get_flow_history", "get_flow_run", "list_flow_connections", "search_tool_catalog", "get_tool_contract", "get_tool_output_sample", "list_agent_profiles", + "list_connectable_toolkits", + "list_node_kinds", + "get_node_kind_contract", "dry_run_workflow", + "list_flow_runs", + "resume_flow_run", + "cancel_flow_run", + "create_workflow", + "duplicate_flow", "run_flow", "composio_list_toolkits", "composio_list_connections", @@ -1070,8 +1092,9 @@ mod tests { expected.len(), "workflow_builder scope must be EXACTLY the propose-or-read belt (got {names:?})" ); - // Hard exclusions: nothing that creates/enables a flow, - // executes raw integration actions, or touches the host. + // Hard exclusions: nothing that reaches the raw flow + // controller directly, executes raw integration actions, or + // touches the host. // (Persistence onto an EXISTING flow is the deliberate // `save_workflow` carve-out above; raw `flows_update` — which // could also rename/re-gate arbitrary flows — stays out.) diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index 44b73438a9..9f5bca5b12 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -3297,7 +3297,14 @@ fn notify_pending_approval(flow: &Flow, thread_id: &str, pending_approvals: &[St /// reasons read-only over the user's data and ends by emitting /// `suggest_workflows`; its own `max_iterations` caps the loop, but a hung /// LLM/tool call must never let the RPC block indefinitely. -const FLOW_DISCOVER_TIMEOUT_SECS: u64 = 300; +/// +/// Matches [`FLOW_BUILD_TIMEOUT_SECS`] (600s): the session builder applies the +/// `flow_discovery` definition's `effective_max_iterations()` (50, not the +/// global default of 10) to this path (issue #4868), so a worst-case run at +/// ~10s/iteration can take up to ~500s — the old 300s bound could clip a +/// legitimate long discovery run before the iteration cap ever got a chance +/// to (post-merge Codex P2 finding). +const FLOW_DISCOVER_TIMEOUT_SECS: u64 = 600; /// The canned brief handed to the `flow_discovery` agent. The agent's own /// archetype prompt teaches the read → correlate → ground → emit loop; this is diff --git a/src/openhuman/flows/schemas.rs b/src/openhuman/flows/schemas.rs index dac1f64167..feee1c1a7c 100644 --- a/src/openhuman/flows/schemas.rs +++ b/src/openhuman/flows/schemas.rs @@ -1694,6 +1694,7 @@ mod tests { "resume", "cancel_run", "list_runs", + "list_all_runs", "get_run", "prune_runs", "build", @@ -1719,7 +1720,7 @@ mod tests { #[test] fn all_registered_controllers_has_handler_per_schema() { let controllers = all_registered_controllers(); - assert_eq!(controllers.len(), 32); + assert_eq!(controllers.len(), 33); let names: Vec<_> = controllers.iter().map(|c| c.schema.function).collect(); assert_eq!( names, @@ -1738,6 +1739,7 @@ mod tests { "resume", "cancel_run", "list_runs", + "list_all_runs", "get_run", "prune_runs", "build", diff --git a/src/openhuman/skill_runtime/run_machinery.rs b/src/openhuman/skill_runtime/run_machinery.rs index 8d5dee3a72..f69bba0f1b 100644 --- a/src/openhuman/skill_runtime/run_machinery.rs +++ b/src/openhuman/skill_runtime/run_machinery.rs @@ -202,6 +202,12 @@ pub async fn spawn_workflow_run_background( // a post-construction override rather than a pre-set on `config` // (which the builder would otherwise clobber). agent.set_max_tool_iterations(WORKFLOW_RUN_MAX_ITERATIONS); + tracing::debug!( + run_id = %run_id, + max_tool_iterations = WORKFLOW_RUN_MAX_ITERATIONS, + "[skills] workflow_run: pinned iteration budget post-construction (overrides \ + the session builder's orchestrator definition cap)" + ); agent.set_event_context(run_id.clone(), "skill"); agent.set_agent_definition_name(format!( "orchestrator-skill-{}", diff --git a/src/openhuman/subconscious/profiles/memory.rs b/src/openhuman/subconscious/profiles/memory.rs index c9f6ef7ae9..49f818066c 100644 --- a/src/openhuman/subconscious/profiles/memory.rs +++ b/src/openhuman/subconscious/profiles/memory.rs @@ -166,6 +166,13 @@ impl MemoryProfile { warn!("[subconscious:memory] agent init failed: {e}"); format!("agent init: {e}") })?; + // Stable per-tick correlation id — minted once and reused below for + // the pin log, the agent's event context, and the turn origin's + // `job_id`, so `mode`/`max_tool_iterations` (which repeat across + // ticks) don't leave concurrent/successive subconscious ticks + // indistinguishable in logs. + let tick_id = format!("subconscious:tick:{}", now_secs() as u64); + // Issue #4868 — `Agent::from_config` builds as the `orchestrator` // definition (max_iterations=15, strict), so the session builder // would stamp orchestrator's cap onto this agent regardless of mode @@ -173,12 +180,16 @@ impl MemoryProfile { // 30-iteration budget set above to 15. Re-apply the mode-specific // cap post-construction so this tick keeps its previous behavior. agent.set_max_tool_iterations(mode_iteration_cap); - - agent.set_event_context( - format!("subconscious:tick:{}", now_secs() as u64), - "subconscious", + debug!( + tick_id = %tick_id, + "[subconscious:memory] pinned mode-specific iteration budget post-construction: \ + mode={:?} max_tool_iterations={} (overrides the session builder's orchestrator \ + definition cap)", + self.mode, mode_iteration_cap ); + agent.set_event_context(tick_id.clone(), "subconscious"); + let mode_guidance = match self.mode { SubconsciousMode::Aggressive | SubconsciousMode::EventDriven => { "\n\nYou may delegate deeper work with `spawn_async_subagent` (e.g. research \ @@ -201,10 +212,10 @@ impl MemoryProfile { ticks. Do not invent busywork.{mode_guidance}", ); - debug!("[subconscious:memory] spawning decision agent"); + debug!(tick_id = %tick_id, "[subconscious:memory] spawning decision agent"); let source = tick_origin_source(has_external_content); let origin = crate::openhuman::agent::turn_origin::AgentTurnOrigin::TrustedAutomation { - job_id: format!("subconscious:tick:{}", now_secs() as u64), + job_id: tick_id.clone(), source, }; let response = crate::openhuman::agent::turn_origin::with_origin( diff --git a/src/openhuman/tinyflows/caps.rs b/src/openhuman/tinyflows/caps.rs index c3db0c133e..486fb9f864 100644 --- a/src/openhuman/tinyflows/caps.rs +++ b/src/openhuman/tinyflows/caps.rs @@ -680,6 +680,35 @@ pub(crate) fn scale_timeout_for_iteration_cap( } } +/// Resolves the actual wall-clock timeout for one agent-node harness turn, +/// combining [`clamp_run_timeout_secs`] and [`scale_timeout_for_iteration_cap`] +/// per the post-merge Codex P2 finding on issue #4868's iteration-cap timeout +/// scaling: **an explicit `timeout_secs` the flow author set on the node must +/// never be scaled up.** +/// +/// A node's `timeout_secs` can be an intentional fast-fail/SLA bound (e.g. +/// `timeout_secs: 120` to bound a health-check-style agent call) — scaling +/// that up to match a 50-iteration-cap agent would silently defeat the +/// author's explicit choice. So the iteration-cap scaling only ever widens +/// the *default* (no `timeout_secs` supplied) 240s bound; an explicit value is +/// clamped to `10..=600` (as it always was) and returned as-is. +/// +/// `requested_timeout_secs` is the raw `request["timeout_secs"]` (before +/// clamping) so this function can distinguish "caller supplied a value" from +/// "caller supplied nothing" — [`clamp_run_timeout_secs`] alone collapses that +/// distinction into a plain `u64`. +pub(crate) fn resolve_run_timeout_secs( + requested_timeout_secs: Option, + effective_iteration_cap: usize, +) -> u64 { + let base_timeout_secs = clamp_run_timeout_secs(requested_timeout_secs); + if requested_timeout_secs.is_some() { + base_timeout_secs + } else { + scale_timeout_for_iteration_cap(base_timeout_secs, effective_iteration_cap) + } +} + /// Renders an agent-node completion `request` into the single user message /// [`Agent::run_single`](crate::openhuman::agent::Agent::run_single) takes: the /// `prompt` string when present and non-empty, else the `messages` array @@ -923,19 +952,24 @@ impl OpenHumanAgentRunner { let prompt = build_harness_run_prompt(&request); - let base_timeout_secs = - clamp_run_timeout_secs(request.get("timeout_secs").and_then(Value::as_u64)); + let requested_timeout_secs = request.get("timeout_secs").and_then(Value::as_u64); + let base_timeout_secs = clamp_run_timeout_secs(requested_timeout_secs); // Issue #4868 — the session builder now stamps `agent_ref`'s own // `effective_max_iterations()` onto the agent (instead of the global // default of 10), so `code_executor`/`tools_agent`/etc. can run up to // 50 iterations here. Read the cap actually applied to `agent` // (reflects the definition cap or the global fallback, whichever the - // builder resolved) and scale the timeout accordingly — see + // builder resolved) and scale the DEFAULT timeout accordingly — see // `scale_timeout_for_iteration_cap`. + // + // Post-merge Codex P2 finding: an EXPLICIT `timeout_secs` the node + // config supplied is a caller-chosen bound (e.g. a fast-fail/SLA of + // 120s) and must be honored as-is, never scaled up just because the + // agent's iteration cap is high — see `resolve_run_timeout_secs`. let effective_iteration_cap = agent.agent_config().max_tool_iterations; let timeout_secs = - scale_timeout_for_iteration_cap(base_timeout_secs, effective_iteration_cap); + resolve_run_timeout_secs(requested_timeout_secs, effective_iteration_cap); tracing::debug!( target: "flows", @@ -943,6 +977,7 @@ impl OpenHumanAgentRunner { node_model = node_model.as_deref().unwrap_or(""), default_model = effective.default_model.as_deref().unwrap_or(""), effective_iteration_cap, + explicit_timeout_secs = requested_timeout_secs.is_some(), base_timeout_secs, timeout_secs, prompt_len = prompt.len(), @@ -4165,6 +4200,29 @@ mod tests { assert_eq!(scale_timeout_for_iteration_cap(240, 200), 600); } + /// Post-merge Codex P2 finding on issue #4868: an explicit `timeout_secs` + /// the node config supplied (a caller-chosen fast-fail/SLA bound) must be + /// honored as-is — never scaled up just because the agent's iteration cap + /// is high — while the absence of one still gets the iteration-cap + /// scaling so a 50-iteration agent isn't killed by the 240s default. + #[test] + fn resolve_run_timeout_secs_preserves_an_explicit_request_even_for_a_high_cap_agent() { + assert_eq!(resolve_run_timeout_secs(Some(120), 50), 120); + } + + #[test] + fn resolve_run_timeout_secs_scales_the_default_up_for_a_high_cap_agent() { + // No explicit timeout_secs (None) -> default 240s, scaled by the + // 50-iteration cap to min(50*12, 600) = 600. + assert_eq!(resolve_run_timeout_secs(None, 50), 600); + } + + #[test] + fn resolve_run_timeout_secs_leaves_low_cap_agents_unscaled_either_way() { + assert_eq!(resolve_run_timeout_secs(None, 10), 240); + assert_eq!(resolve_run_timeout_secs(Some(120), 10), 120); + } + /// Regression for issue #4868: the agent-node runtime path /// (`OpenHumanAgentRunner::run_via_harness`) must build an `Agent` that /// carries `agent_ref`'s definition's effective cap (50 for an From ad771fdc4e359c5d961361b4f6d2ea1f4a4200db Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:58:22 +0300 Subject: [PATCH 02/18] =?UTF-8?q?feat(flows):=20workflow-builder=20agent-f?= =?UTF-8?q?riendliness=20plumbing=20=E2=80=94=20schema-aware=20sandbox,=20?= =?UTF-8?q?unified=20draft=20handles,=20reference=20gates=20(#4881)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../flows/agents/workflow_builder/prompt.md | 103 ++- src/openhuman/flows/builder_tools.rs | 762 +++++++++++++++--- src/openhuman/flows/builder_tools_tests.rs | 707 ++++++++++++++++ src/openhuman/flows/ops.rs | 402 ++++++++- src/openhuman/flows/ops_tests.rs | 245 ++++++ src/openhuman/flows/tools.rs | 4 + src/openhuman/flows/tools_tests.rs | 3 + src/openhuman/tinyflows/caps.rs | 121 +++ vendor/tinyflows | 2 +- 9 files changed, 2206 insertions(+), 143 deletions(-) diff --git a/src/openhuman/flows/agents/workflow_builder/prompt.md b/src/openhuman/flows/agents/workflow_builder/prompt.md index 7d8430866f..3a643e3496 100644 --- a/src/openhuman/flows/agents/workflow_builder/prompt.md +++ b/src/openhuman/flows/agents/workflow_builder/prompt.md @@ -13,11 +13,15 @@ no tool that does — by design. Your authoring outputs are: - **`propose_workflow`** / **`revise_workflow`** — these *validate* a candidate graph and hand back a proposal summary. They **never** save anything. -- **`dry_run_workflow`** — runs a *draft* in a **sandbox** against mock +- **`dry_run_workflow`** — runs a graph in a **sandbox** against mock capabilities (deterministic echoes). Nothing real happens: no message is sent, - no code runs, no HTTP fires. Treat its output as a wiring check only. + no code runs, no HTTP fires. Treat its output as a wiring check only. Takes the + graph as any of `draft_id` / `flow_id` / an inline `graph` (precedence + `draft_id` > `flow_id` > `graph`). - **`save_workflow`** — the ONE persistence tool you have, and it only writes to - a flow that **already exists** (you need its `flow_id`). See below. + a flow that **already exists** (you need its `flow_id` as the target). Its + source is a `draft_id` (the usual case after iterating with `edit_workflow`) OR + an inline `graph`. See below. Persisting is otherwise the user's own action, not a tool you have — the one exception is `save_workflow` on an **existing** flow id, and only when the @@ -39,8 +43,9 @@ default. Your arc is: **When the user says "save it":** if you have a `save_workflow` action available — an **existing** `flow_id` plus their explicit ask ("save this", -"yes save it onto flow_X") — just call `save_workflow { flow_id, graph, -name? }` and confirm in one plain line what you saved (trigger, steps, and — +"yes save it onto flow_X") — just call `save_workflow { flow_id, draft_id, +name? }` (pass the `draft_id` you've been iterating on; an inline `graph` +also works) and confirm in one plain line what you saved (trigger, steps, and — if the flow is enabled with a schedule/app_event trigger — that it's now live and will fire on its own). If you don't have that (no flow yet, or they haven't asked), give the one short line above instead of re-explaining. @@ -91,13 +96,19 @@ rather than a general context recall), use `memory_hybrid_search` in its - `search_tool_catalog { query, toolkit? }` → real Composio action **slugs** from the FULL LIVE catalog for ANY named app — connected or not, curated or not (curated matches come back `featured: true` and are - ranked first). **Never hallucinate a slug** — if the catalog has no - match for the app, prefer an `http_request` node or tell the user the - integration isn't available. Each match also carries `required_args` / - `output_fields` / `primary_array_path` — but call `get_tool_contract - { slug }` before you actually WIRE a match: it hands back the exact - required args, the full input/output schema, and the array path a - `split_out` should use (see `tool_call` below). `propose_workflow` / + ranked first; a match may also carry `runtime_gated: true`, meaning that + action is blocked on real runs — prefer a `featured` one instead). + **Prefer ONE short keyword** (e.g. `gmail`, `send email`) for the widest + listing; a multi-word query that finds nothing no longer dead-ends — it + falls back to the nearest per-keyword matches with an explanatory `note`, + so read that note rather than assuming the app is missing. **Never + hallucinate a slug** — if the catalog genuinely has no match, prefer an + `http_request` node or tell the user the integration isn't available. Each + match also carries `required_args` / `output_fields` / `primary_array_path` + — but call `get_tool_contract { slug }` before you actually WIRE a match: it + hands back the exact required args, the full input/output schema, and the + array path a `split_out` should use (see `tool_call` below). + `propose_workflow` / `revise_workflow` / `save_workflow` HARD-REJECT a `tool_call` whose slug isn't real in the live catalog, or that's missing one of its real required args — so grounding here isn't optional polish, it's what @@ -117,13 +128,29 @@ You have a machine-readable belt; use it instead of relying on memory: gotchas. Consult these instead of guessing config shapes (this is the source of truth; the summary below is just orientation). - **Iterate cheaply:** once a draft exists, prefer `edit_workflow { draft_id | - flow_id | graph, ops[] }` (add_node / update_node_config[merge-patch] / - rename_node / add_edge / remove_edge / …) over re-emitting the whole graph - with `revise_workflow` — it's fewer tokens and won't drop a node or mangle an - edge. Edits to a `draft_id` are written back to the shared draft. -- **Check without proposing:** `validate_workflow { graph | flow_id }` runs the - same structural + hard-gate stack and returns every problem at once, so you - can self-verify mid-build without emitting a proposal card. + flow_id | graph, ops[] }` over re-emitting the whole graph with + `revise_workflow` — it's fewer tokens and won't drop a node or mangle an edge. + The op shapes (each is `{ "op": , … }`; `id` also accepts the alias + `node_id`, and `rename_node`'s `new_id` accepts `new_node_id`): + `add_node {node}` · `update_node_config {id, config}` (a JSON merge-patch — a + `null` value deletes that config key) · `set_node_name {id, name}` · + `rename_node {id, new_id}` (rewires edges) · `remove_node {id}` (drops its + edges) · `add_edge {edge}` · `remove_edge {from_node, to_node, from_port?, + to_port?}` · `set_node_position {id, position}`. Ops apply **strictly in array + order**, so to replace a node put its `remove_node` BEFORE the `add_node` (or + just `update_node_config` in place) — an "id already exists" error is almost + always that ordering slip. A bad op's error names the failing op index and the + exact shape that op wanted; fix and call again. + **Persistence:** `edit_workflow` NEVER saves. Editing a `flow_id` **seeds a new + draft** from that flow (the flow itself is untouched) and returns its + `draft_id`; editing a `draft_id` writes back to that same draft. The result + always carries `persisted: false` plus a `next` hint — keep iterating by + passing the returned `draft_id` to `edit_workflow` / `dry_run_workflow`, and + persist only on the user's explicit ask with `save_workflow { flow_id, + draft_id }`. A proposal is never a save. +- **Check without proposing:** `validate_workflow { draft_id | flow_id | graph }` + runs the same structural + hard-gate stack and returns every problem at once, + so you can self-verify mid-build without emitting a proposal card. - **Steer connections:** `list_connectable_toolkits` flags which toolkits are already connected — prefer those; the proposal's `required_connections` enumerates what still needs linking. @@ -300,6 +327,20 @@ A `WorkflowGraph` is `{ name?, nodes: [...], edges: [...] }`. slug isn't a real action in the live Composio catalog for its toolkit — a hallucinated or typo'd slug never makes it past validation, so always ground `config.slug` in a `search_tool_catalog` result first. + - **The `connection_ref` is enforced against the RIGHT toolkit.** + `config.connection_ref` must read `composio::` where + `` matches the slug's toolkit AND `` is one of the user's real + connections **for that toolkit** — get each ref verbatim from + `list_flow_connections`. Copying an id from a DIFFERENT toolkit (e.g. a + TikTok connection id onto a Gmail node) is HARD-REJECTED at + `propose_workflow`/`revise_workflow`/`save_workflow`, naming the correct + ref — so never reuse an id across toolkits. + - **`get_tool_contract` may return a top-level `runtime_gate` warning.** For + an uncurated action of a toolkit that ships a curated catalog, the real + runtime tool gate allows curated actions only, so that action is REJECTED + on every real run. Treat a `runtime_gate` warning as a **hard stop**: go + back to `search_tool_catalog` and pick a `featured: true` action instead of + wiring the gated one. - **Wiring a DOWNSTREAM node off THIS tool's output?** Don't guess the field name (e.g. assuming `GMAIL_FETCH_EMAILS` returns `.messages`) — `get_tool_contract`'s `output_fields` names the action's REAL top-level @@ -585,7 +626,11 @@ names, `dry_run_workflow` again, and repeat until it comes back clean. Only then call `propose_workflow` / `save_workflow`. Don't hand back a proposal you haven't verified just because the turn has run long — the user would rather wait one more tool call than review a graph that silently does -nothing. +nothing. **One exception:** a `null_resolutions` entry flagged `unverifiable: +true` (or an `unverifiable_bindings` list) is a Composio-upstream binding the +sandbox genuinely can't check — confirm it with `get_tool_contract` rather +than re-wiring, and don't loop on it (see "Interpreting dry-run results +honestly" below). ### Interpreting dry-run results honestly @@ -613,13 +658,27 @@ values appear: entry and the dry run returns `ok: false`. **These are real bugs — never dismiss them.** Fix every one before proposing. +3. **Unverifiable Composio-upstream bindings** — a `null_resolutions` entry + may carry `unverifiable: true` and an `upstream_tool_call` when the required + arg binds to the OUTPUT of a Composio `tool_call` node (an early-abort dry + run surfaces the same class as `unverifiable_bindings`). The echo sandbox + can never produce a Composio tool's real output fields, so this null does + **NOT** prove the binding wrong — it is genuinely unknowable here. Do **not** + thrash re-wiring it. Confirm the path against `get_tool_contract`'s + `output_fields` / `primary_array_path` (remember Composio results nest under + `.item.json.data.`), or `get_tool_output_sample { slug, args }` for the real + shape; it's a bug only if the path doesn't match the action's actual output. + The propose/save gate no longer blocks on this class, so a graph whose only + flag is `unverifiable` bindings you've confirmed is fine to propose. + #### Sandbox mock behavior per node type (authoritative — do NOT probe) | Node kind | Sandbox output | Enveloped? | What resolves downstream | |-----------|----------------|------------|---------------------------| | `trigger` | Passthrough — echoes the `input` value (default `{}`) | No | Whatever was passed as `input` | -| `agent` (with `output_parser.schema`) | Typed placeholder per schema property (`string`→`""`, `number`/`integer`→`0`, `boolean`→`false`, `object`→`{}`, `array`→`[]`, `enum`→its first listed value) | Yes | `=nodes..item.json.` → the placeholder (non-null) | -| `agent` (no schema) | `{ "agent": "", "request": {...}, "connection": ... }` | Yes | Only `.json.agent` / `.json.request` / `.json.connection` resolve; any other `.json.` → null | +| `agent` (with `output_parser.schema`) | Typed placeholder per schema property (`string`→`""`, `number`/`integer`→`0`, `boolean`→`false`, `object`→`{}`, `array`→`[]`, `enum`→its first listed value). Applies to **every** agent node — plain or with an `agent_ref` | Yes | `=nodes..item.json.` → the placeholder (non-null) | +| `agent` (no schema, plain — no `agent_ref`) | `{ "completion": , "connection": ... }` (the mock LLM echo) | Yes | Only `.json.completion` / `.json.connection` resolve; any other `.json.` → null | +| `agent` (no schema, with `agent_ref`) | `{ "agent": "", "request": {...}, "connection": ... }` | Yes | Only `.json.agent` / `.json.request` / `.json.connection` resolve; any other `.json.` → null | | `tool_call` | Required Composio args are preflight-checked first (missing/null → dry run fails before the mock even runs), then echoes `{ "tool": "", "args": {...}, "connection": ... }` — NOT a real API response | Yes | `.json.tool` / `.json.args` / `.json.connection` resolve; a response-shaped field (e.g. `.json.data.` for a real Composio call — see "the envelope" above) does **not**, because the mock echo carries no `data` wrapper. That is a mock-shape gap, not a wiring bug — don't "fix" a correctly-wired `.json.data.` binding just because the dry run can't resolve it | | `http_request` | `{ "status": 200, "request": {...}, "connection": ... }` | Yes | `.json.status` → `200`; response-body fields → null | | `code` | `{ "result": }` — the real `source` is NOT executed | **No** | `.item.result` resolves directly (no `.json.` — `code` does not envelope) | diff --git a/src/openhuman/flows/builder_tools.rs b/src/openhuman/flows/builder_tools.rs index 599d90b0ab..21a49b19a6 100644 --- a/src/openhuman/flows/builder_tools.rs +++ b/src/openhuman/flows/builder_tools.rs @@ -64,6 +64,30 @@ use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; /// capabilities are non-blocking echoes, so this is a generous safety net. const DRY_RUN_TIMEOUT_SECS: u64 = 30; +/// Comma list of the valid `op` tag values, for the missing-/unknown-`op` +/// parse errors surfaced by [`EditWorkflowTool`]. +const VALID_OP_TYPES: &str = "add_node, update_node_config, set_node_name, rename_node, \ + remove_node, add_edge, remove_edge, set_node_position"; + +/// The expected field shape for a given `op` tag, used in `edit_workflow`'s +/// per-op parse diagnostics so a failing op tells the agent exactly what that +/// op type wants. Returns `None` for an unrecognized tag. +fn edit_op_shape(op: &str) -> Option<&'static str> { + Some(match op { + "add_node" => "{ op, node: { id, kind, name, config? } }", + "update_node_config" => { + "{ op, id, config } (id also accepts alias `node_id`; config is a JSON merge-patch)" + } + "set_node_name" => "{ op, id, name } (id also accepts alias `node_id`)", + "rename_node" => "{ op, id, new_id } (also accept aliases `node_id` / `new_node_id`)", + "remove_node" => "{ op, id } (id also accepts alias `node_id`)", + "add_edge" => "{ op, edge: { from_node, to_node, from_port?, to_port? } }", + "remove_edge" => "{ op, from_node, to_node, from_port?, to_port? }", + "set_node_position" => "{ op, id, position: { x, y } } (id also accepts alias `node_id`)", + _ => return None, + }) +} + // ───────────────────────────────────────────────────────────────────────────── // revise_workflow — iterative refine of an existing draft (proposal only) // ───────────────────────────────────────────────────────────────────────────── @@ -191,6 +215,10 @@ impl Tool for ReviseWorkflowTool { require_approval, true, instruction, + // revise_workflow takes only an inline graph — no draft/flow handle + // to echo. The payload still carries persisted:false unconditionally. + None, + None, ) .await { @@ -240,10 +268,14 @@ impl Tool for EditWorkflowTool { {id, config} (JSON merge-patch — a null value deletes that config key), set_node_name \ {id, name}, rename_node {id, new_id} (rewires edges), remove_node {id} (drops its edges), \ add_edge {edge}, remove_edge {from_node, to_node, from_port?, to_port?}, set_node_position \ - {id, position}. Like propose/revise_workflow this ONLY VALIDATES and returns a proposal \ - for the user to review — it never creates, updates, or enables the flow. If an op fails or \ - the resulting graph is invalid, the error names the failing op / node; fix it and call \ - edit_workflow again." + {id, position}. PERSISTENCE: the applied edit is written to a DRAFT, never onto the saved \ + flow — this tool NEVER saves. Editing a flow_id SEEDS A NEW DRAFT from that flow's graph \ + and returns its `draft_id`; editing a draft_id writes back to that same draft. The result \ + carries `draft_id`, `flow_id` (if any), `persisted: false`, and a `next` hint. To keep \ + iterating pass that `draft_id` (to edit_workflow / dry_run_workflow); to persist, call \ + save_workflow { flow_id, draft_id } when the user asks. If an op fails or the resulting \ + graph is invalid, the error names the failing op / node; fix it and call edit_workflow \ + again." } fn parameters_schema(&self) -> Value { @@ -314,9 +346,16 @@ impl Tool for EditWorkflowTool { .filter(|s| !s.is_empty()); let inline_graph = args.get("graph").filter(|v| !v.is_null()); - // When editing a draft, remember its id so the applied edit is written - // back — the draft is the durable working copy across turns/reloads. + // The applied edit is always written back to a durable DRAFT (the shared + // working copy across turns/reloads). `write_back_draft` is the draft id + // it lands on; `edited_from_flow` is the saved flow this edit derives + // from / would persist onto, if any. The core WS2 fix: editing a bare + // `flow_id` used to persist NOTHING and return NO handle — the edit was + // unreachable and read as "written onto the flow". Now a `flow_id` base + // seeds a NEW draft, so the edit is durable, addressable, and clearly + // NOT the saved flow. let mut write_back_draft: Option = None; + let mut edited_from_flow: Option = None; let (base_graph, default_name) = match (draft_id, flow_id, inline_graph) { (Some(id), _, _) => match ops::flows_draft_get(&self.config, id) { @@ -325,6 +364,9 @@ impl Tool for EditWorkflowTool { match ops::migrate_and_deserialize_graph(draft.graph.clone()) { Ok(graph) => { write_back_draft = Some(draft.id.clone()); + // A draft may already be linked to a saved flow — + // carry that through so the proposal echoes it. + edited_from_flow = draft.flow_id.clone(); (graph, draft.name) } Err(e) => { @@ -341,7 +383,47 @@ impl Tool for EditWorkflowTool { } }, (None, Some(id), _) => match ops::flows_get(&self.config, id).await { - Ok(outcome) => (outcome.value.graph, outcome.value.name), + Ok(outcome) => { + let flow = outcome.value; + // Seed a NEW draft from the saved flow's graph so the edit is + // durable and reachable (the RPC/canvas path uses the same + // `flows_draft_create` op). Linking the draft to `flow.id` + // means a later save_workflow { flow_id, draft_id } knows its + // target. + let graph_json = match serde_json::to_value(&flow.graph) { + Ok(v) => v, + Err(e) => { + return Ok(ToolResult::error(format!( + "Could not serialize flow '{id}' to seed a draft: {e}" + ))); + } + }; + match ops::flows_draft_create( + &self.config, + Some(flow.id.clone()), + flow.name.clone(), + graph_json, + crate::openhuman::flows::DraftOrigin::Chat, + ) { + Ok(created) => { + let new_draft_id = created.value.id.clone(); + tracing::debug!( + target: "flows", + draft_id = %new_draft_id, + flow_id = %flow.id, + "[flows] edit_workflow: seeded a new draft from saved flow (edits live on the draft, NOT the flow)" + ); + write_back_draft = Some(new_draft_id); + edited_from_flow = Some(flow.id.clone()); + (flow.graph, flow.name) + } + Err(e) => { + return Ok(ToolResult::error(format!( + "Could not create a draft to edit flow '{id}': {e}" + ))); + } + } + } Err(e) => { return Ok(ToolResult::error(format!( "Could not load flow '{id}' to edit: {e}" @@ -370,31 +452,46 @@ impl Tool for EditWorkflowTool { } }; - // Parse the ops list. - let ops_value = match args.get("ops") { - Some(v) if v.is_array() => v.clone(), + // Parse the ops list element-by-element so a bad op reports its index, + // its `op` tag, the serde error, AND the expected field shape for THAT + // op type — instead of a bare aggregate "missing field `id`" that names + // neither the failing op nor what it wanted (audit WS4). + let ops_array = match args.get("ops") { + Some(Value::Array(items)) => items.clone(), _ => { return Ok(ToolResult::error( "Missing 'ops' parameter (a non-empty array of structured edits).".to_string(), )); } }; - let graph_ops: Vec = match serde_json::from_value(ops_value) - { - Ok(ops) => ops, - Err(e) => { - return Ok(ToolResult::error(format!( - "Could not parse `ops`: {e}. Each op is {{ \"op\": , ... }} — valid \ - types: add_node, update_node_config, set_node_name, rename_node, \ - remove_node, add_edge, remove_edge, set_node_position." - ))); - } - }; - if graph_ops.is_empty() { + if ops_array.is_empty() { return Ok(ToolResult::error( "`ops` is empty — provide at least one edit.".to_string(), )); } + let mut graph_ops: Vec = Vec::with_capacity(ops_array.len()); + for (index, item) in ops_array.into_iter().enumerate() { + let op_tag = item.get("op").and_then(Value::as_str).map(str::to_string); + match serde_json::from_value::(item) { + Ok(op) => graph_ops.push(op), + Err(e) => { + let shape = match op_tag.as_deref() { + Some(tag) => match edit_op_shape(tag) { + Some(shape) => format!("op `{tag}` expects {shape}"), + None => { + format!("unknown op type `{tag}` — valid types: {VALID_OP_TYPES}") + } + }, + None => format!("missing `op` field — valid types: {VALID_OP_TYPES}"), + }; + tracing::debug!(target: "flows", index, ?op_tag, error = %e, "[flows] edit_workflow: op failed to parse"); + return Ok(ToolResult::error(format!( + "Could not parse op {index}: {e}. Expected {shape}. Each op is \ + {{ \"op\": , ... }}. Fix the ops and call edit_workflow again." + ))); + } + } + } let name = args .get("name") @@ -430,8 +527,22 @@ impl Tool for EditWorkflowTool { Ok(graph) => graph, Err(e) => { tracing::debug!(target: "flows", %name, error = %e, "[flows] edit_workflow: an op failed to apply"); + // Ops apply strictly in array order, so an add_node for an id + // that already exists is almost always an ordering mistake + // (adding before removing the old node). Point at the fix — this + // is the exact 2nd wasted call the WS4 audit caught. + let hint = match (e.op, &e.kind) { + ("add_node", tinyflows::graph_ops::GraphOpErrorKind::NodeIdExists(id)) => { + format!( + "\n\nOps apply strictly in array order. To replace node `{id}`, put a \ + remove_node op for it BEFORE the add_node, or use update_node_config \ + to patch it in place." + ) + } + _ => String::new(), + }; return Ok(ToolResult::error(format!( - "{e}\n\nFix the ops and call edit_workflow again." + "{e}{hint}\n\nFix the ops and call edit_workflow again." ))); } }; @@ -469,6 +580,8 @@ impl Tool for EditWorkflowTool { } // Full builder hard-gate stack + proposal payload (shared with revise). + // Thread the persistence-state handles so the payload carries draft_id / + // flow_id / persisted:false and can't be misread as a save. match ops::build_builder_proposal( &self.config, "edit_workflow", @@ -477,12 +590,32 @@ impl Tool for EditWorkflowTool { require_approval, true, instruction, + write_back_draft.clone(), + edited_from_flow.clone(), ) .await { Ok(mut payload) => { - if let Some(draft_id) = write_back_draft { - payload["draft_id"] = json!(draft_id); + // A prominent, one-line pointer at where the edit actually lives + // (the draft) vs. where it does NOT (the saved flow) — the exact + // confusion the WS2 audit caught. Only meaningful when the edit + // landed on a draft (inline-graph edits have no durable handle). + if let Some(draft_id) = write_back_draft.as_deref() { + let next = match edited_from_flow.as_deref() { + Some(flow_id) => format!( + "Edits live on draft {draft_id}, NOT on flow {flow_id}. Iterate with \ + edit_workflow/dry_run_workflow {{ draft_id: \"{draft_id}\" }}, then \ + persist with save_workflow {{ flow_id: \"{flow_id}\", draft_id: \ + \"{draft_id}\" }} when the user asks." + ), + None => format!( + "Edits live on draft {draft_id} (not yet linked to a saved flow). \ + Iterate with edit_workflow/dry_run_workflow {{ draft_id: \ + \"{draft_id}\" }}, then persist with create_workflow, or save_workflow \ + {{ flow_id, draft_id: \"{draft_id}\" }} once a flow exists." + ), + }; + payload["next"] = json!(next); } Ok(ToolResult::success(serde_json::to_string_pretty(&payload)?)) } @@ -524,25 +657,31 @@ impl Tool for ValidateWorkflowTool { fn description(&self) -> &str { "Check a workflow graph WITHOUT proposing or saving it — the same validation the \ propose/revise/edit/save tools run, surfaced on its own so you can verify a draft mid-\ - build. Provide the graph to check (inline `graph`, or `flow_id` for a saved flow). \ - Returns { ok, structurally_valid, errors, error_details:[{code, message, node_id}], \ - gate_errors, warnings }: `errors` lists EVERY structural problem at once; `gate_errors` \ - lists the hard author-gate failures (unresolvable bindings, unreal tool slugs, unwired \ - required args) checked only once the graph is structurally valid; `warnings` are \ - non-fatal. `ok` is true only when there are no errors and no gate_errors. Read-only." + build. Provide the graph to check as exactly one of `draft_id` (a working draft), \ + `flow_id` (a saved flow), or inline `graph` (if several are given, draft_id wins, then \ + flow_id). Returns { ok, structurally_valid, errors, error_details:[{code, message, \ + node_id}], gate_errors, warnings }: `errors` lists EVERY structural problem at once; \ + `gate_errors` lists the hard author-gate failures (unresolvable bindings, unreal tool \ + slugs, unwired required args) checked only once the graph is structurally valid; \ + `warnings` are non-fatal. `ok` is true only when there are no errors and no gate_errors. \ + Read-only." } fn parameters_schema(&self) -> Value { json!({ "type": "object", "properties": { + "draft_id": { + "type": "string", + "description": "A working draft to validate. Provide one of draft_id / flow_id / graph (draft_id wins)." + }, "flow_id": { "type": "string", - "description": "A saved flow to validate. Provide this OR `graph`." + "description": "A saved flow to validate. Provide one of draft_id / flow_id / graph." }, "graph": { "type": "object", - "description": "An inline tinyflows WorkflowGraph to validate. Provide this OR `flow_id`.", + "description": "An inline tinyflows WorkflowGraph to validate. Provide one of draft_id / flow_id / graph.", "properties": { "nodes": { "type": "array" }, "edges": { "type": "array" } @@ -561,7 +700,14 @@ impl Tool for ValidateWorkflowTool { } async fn execute(&self, args: Value) -> anyhow::Result { - // Resolve the graph to check from either a saved flow or an inline graph. + // Resolve the graph to check from exactly one of a working draft, a + // saved flow, or an inline graph — same precedence (draft_id > flow_id > + // graph) as edit_workflow, so the sibling tools accept the same handles. + let draft_id = args + .get("draft_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()); let flow_id = args .get("flow_id") .and_then(Value::as_str) @@ -569,8 +715,16 @@ impl Tool for ValidateWorkflowTool { .filter(|s| !s.is_empty()); let inline_graph = args.get("graph").filter(|v| !v.is_null()); - let graph_json = match (flow_id, inline_graph) { - (Some(id), _) => match ops::load_flow_graph(&self.config, id) { + let graph_json = match (draft_id, flow_id, inline_graph) { + (Some(id), _, _) => match ops::flows_draft_get(&self.config, id) { + Ok(outcome) => outcome.value.graph, + Err(e) => { + return Ok(ToolResult::error(format!( + "Could not load draft '{id}' to validate: {e}" + ))); + } + }, + (None, Some(id), _) => match ops::load_flow_graph(&self.config, id) { Ok(Some(graph)) => serde_json::to_value(&graph)?, Ok(None) => { return Ok(ToolResult::error(format!("flow '{id}' not found"))); @@ -581,11 +735,11 @@ impl Tool for ValidateWorkflowTool { ))); } }, - (None, Some(graph)) => graph.clone(), - (None, None) => { + (None, None, Some(graph)) => graph.clone(), + (None, None, None) => { return Ok(ToolResult::error( - "Provide either `flow_id` (a saved flow) or `graph` (an inline graph) to \ - validate." + "Provide one of `draft_id` (a working draft), `flow_id` (a saved flow), or \ + `graph` (an inline graph) to validate." .to_string(), )); } @@ -593,6 +747,7 @@ impl Tool for ValidateWorkflowTool { tracing::debug!( target: "flows", + from_draft = draft_id.is_some(), from_flow = flow_id.is_some(), "[flows] validate_workflow: checking graph (read-only)" ); @@ -1458,6 +1613,75 @@ pub(crate) async fn search_live_catalog( toolkit_filter: Option<&str>, limit: usize, ) -> Vec { + search_catalog(config, query, toolkit_filter, limit) + .await + .results +} + +/// Cap on fallback (per-keyword) matches — a near-miss query must not flood the +/// agent's context with the whole toolkit, so the OR-scored fallback returns at +/// most this many rows regardless of the primary `limit`. +const MAX_FALLBACK_RESULTS: usize = 10; + +/// Outcome of a catalog search: the shaped rows, whether the per-keyword +/// fallback pass fired, and an optional advisory `note` the tool surfaces so an +/// agent never misreads a keyword miss as "the action doesn't exist". +pub(crate) struct CatalogSearchOutcome { + pub results: Vec, + /// True when the per-token OR fallback pass ran (primary AND match was + /// empty for a multi-word query). + pub fallback: bool, + /// Advisory note explaining a near-miss / keyword-based search, if any. + pub note: Option, +} + +/// Shape one live-catalog [`ToolContract`](crate::openhuman::tinyflows::caps::ToolContract) +/// into a search-result row. The SINGLE row-construction site shared by both +/// the primary AND-match path and the per-keyword fallback path, so every row +/// carries the same fields — including WS3's `runtime_gated: true` on an +/// uncurated action of a toolkit that ships a curated-only allowlist. +fn shape_catalog_row( + tool: &crate::openhuman::tinyflows::caps::ToolContract, + toolkit: &str, + toolkit_curated: bool, +) -> Value { + let mut row = json!({ + "slug": tool.slug, + "toolkit": toolkit, + "description": tool.description, + "required_args": tool.required_args, + "output_fields": tool.output_fields, + "primary_array_path": tool.primary_array_path, + "featured": tool.is_curated, + }); + // Compact: only present when true. + if !tool.is_curated && toolkit_curated { + if let Some(obj) = row.as_object_mut() { + obj.insert("runtime_gated".to_string(), Value::Bool(true)); + } + } + row +} + +/// Search the FULL LIVE Composio catalog and return a [`CatalogSearchOutcome`]. +/// +/// Primary pass: case-insensitive AND — an action matches only if EVERY +/// whitespace-separated term substring-matches its slug, toolkit name, or +/// description (curated matches ranked first, stable sort preserves fetch +/// order). When that yields zero rows for a MULTI-WORD query, a per-keyword OR +/// fallback runs: each action is scored by how many query tokens match its +/// slug/toolkit/description, and the top [`MAX_FALLBACK_RESULTS`] (ranked by +/// hit-count desc, then curated first) are returned with an advisory `note`. +/// This is what keeps a natural-language query like "twitter tweet replies +/// lookup" from returning a bare `count: 0` even though `TWITTER_*` actions +/// exist — the agent gets the nearest keyword matches instead of falsely +/// concluding the action is missing. +pub(crate) async fn search_catalog( + config: &Config, + query: &str, + toolkit_filter: Option<&str>, + limit: usize, +) -> CatalogSearchOutcome { use crate::openhuman::memory_sync::composio::providers::agent_ready_toolkits; use crate::openhuman::tinyflows::caps::fetch_live_toolkit_catalog; @@ -1487,10 +1711,26 @@ pub(crate) async fn search_live_catalog( })) .await; + // Drop toolkits whose fetch failed (no backend session / network error) — + // they contribute zero results rather than erroring the whole search. + let fetched: Vec<(String, Vec)> = fetched + .into_iter() + .filter_map(|(tk, catalog)| catalog.map(|c| (tk, c))) + .collect(); + + // Does the scanned scope hold ANY actions at all? Distinguishes "keyword + // miss" (has actions, none matched) from "nothing to search" (empty scope). + let any_actions = fetched.iter().any(|(_, catalog)| !catalog.is_empty()); + + // ── Primary pass: case-insensitive AND across every term ── let mut matches: Vec<(bool, Value)> = Vec::new(); - for (toolkit, catalog) in fetched { - let Some(catalog) = catalog else { continue }; - for tool in &catalog { + for (toolkit, catalog) in &fetched { + // WS3 — a toolkit that ships a curated catalog is a hard curated-only + // allowlist at RUNTIME, so any `featured: false` action of it is + // rejected on every real run. Compute once per toolkit and flag those + // rows so the blocker is visible at search time (transcript failure #2). + let toolkit_curated = ops::toolkit_has_curated_catalog(toolkit); + for tool in catalog { let slug_lc = tool.slug.to_ascii_lowercase(); let desc_lc = tool .description @@ -1505,15 +1745,7 @@ pub(crate) async fn search_live_catalog( } matches.push(( tool.is_curated, - json!({ - "slug": tool.slug, - "toolkit": toolkit, - "description": tool.description, - "required_args": tool.required_args, - "output_fields": tool.output_fields, - "primary_array_path": tool.primary_array_path, - "featured": tool.is_curated, - }), + shape_catalog_row(tool, toolkit, toolkit_curated), )); } } @@ -1522,7 +1754,104 @@ pub(crate) async fn search_live_catalog( // within each group. matches.sort_by_key(|(is_curated, _)| std::cmp::Reverse(*is_curated)); matches.truncate(limit); - matches.into_iter().map(|(_, v)| v).collect() + let primary: Vec = matches.into_iter().map(|(_, v)| v).collect(); + + if !primary.is_empty() { + return CatalogSearchOutcome { + results: primary, + fallback: false, + note: None, + }; + } + + // ── Zero primary hits ── + // Single-token queries keep today's behavior exactly; only attach a light + // advisory note so a lone keyword miss still explains the search is + // keyword-based (task WS5.4, optional). + if terms.len() <= 1 { + let note = if any_actions { + Some(format!( + "No actions matched '{query}'. This search is keyword-based (matches action \ + slug/name/description) — try a different single keyword (e.g. 'gmail' or \ + 'tweets')." + )) + } else { + None + }; + return CatalogSearchOutcome { + results: Vec::new(), + fallback: false, + note, + }; + } + + // ── Fallback pass (multi-word, zero primary hits): per-token OR scoring ── + // Score each action by how many DISTINCT query tokens match its + // slug/toolkit/description; keep the primary path's curated boost as the + // tiebreak. Rows go through the SAME `shape_catalog_row` path as primary. + let mut scored: Vec<(usize, bool, Value)> = Vec::new(); + for (toolkit, catalog) in &fetched { + let toolkit_curated = ops::toolkit_has_curated_catalog(toolkit); + for tool in catalog { + let slug_lc = tool.slug.to_ascii_lowercase(); + let desc_lc = tool + .description + .as_deref() + .unwrap_or_default() + .to_ascii_lowercase(); + let hits = terms + .iter() + .filter(|term| { + slug_lc.contains(*term) || toolkit.contains(*term) || desc_lc.contains(*term) + }) + .count(); + if hits == 0 { + continue; + } + scored.push(( + hits, + tool.is_curated, + shape_catalog_row(tool, toolkit, toolkit_curated), + )); + } + } + + // Most keyword hits first, then curated first; stable sort preserves fetch + // order within a (hits, curated) group. + scored.sort_by_key(|(hits, is_curated, _)| std::cmp::Reverse((*hits, *is_curated))); + scored.truncate(limit.min(MAX_FALLBACK_RESULTS)); + let results: Vec = scored.into_iter().map(|(_, _, v)| v).collect(); + + tracing::debug!( + target: "flows", + query, + fallback = true, + hits = results.len(), + "[flows] search_tool_catalog: primary AND-match empty for a multi-word query — ran per-keyword OR fallback" + ); + + if results.is_empty() { + // Literally zero tokens matched anything: no rows, but a note so the + // agent doesn't read `count: 0` as "action doesn't exist" (task WS5.3). + return CatalogSearchOutcome { + results, + fallback: true, + note: Some(format!( + "No actions matched any keyword in '{query}'. This search is keyword-based \ + (matches action slug/name/description) — retry with a single keyword (e.g. one \ + word like 'gmail' or 'tweets') for a full listing." + )), + }; + } + + CatalogSearchOutcome { + results, + fallback: true, + note: Some(format!( + "No exact match for '{query}'. Showing the nearest per-keyword matches — retry with a \ + single keyword (e.g. one word like 'gmail' or 'tweets') for a full listing." + )), + } } #[async_trait] @@ -1553,7 +1882,7 @@ impl Tool for SearchToolCatalogTool { "properties": { "query": { "type": "string", - "description": "Keywords to match against tool slugs/descriptions (case-insensitive; all terms must match)." + "description": "Keywords to match against tool slugs/descriptions (case-insensitive). All terms must match for an exact hit; a multi-word query with no exact match falls back to the nearest per-keyword matches. For the widest listing, prefer ONE keyword (e.g. 'gmail' or 'tweets')." }, "toolkit": { "type": "string", @@ -1585,12 +1914,24 @@ impl Tool for SearchToolCatalogTool { toolkit = toolkit.unwrap_or("(any)"), "[flows] search_tool_catalog: searching the FULL LIVE Composio catalog (read-only)" ); - let results = search_live_catalog(&self.config, &query, toolkit, MAX_CATALOG_RESULTS).await; - Ok(ToolResult::success(serde_json::to_string_pretty(&json!({ - "query": query, - "count": results.len(), - "results": results, - }))?)) + let outcome = search_catalog(&self.config, &query, toolkit, MAX_CATALOG_RESULTS).await; + // Build with `note` first so an agent reading top-down sees the + // near-miss / keyword-based advisory before the (possibly zero) rows. + // `count` is always the number of returned rows, never a stand-in for + // "no such action" — a fallback carries a non-zero count. + let mut obj = serde_json::Map::new(); + if let Some(note) = outcome.note { + obj.insert("note".to_string(), Value::String(note)); + } + obj.insert("query".to_string(), Value::String(query)); + obj.insert( + "count".to_string(), + Value::Number(outcome.results.len().into()), + ); + obj.insert("results".to_string(), Value::Array(outcome.results)); + Ok(ToolResult::success(serde_json::to_string_pretty( + &Value::Object(obj), + )?)) } } @@ -1705,6 +2046,37 @@ impl Tool for GetToolContractTool { // permanently `None`. let contract = crate::openhuman::tinyflows::caps::apply_probe_override(contract.clone()); + + // WS3 — EARLY runtime-gate warning (transcript failure #2): a + // real-but-uncurated action of a toolkit that ships a curated + // catalog is a hard curated-only allowlist at RUNTIME, so it is + // REJECTED on every real run. The late `validate_workflow` gate + // catches it, but only ~15 tool calls after the agent has built + // and wired the node. Surface the blocker HERE, at contract-fetch + // time (and first in the payload), so the agent never wires it. + if !contract.is_curated && ops::toolkit_has_curated_catalog(&toolkit) { + tracing::debug!( + target: "flows", + %slug, + %toolkit, + "[flows] get_tool_contract: uncurated action of a curated toolkit — attaching runtime_gate warning" + ); + #[derive(serde::Serialize)] + struct ContractWithRuntimeGate { + runtime_gate: &'static str, + #[serde(flatten)] + contract: crate::openhuman::tinyflows::caps::ToolContract, + } + let payload = ContractWithRuntimeGate { + runtime_gate: "This action will be REJECTED on every real run — the \ + runtime tool gate only allows curated actions for this \ + toolkit. Pick a `featured: true` result from \ + search_tool_catalog instead.", + contract, + }; + return Ok(ToolResult::success(serde_json::to_string_pretty(&payload)?)); + } + Ok(ToolResult::success(serde_json::to_string_pretty( &contract, )?)) @@ -2170,6 +2542,79 @@ impl Tool for GetNodeKindContractTool { /// (an unexercised branch can be entirely intentional), and is surfaced on /// both the `ok: true` and `ok: false` result shapes so the caller can /// double-check that node's wiring by hand. +/// Builds one `null_resolutions` diagnostic entry for a `tool_call` node's +/// null-resolved `args.*` config expression. +/// +/// The common case reports `{ node_id, location, expression }` — a wiring +/// mistake the agent should fix. But when the null-resolved expression binds to +/// the output of an upstream Composio `tool_call` node +/// ([`ops::composio_tool_call_upstream_ref`]), the entry is instead marked +/// `unverifiable: true` and carries an honest `suggestion`: the echo sandbox +/// can NEVER produce a Composio tool's real output fields, so this particular +/// null is expected here and does NOT prove the binding wrong (WS6 — the +/// transcript audit where the agent re-wired an already-correct binding three +/// times chasing this exact false negative). The message points at +/// `get_tool_contract` / `get_tool_output_sample` as the real disambiguators. +fn build_null_resolution_entry( + node_id: &str, + diag: &tinyflows::expr::NullResolution, + graph: &WorkflowGraph, +) -> Value { + if let Some(upstream) = crate::openhuman::flows::ops::composio_tool_call_upstream_ref( + &diag.expression, + graph, + node_id, + ) { + let field = diag.location.strip_prefix("args.").unwrap_or("args"); + return json!({ + "node_id": node_id, + "location": diag.location, + "expression": diag.expression, + "unverifiable": true, + "upstream_tool_call": upstream, + "suggestion": format!( + "required arg `{field}` binds to the output of Composio tool_call node \ + `{upstream}` — the SANDBOX only echoes tool calls and can never produce \ + their real output fields, so this binding is UNVERIFIABLE here (not \ + necessarily wrong). Confirm the path against get_tool_contract {{ slug }}'s \ + output_fields / primary_array_path (remember Composio results nest under \ + `.item.json.data.`), or get_tool_output_sample {{ slug, args }} for the \ + real shape. It is a real bug only if the path doesn't match the action's \ + actual output." + ), + }); + } + json!({ + "node_id": node_id, + "location": diag.location, + "expression": diag.expression, + }) +} + +/// Every null-resolved `args.*` config expression that landed on a `tool_call` +/// node, as `null_resolutions` diagnostic entries (see +/// [`build_null_resolution_entry`] for the shape, including the WS6 +/// `unverifiable` Composio-upstream variant). Shared by the settled-run path +/// (which fails the dry run on these) and the errored-run path (which surfaces +/// only the `unverifiable` ones so a stop-policy preflight abort explains +/// itself honestly instead of via the generic required-arg text). +fn tool_call_arg_null_entries( + steps: &[tinyflows::observability::ExecutionStep], + graph: &WorkflowGraph, + tool_call_node_ids: &std::collections::HashSet<&str>, +) -> Vec { + steps + .iter() + .filter(|step| tool_call_node_ids.contains(step.node_id.as_str())) + .flat_map(|step| { + step.diagnostics + .iter() + .filter(|&diag| diag.location == "args" || diag.location.starts_with("args.")) + .map(|diag| build_null_resolution_entry(&step.node_id, diag, graph)) + }) + .collect() +} + pub struct DryRunWorkflowTool { security: Arc, config: Arc, @@ -2188,13 +2633,15 @@ impl Tool for DryRunWorkflowTool { } fn description(&self) -> &str { - "Dry-run a DRAFT workflow graph in a SANDBOX to self-verify it before \ + "Dry-run a workflow graph in a SANDBOX to self-verify it before \ proposing. Compiles the graph and executes it against MOCK capabilities \ — every LLM / tool_call / http_request / code node returns a deterministic \ echo, so NOTHING real happens (no messages sent, no code run). Returns the \ simulated per-node output labeled as sandbox output. Use it to catch \ - wiring/routing mistakes; it does NOT prove real integrations work. Pass \ - the same graph shape as propose_workflow, plus an optional `input`." + wiring/routing mistakes; it does NOT prove real integrations work. Provide \ + the graph as exactly one of `draft_id` (a working draft), `flow_id` (a saved \ + flow), or inline `graph` (draft_id wins, then flow_id), plus an optional \ + `input`." } fn parameters_schema(&self) -> Value { @@ -2203,11 +2650,15 @@ impl Tool for DryRunWorkflowTool { "properties": { "draft_id": { "type": "string", - "description": "A working draft to simulate. Provide this OR `graph`." + "description": "A working draft to simulate. Provide one of draft_id / flow_id / graph (draft_id wins)." + }, + "flow_id": { + "type": "string", + "description": "A saved flow to simulate. Provide one of draft_id / flow_id / graph." }, "graph": { "type": "object", - "description": "The DRAFT tinyflows WorkflowGraph to simulate: { nodes: [...], edges: [...] }. Provide this OR `draft_id`.", + "description": "An inline tinyflows WorkflowGraph to simulate: { nodes: [...], edges: [...] }. Provide one of draft_id / flow_id / graph.", "properties": { "nodes": { "type": "array" }, "edges": { "type": "array" } @@ -2235,31 +2686,48 @@ impl Tool for DryRunWorkflowTool { } async fn execute(&self, args: Value) -> anyhow::Result { - // Graph source: a working draft (draft_id) or an inline graph. - let graph_json = if let Some(draft_id) = args + // Graph source: exactly one of a working draft, a saved flow, or an + // inline graph — same precedence (draft_id > flow_id > graph) as the + // sibling validate/edit tools, so they all accept the same handles. + let draft_id = args .get("draft_id") .and_then(Value::as_str) .map(str::trim) - .filter(|s| !s.is_empty()) - { - match ops::flows_draft_get(&self.config, draft_id) { + .filter(|s| !s.is_empty()); + let flow_id = args + .get("flow_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()); + let inline_graph = args.get("graph").filter(|v| !v.is_null()); + + let graph_json = match (draft_id, flow_id, inline_graph) { + (Some(id), _, _) => match ops::flows_draft_get(&self.config, id) { Ok(outcome) => outcome.value.graph, Err(e) => { return Ok(ToolResult::error(format!( - "Could not load draft '{draft_id}' to dry-run: {e}" + "Could not load draft '{id}' to dry-run: {e}" ))); } - } - } else { - match args.get("graph") { - Some(v) if !v.is_null() => v.clone(), - _ => { - return Ok(ToolResult::error( - "Provide `graph` (an inline graph) or `draft_id` (a working draft) to \ - dry-run." - .to_string(), - )); + }, + (None, Some(id), _) => match ops::load_flow_graph(&self.config, id) { + Ok(Some(graph)) => serde_json::to_value(&graph)?, + Ok(None) => { + return Ok(ToolResult::error(format!("flow '{id}' not found"))); + } + Err(e) => { + return Ok(ToolResult::error(format!( + "Could not load flow '{id}' to dry-run: {e}" + ))); } + }, + (None, None, Some(v)) => v.clone(), + (None, None, None) => { + return Ok(ToolResult::error( + "Provide one of `draft_id` (a working draft), `flow_id` (a saved flow), or \ + `graph` (an inline graph) to dry-run." + .to_string(), + )); } }; let input = args.get("input").cloned().unwrap_or_else(|| json!({})); @@ -2299,6 +2767,12 @@ impl Tool for DryRunWorkflowTool { let mut caps = tinyflows::caps::mock::mock_capabilities_with_agent( crate::openhuman::tinyflows::caps::SchemaAwareMockAgentRunner, ); + // Plain agent nodes (no `agent_ref`) never reach the runner above — + // the vendored `agent` node routes them to the `llm` slot instead (see + // `SchemaAwareMockLlm`'s doc). Swap the vendored `MockLlm` echo for the + // schema-aware mock so their `output_parser.schema` is honored too, + // instead of the echo shape failing the sub-port's validation. + caps.llm = std::sync::Arc::new(crate::openhuman::tinyflows::caps::SchemaAwareMockLlm); // Wiring preflight over the echo mocks (see the struct doc): required // Composio args must be present and non-null even in the sandbox. caps.tools = std::sync::Arc::new(crate::openhuman::tinyflows::caps::PreflightToolInvoker { @@ -2344,6 +2818,46 @@ impl Tool for DryRunWorkflowTool { { Ok(Ok(outcome)) => outcome, Ok(Err(e)) => { + // A `stop`-policy `tool_call` whose required arg resolved null + // aborts the WHOLE run here (via `PreflightToolInvoker`), so + // the honest per-field diagnostic never reaches the settled-run + // `null_resolutions` path below. Recover it from the observer: + // if the abort was caused by a required arg bound to an upstream + // Composio `tool_call`'s output, the echo mock simply CAN'T + // produce that field — so surface it as `unverifiable` rather + // than letting the generic "required arg missing/null" text + // (which sent the transcript agent re-wiring a correct binding + // three times) stand alone. WS6. + let unverifiable_bindings: Vec = + tool_call_arg_null_entries(&observer.steps(), &graph, &tool_call_node_ids) + .into_iter() + .filter(|entry| { + entry.get("unverifiable").and_then(Value::as_bool) == Some(true) + }) + .collect(); + if !unverifiable_bindings.is_empty() { + tracing::debug!( + target: "flows", + error = %e, + unverifiable_count = unverifiable_bindings.len(), + "[flows] dry_run_workflow: sandbox run aborted on a Composio-upstream \ + binding the echo mock cannot verify — surfacing it honestly" + ); + return Ok(ToolResult::success(serde_json::to_string_pretty(&json!({ + "sandbox": true, + "ok": false, + "error": e.to_string(), + "unverifiable_bindings": unverifiable_bindings, + "note": "SANDBOX (mock) output — a tool_call node aborted because a \ + required arg binds to the output of an upstream Composio tool_call, \ + which the sandbox can only ECHO (it never produces real tool output \ + fields). See unverifiable_bindings: each MAY already be wired \ + correctly — confirm the path with get_tool_contract {{ slug }} \ + (output_fields / primary_array_path; Composio results nest under \ + .item.json.data.) or get_tool_output_sample {{ slug, args }} instead \ + of re-wiring blindly. No real side effects occurred.", + }))?)); + } tracing::debug!(target: "flows", error = %e, "[flows] dry_run_workflow: sandbox run errored"); return Ok(ToolResult::success(serde_json::to_string_pretty(&json!({ "sandbox": true, @@ -2363,23 +2877,12 @@ impl Tool for DryRunWorkflowTool { // `tool_call` node's `args.*` config path — the class of binding // mistake that "builds" (compiles, dry-runs against echo mocks) but // does nothing at runtime because the wired field never had a value. - let null_resolutions: Vec = observer - .steps() - .iter() - .filter(|step| tool_call_node_ids.contains(step.node_id.as_str())) - .flat_map(|step| { - step.diagnostics - .iter() - .filter(|&diag| diag.location == "args" || diag.location.starts_with("args.")) - .map(|diag| { - json!({ - "node_id": step.node_id, - "location": diag.location, - "expression": diag.expression, - }) - }) - }) - .collect(); + // Each entry is honest about WHY it resolved null: a binding to an + // upstream Composio `tool_call`'s output is flagged `unverifiable` + // (the echo mock can't produce real tool output fields) rather than + // reported as a plain wiring mistake — see [`build_null_resolution_entry`]. + let null_resolutions: Vec = + tool_call_arg_null_entries(&observer.steps(), &graph, &tool_call_node_ids); // Collect every null-resolved `agent`-node `prompt` — execution- // breaking in the same way a null `tool_call` arg is: `prompt` is the @@ -2723,12 +3226,16 @@ impl Tool for SaveWorkflowTool { fn description(&self) -> &str { "Save a workflow graph onto an EXISTING saved flow (by `flow_id`), persisting it. \ - Use this after the user asked you to build/update a workflow and you have \ - dry-run-verified the graph: it validates and writes the graph (and optional new \ - `name`) to that flow. It can NOT create a new flow, and it never changes the \ - flow's enabled state or its approval gate. NOTE: if the flow is enabled and the \ - graph has a schedule/app_event trigger, saving arms it — it will start firing on \ - its own. Always tell the user what you saved. Params: { flow_id, graph, name? }." + This is the ONLY builder tool that writes onto a saved flow — edit/validate/dry_run \ + never do. Use it after the user asked you to build/update a workflow and you have \ + dry-run-verified the graph. The graph source is either `draft_id` (a working draft — \ + the usual case after editing with edit_workflow; draft_id wins if both are given) or \ + an inline `graph`; `flow_id` is always required as the persistence TARGET. It \ + validates and writes the graph (and optional new `name`) to that flow. It can NOT \ + create a new flow, and it never changes the flow's enabled state or its approval \ + gate. NOTE: if the flow is enabled and the graph has a schedule/app_event trigger, \ + saving arms it — it will start firing on its own. Always tell the user what you \ + saved. Params: { flow_id, draft_id? | graph?, name? }." } fn parameters_schema(&self) -> Value { @@ -2737,11 +3244,15 @@ impl Tool for SaveWorkflowTool { "properties": { "flow_id": { "type": "string", - "description": "Id of the EXISTING saved flow to write the graph to." + "description": "Id of the EXISTING saved flow to write the graph to (the persistence target — always required)." + }, + "draft_id": { + "type": "string", + "description": "A working draft whose graph to persist onto the flow. Provide this OR inline `graph`; if both are given, draft_id wins." }, "graph": { "type": "object", - "description": "The full tinyflows WorkflowGraph to persist: { name?, nodes: [...], edges: [...] }. Same shape as propose_workflow.", + "description": "The full tinyflows WorkflowGraph to persist: { name?, nodes: [...], edges: [...] }. Provide this OR `draft_id`. Same shape as propose_workflow.", "properties": { "nodes": { "type": "array" }, "edges": { "type": "array" } @@ -2753,7 +3264,7 @@ impl Tool for SaveWorkflowTool { "description": "Optional new human-readable name for the flow." } }, - "required": ["flow_id", "graph"], + "required": ["flow_id"], "additionalProperties": false }) } @@ -2781,10 +3292,36 @@ impl Tool for SaveWorkflowTool { )) } }; - let graph_json = match args.get("graph") { - Some(v) if !v.is_null() => v.clone(), - _ => return Ok(ToolResult::error("Missing 'graph' parameter".to_string())), - }; + // Graph source: a working draft (the usual post-edit_workflow handle) or + // an inline graph. `flow_id` above is the persistence TARGET, always + // required; the draft only supplies the graph to write. If both a + // draft_id and an inline graph are given, the draft wins (it is the + // durable working copy the agent just iterated on). + let draft_id = args + .get("draft_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()); + let graph_json = + if let Some(id) = draft_id { + match ops::flows_draft_get(&self.config, id) { + Ok(outcome) => outcome.value.graph, + Err(e) => { + return Ok(ToolResult::error(format!( + "Could not load draft '{id}' to save: {e}" + ))); + } + } + } else { + match args.get("graph") { + Some(v) if !v.is_null() => v.clone(), + _ => return Ok(ToolResult::error( + "Provide `draft_id` (a working draft) or inline `graph` to save onto the \ + flow." + .to_string(), + )), + } + }; let name = args .get("name") .and_then(Value::as_str) @@ -2878,6 +3415,9 @@ impl Tool for SaveWorkflowTool { } Ok(ToolResult::success(serde_json::to_string_pretty(&json!({ "type": "workflow_saved", + // Explicit counterpart to a proposal's persisted:false — this + // graph IS now written onto the saved flow. + "persisted": true, "flow_id": flow.id, "name": flow.name, "enabled": flow.enabled, diff --git a/src/openhuman/flows/builder_tools_tests.rs b/src/openhuman/flows/builder_tools_tests.rs index fc207c73fe..e6a4d59dd1 100644 --- a/src/openhuman/flows/builder_tools_tests.rs +++ b/src/openhuman/flows/builder_tools_tests.rs @@ -211,6 +211,26 @@ fn seeded_gmail_send_contract() -> ToolContract { } } +/// A minimal seeded contract with NO required args, for WS6 dry-run tests: seeds +/// a bespoke toolkit so the required-arg preflight always passes and the sandbox +/// run settles into the `null_resolutions` path (rather than aborting), letting +/// the test assert the honest Composio-upstream diagnostic deterministically — +/// independent of whatever gmail/slack contracts other tests seed into the +/// process-global cache. +fn seeded_ws6_contract(slug: &str, toolkit: &str) -> ToolContract { + ToolContract { + slug: slug.to_string(), + toolkit: toolkit.to_string(), + description: Some("ws6 test action".to_string()), + required_args: vec![], + input_schema: Some(json!({ "type": "object", "additionalProperties": true })), + output_fields: vec![], + output_schema: None, + primary_array_path: None, + is_curated: true, + } +} + #[tokio::test] async fn search_live_catalog_finds_a_seeded_real_gmail_slug() { seed_live_catalog_cache("gmail", vec![seeded_gmail_send_contract()]); @@ -403,6 +423,279 @@ async fn get_tool_contract_rejects_a_hallucinated_slug() { assert!(result.output().contains("not a real action")); } +// ── WS3: early runtime-gate warnings on uncurated actions ──────────────────── +// +// Transcript failure #2: `get_tool_contract { slug: "TWITTER_USER_LOOKUP_ME" }` +// returned `is_curated: false` with no other signal; the agent built and wired +// the node and only ~15 tool calls later did `validate_workflow` reject it. A +// real-but-uncurated action of a toolkit that ships a curated catalog is a hard +// curated-only allowlist at RUNTIME, so surface the blocker at contract-fetch / +// search time. Uses `spotify` / `telegram` (real curated toolkits unused by +// other tests) so these seeds can't race with the shared `gmail`/`slack` keys. + +fn spotify_curated_action() -> ToolContract { + ToolContract { + slug: "SPOTIFY_START_PLAYBACK".to_string(), + toolkit: "spotify".to_string(), + description: Some("Start playback".to_string()), + required_args: vec![], + input_schema: Some(json!({ "type": "object" })), + output_fields: vec![], + output_schema: None, + primary_array_path: None, + is_curated: true, + } +} + +#[tokio::test] +async fn get_tool_contract_warns_on_an_uncurated_action_of_a_curated_toolkit() { + let uncurated = ToolContract { + slug: "SPOTIFY_OBSCURE_ACTION".to_string(), + is_curated: false, + ..spotify_curated_action() + }; + seed_live_catalog_cache("spotify", vec![spotify_curated_action(), uncurated]); + let tmp = TempDir::new().unwrap(); + let tool = GetToolContractTool::new(test_config(&tmp)); + + // Uncurated action → runtime_gate present, FIRST in the payload, contract intact. + let result = tool + .execute(json!({ "slug": "SPOTIFY_OBSCURE_ACTION" })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + let out = result.output(); + assert!(out.contains("runtime_gate"), "{out}"); + assert!(out.contains("REJECTED on every real run"), "{out}"); + let gate_pos = out.find("runtime_gate").expect("runtime_gate key"); + let slug_pos = out.find("\"slug\"").expect("slug key"); + assert!( + gate_pos < slug_pos, + "runtime_gate must serialize first (agents read top-down): {out}" + ); + let parsed: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(parsed["slug"], "SPOTIFY_OBSCURE_ACTION"); + assert_eq!(parsed["is_curated"], false); + + // Curated action of the same toolkit → NO runtime_gate. + let result = tool + .execute(json!({ "slug": "SPOTIFY_START_PLAYBACK" })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + assert!( + !result.output().contains("runtime_gate"), + "{}", + result.output() + ); +} + +#[tokio::test] +async fn search_tool_catalog_flags_runtime_gated_uncurated_rows() { + let curated = ToolContract { + slug: "TELEGRAM_SEND_MESSAGE".to_string(), + toolkit: "telegram".to_string(), + description: Some("Send a message".to_string()), + required_args: vec![], + input_schema: None, + output_fields: vec![], + output_schema: None, + primary_array_path: None, + is_curated: true, + }; + let uncurated = ToolContract { + slug: "TELEGRAM_OBSCURE_SEND".to_string(), + is_curated: false, + ..curated.clone() + }; + seed_live_catalog_cache("telegram", vec![curated, uncurated]); + + let config = Config::default(); + let results = search_live_catalog(&config, "send", Some("telegram"), 40).await; + assert_eq!(results.len(), 2, "{results:?}"); + // Curated row: no `runtime_gated` key (only present when true). + let curated_row = results.iter().find(|r| r["featured"] == true).unwrap(); + assert!(curated_row.get("runtime_gated").is_none(), "{curated_row}"); + // Uncurated row of a curated toolkit: `runtime_gated: true`. + let uncurated_row = results.iter().find(|r| r["featured"] == false).unwrap(); + assert_eq!(uncurated_row["runtime_gated"], true); +} + +// ── WS5: per-token fallback ranking for zero-result multi-word queries ─────── +// +// Transcript failure: `search_tool_catalog` behaved like near-exact matching — +// multi-word natural-language queries ("twitter tweet replies lookup") returned +// `count: 0` even though the toolkit HAS matching actions, so the agent falsely +// concluded the action didn't exist. The primary pass is a strict case- +// insensitive AND (every token must match); when that misses for a multi-word +// query, a per-keyword OR fallback now returns the nearest matches + a note. + +fn twt_lookup() -> ToolContract { + ToolContract { + slug: "TWTFALLBACKTEST_TWEET_LOOKUP".to_string(), + toolkit: "twtfallbacktest".to_string(), + description: Some("Look up a tweet".to_string()), + required_args: vec!["id".to_string()], + input_schema: None, + output_fields: vec!["text".to_string()], + output_schema: None, + primary_array_path: None, + is_curated: true, + } +} + +fn twt_replies() -> ToolContract { + ToolContract { + slug: "TWTFALLBACKTEST_LIST_REPLIES".to_string(), + toolkit: "twtfallbacktest".to_string(), + description: Some("List replies to a tweet".to_string()), + required_args: vec!["tweet_id".to_string()], + input_schema: None, + output_fields: vec!["replies".to_string()], + output_schema: None, + primary_array_path: None, + is_curated: true, + } +} + +#[tokio::test] +async fn search_catalog_multiword_miss_falls_back_to_per_keyword() { + seed_live_catalog_cache("twtfallbacktest", vec![twt_lookup(), twt_replies()]); + let config = Config::default(); + // Strict AND misses ("twitter"/"timeline" match nothing) but individual + // tokens ("tweet", "replies", "lookup") hit — so the fallback fires. + let outcome = search_catalog( + &config, + "twitter tweet replies lookup timeline", + Some("twtfallbacktest"), + 40, + ) + .await; + assert!( + outcome.fallback, + "multi-word AND-miss must run the fallback" + ); + assert_eq!(outcome.results.len(), 2, "{:?}", outcome.results); + let note = outcome.note.expect("fallback carries an advisory note"); + assert!( + note.contains("nearest per-keyword"), + "note should explain the near-miss + single-keyword retry: {note}" + ); + // Fallback rows carry the SAME shape as primary rows. + for r in &outcome.results { + assert_eq!(r["toolkit"], "twtfallbacktest"); + assert_eq!(r["featured"], true); + assert!(r["required_args"].is_array()); + } +} + +#[tokio::test] +async fn search_tool_catalog_tool_surfaces_fallback_note_with_nonzero_count() { + seed_live_catalog_cache("twtfallbacktest", vec![twt_lookup(), twt_replies()]); + let tmp = TempDir::new().unwrap(); + let tool = SearchToolCatalogTool::new(test_config(&tmp)); + let result = tool + .execute(json!({ + "query": "twitter tweet replies lookup timeline", + "toolkit": "twtfallbacktest" + })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + // `count` reflects the returned rows (non-zero) so an agent never reads a + // fallback as "no such action". + assert_eq!(parsed["count"], 2); + assert!(parsed["results"].as_array().unwrap().len() == 2); + assert!(parsed["note"].as_str().unwrap().contains("No exact match")); +} + +#[tokio::test] +async fn search_catalog_single_word_behavior_unchanged() { + seed_live_catalog_cache("onewordtest", vec![twt_lookup()]); + let config = Config::default(); + // A hit: single-word query returns the primary match, no fallback, no note. + let hit = search_catalog(&config, "tweet", Some("onewordtest"), 40).await; + assert!(!hit.fallback); + assert!(hit.note.is_none()); + assert_eq!(hit.results.len(), 1); + // A miss: single-word query stays empty and does NOT run the fallback. + let miss = search_catalog(&config, "zzznomatchzzz", Some("onewordtest"), 40).await; + assert!( + !miss.fallback, + "single-token miss must not trigger fallback" + ); + assert!(miss.results.is_empty()); +} + +#[tokio::test] +async fn search_catalog_multiword_zero_token_match_returns_note() { + seed_live_catalog_cache("zerotoktest", vec![twt_lookup()]); + let config = Config::default(); + // Multi-word query where NO token matches anything: still a note (not a bare + // count: 0), but zero rows. + let outcome = search_catalog(&config, "qqq www eeeeee", Some("zerotoktest"), 40).await; + assert!(outcome.fallback, "multi-word miss ran the fallback pass"); + assert!(outcome.results.is_empty()); + let note = outcome + .note + .expect("zero-token multi-word miss still gets a note"); + assert!( + note.contains("keyword-based"), + "note should explain the keyword-based search: {note}" + ); +} + +#[tokio::test] +async fn search_catalog_fallback_rows_flag_runtime_gated() { + // Reuse the exact telegram seed of the runtime_gated primary test so a + // concurrent run over the shared cache stays self-consistent; telegram is a + // real curated toolkit, so its uncurated action is `runtime_gated`. + let curated = ToolContract { + slug: "TELEGRAM_SEND_MESSAGE".to_string(), + toolkit: "telegram".to_string(), + description: Some("Send a message".to_string()), + required_args: vec![], + input_schema: None, + output_fields: vec![], + output_schema: None, + primary_array_path: None, + is_curated: true, + }; + let uncurated = ToolContract { + slug: "TELEGRAM_OBSCURE_SEND".to_string(), + is_curated: false, + ..curated.clone() + }; + seed_live_catalog_cache("telegram", vec![curated, uncurated]); + + let config = Config::default(); + // "obscure" hits only the uncurated slug; "lookup"/"replies" hit nothing; + // "telegram" matches the toolkit of both — so strict AND misses and the + // fallback ranks the OBSCURE row first (2 hits) over SEND_MESSAGE (1 hit). + let outcome = search_catalog( + &config, + "telegram obscure lookup replies", + Some("telegram"), + 40, + ) + .await; + assert!(outcome.fallback); + assert_eq!(outcome.results.len(), 2, "{:?}", outcome.results); + let gated = outcome + .results + .iter() + .find(|r| r["featured"] == false) + .expect("uncurated row present"); + assert_eq!(gated["runtime_gated"], true); + let curated_row = outcome + .results + .iter() + .find(|r| r["featured"] == true) + .expect("curated row present"); + assert!(curated_row.get("runtime_gated").is_none()); +} + /// B12: a cached real-output probe overrides `get_tool_contract`'s /// schema-derived `primary_array_path`/`output_fields` — most relevant for a /// slug whose live listing (like every GitHub action, verified live) has NO @@ -587,6 +880,78 @@ async fn dry_run_exercises_agent_ref_node_via_mock_agent_runner() { ); } +#[tokio::test] +async fn dry_run_plain_agent_with_output_parser_schema_is_green() { + // Regression for the transcript false-failure: a builder-generated `agent` + // node carries NO `agent_ref`, so the vendored engine routes it to the + // `llm` slot (not the `AgentRunner`). Before `SchemaAwareMockLlm` the plain + // `MockLlm` echo (`{ completion, connection }`) failed the node's + // `output_parser.schema` sub-port with `output_parser: value failed schema + // validation after auto-fix: missing required property ...`, sinking a + // correctly-built graph. Now the mock LLM synthesizes a schema-valid object, + // and a downstream node binds the typed placeholders (non-null). + let tool = DryRunWorkflowTool::new( + policy(AutonomyLevel::Supervised), + test_config(&TempDir::new().unwrap()), + ); + let graph = json!({ + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Schedule", + "config": { "trigger_kind": "schedule" } }, + { "id": "a", "kind": "agent", "name": "Extract", + "config": { "prompt": "extract the fields", + "output_parser": { "schema": { "type": "object", + "required": ["subject", "priority", "recipients"], + "properties": { + "subject": { "type": "string" }, + "priority": { "type": "integer" }, + "recipients": { "type": "array" } + } } } } }, + // Downstream node binds the schema'd agent fields: proves the + // placeholders are addressable and resolve to typed (non-null) + // values, not the vendored echo's opaque `{ completion, ... }`. + { "id": "down", "kind": "transform", "name": "Route", + "config": { "set": { + "subject": "=nodes.a.item.json.subject", + "priority": "=nodes.a.item.json.priority", + "recipients": "=nodes.a.item.json.recipients" } } } + ], + "edges": [ + { "from_node": "t", "to_node": "a" }, + { "from_node": "a", "to_node": "down" } + ] + }); + let result = tool + .execute(json!({ "graph": graph, "input": { "topic": "launch" } })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + let out = result.output(); + assert!( + !out.to_lowercase().contains("schema validation"), + "plain agent with a valid schema must not hit the output_parser failure: {out}" + ); + let parsed: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(parsed["sandbox"], true); + assert_eq!( + parsed["ok"], true, + "plain-agent-with-schema dry-run must be green: {parsed}" + ); + // The agent envelope's `json` carries the schema-synthesized placeholders. + // (In the run OUTPUT each Item serializes as `{ json: }`, and the + // agent's value is the `{json,text,raw}` envelope — hence the double hop.) + let agent_json = &parsed["output"]["nodes"]["a"]["items"][0]["json"]["json"]; + assert_eq!(agent_json["subject"], "", "{parsed}"); + assert_eq!(agent_json["priority"], 0, "{parsed}"); + assert_eq!(agent_json["recipients"], json!([]), "{parsed}"); + // The downstream node's bindings resolved to those typed placeholders — + // none of them null. + let down_json = &parsed["output"]["nodes"]["down"]["items"][0]["json"]; + assert!(!down_json["subject"].is_null(), "{parsed}"); + assert_eq!(down_json["priority"], 0, "{parsed}"); + assert_eq!(down_json["recipients"], json!([]), "{parsed}"); +} + #[tokio::test] async fn dry_run_invalid_graph_is_error() { let tool = DryRunWorkflowTool::new( @@ -711,6 +1076,112 @@ async fn dry_run_flags_tool_call_arg_null_resolved_from_unschemad_agent() { ); } +#[tokio::test] +async fn dry_run_flags_composio_upstream_binding_as_unverifiable_not_a_wiring_bug() { + // WS6: `post`'s `body` binds to the OUTPUT of an upstream Composio + // `tool_call` (`get_me`). The echo sandbox renders `get_me` as + // `{tool, args, connection}` and can NEVER produce `.item.json.data.username`, + // so the binding resolves `null` here even when it's wired correctly. The + // dry run still fails (`ok: false` — a null could hide a typo), but the + // diagnostic must be HONEST: mark it `unverifiable` and point at + // get_tool_contract / get_tool_output_sample rather than telling the agent + // its (possibly-correct) wiring is broken — the exact false negative that + // sent the transcript agent re-wiring an already-correct binding 3 times. + // Seed bespoke toolkits (no other test touches `ws6up`/`ws6dl`) with NO + // required args, so the required-arg preflight passes and the run settles + // into the `null_resolutions` path deterministically — independent of the + // process-global catalog cache other tests seed for gmail/slack/etc. + seed_live_catalog_cache("ws6up", vec![seeded_ws6_contract("WS6UP_LOOKUP", "ws6up")]); + seed_live_catalog_cache("ws6dl", vec![seeded_ws6_contract("WS6DL_SEND", "ws6dl")]); + let tool = DryRunWorkflowTool::new( + policy(AutonomyLevel::Supervised), + test_config(&TempDir::new().unwrap()), + ); + let graph = json!({ + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Manual" }, + { "id": "get_me", "kind": "tool_call", "name": "Who am I", + "config": { "slug": "WS6UP_LOOKUP", "args": {} } }, + { "id": "post", "kind": "tool_call", "name": "Post", + "config": { "slug": "WS6DL_SEND", + "args": { "recipient_email": "a@b.com", "subject": "hi", + "body": "=nodes.get_me.item.json.data.username" } } } + ], + "edges": [ + { "from_node": "t", "to_node": "get_me" }, + { "from_node": "get_me", "to_node": "post" } + ] + }); + let result = tool.execute(json!({ "graph": graph })).await.unwrap(); + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["ok"], false, "{parsed}"); + let null_resolutions = parsed["null_resolutions"] + .as_array() + .expect("null_resolutions array"); + let entry = null_resolutions + .iter() + .find(|e| e["node_id"] == "post" && e["location"] == "args.body") + .unwrap_or_else(|| panic!("expected a post.body null resolution: {parsed}")); + assert_eq!(entry["unverifiable"], true, "{parsed}"); + assert_eq!(entry["upstream_tool_call"], "get_me", "{parsed}"); + let suggestion = entry["suggestion"].as_str().expect("suggestion string"); + assert!(suggestion.contains("UNVERIFIABLE"), "{suggestion}"); + assert!(suggestion.contains("get_tool_contract"), "{suggestion}"); + assert!( + suggestion.contains("get_tool_output_sample"), + "{suggestion}" + ); +} + +#[tokio::test] +async fn dry_run_keeps_generic_null_text_for_a_non_tool_call_upstream_binding() { + // WS6 contrast: `post`'s arg binds to a `transform` node's output (whose + // real output the echo sandbox DOES produce), and the transform never sets + // the referenced field, so the null IS a genuine wiring bug. This entry must + // stay the plain `{ node_id, location, expression }` shape — no + // `unverifiable` flag — so the honest-uncertainty treatment doesn't leak + // onto real mistakes. + seed_live_catalog_cache("ws6dl", vec![seeded_ws6_contract("WS6DL_SEND", "ws6dl")]); + let tool = DryRunWorkflowTool::new( + policy(AutonomyLevel::Supervised), + test_config(&TempDir::new().unwrap()), + ); + let graph = json!({ + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Manual" }, + { "id": "build", "kind": "transform", "name": "Build", + "config": { "set": { "unrelated": "x" } } }, + { "id": "post", "kind": "tool_call", "name": "Post", + "config": { "slug": "WS6DL_SEND", + "args": { "recipient_email": "a@b.com", "subject": "hi", + "body": "=nodes.build.item.json.missing" } } } + ], + "edges": [ + { "from_node": "t", "to_node": "build" }, + { "from_node": "build", "to_node": "post" } + ] + }); + let result = tool.execute(json!({ "graph": graph })).await.unwrap(); + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["ok"], false, "{parsed}"); + let entry = parsed["null_resolutions"] + .as_array() + .expect("null_resolutions array") + .iter() + .find(|e| e["node_id"] == "post" && e["location"] == "args.body") + .unwrap_or_else(|| panic!("expected a post.body null resolution: {parsed}")); + assert!( + entry.get("unverifiable").is_none(), + "a non-tool_call upstream must keep the generic diagnostic: {parsed}" + ); + assert!( + entry.get("suggestion").is_none(), + "generic entry carries no unverifiable suggestion: {parsed}" + ); +} + #[tokio::test] async fn dry_run_passes_when_agent_schema_matches_tool_call_binding() { // The FALSE-POSITIVE-PREVENTION case: `summarize` DOES declare a schema @@ -1501,6 +1972,98 @@ async fn edit_workflow_reports_failing_op_with_guidance() { assert!(out.contains("edit_workflow again"), "{out}"); } +#[tokio::test] +async fn edit_workflow_bad_op_reports_index_type_and_shape() { + let tmp = TempDir::new().unwrap(); + let tool = EditWorkflowTool::new(test_config(&tmp)); + // ops 0 and 1 are well-formed; op 2 is an add_node missing its `node`. + let result = tool + .execute(json!({ + "graph": valid_graph(), + "ops": [ + { "op": "set_node_name", "id": "a", "name": "One" }, + { "op": "set_node_name", "id": "a", "name": "Two" }, + { "op": "add_node", "id": "b" } + ] + })) + .await + .unwrap(); + assert!(result.is_error, "{}", result.output()); + let out = result.output(); + // Names the failing op index, its op type, and the expected shape for it. + assert!(out.contains("op 2"), "{out}"); + assert!(out.contains("add_node"), "{out}"); + assert!(out.contains("node:"), "expected add_node shape in: {out}"); + assert!(out.contains("edit_workflow again"), "{out}"); +} + +#[tokio::test] +async fn edit_workflow_missing_op_field_lists_valid_types() { + let tmp = TempDir::new().unwrap(); + let tool = EditWorkflowTool::new(test_config(&tmp)); + let result = tool + .execute(json!({ + "graph": valid_graph(), + "ops": [ { "id": "a", "name": "No op tag" } ] + })) + .await + .unwrap(); + assert!(result.is_error, "{}", result.output()); + let out = result.output(); + assert!(out.contains("op 0"), "{out}"); + assert!(out.contains("missing `op` field"), "{out}"); + assert!(out.contains("update_node_config"), "{out}"); +} + +#[tokio::test] +async fn edit_workflow_add_node_exists_carries_ordering_hint() { + let tmp = TempDir::new().unwrap(); + let tool = EditWorkflowTool::new(test_config(&tmp)); + // Re-adding an existing node id fails in-order; the hint should point at the + // remove-first / patch-in-place fix. + let result = tool + .execute(json!({ + "graph": valid_graph(), + "ops": [ + { "op": "add_node", "node": { "id": "a", "kind": "merge", "name": "Dup" } } + ] + })) + .await + .unwrap(); + assert!(result.is_error, "{}", result.output()); + let out = result.output(); + assert!(out.contains("already exists"), "{out}"); + assert!(out.contains("array order"), "{out}"); + assert!(out.contains("remove_node"), "{out}"); + assert!(out.contains("update_node_config"), "{out}"); +} + +#[tokio::test] +async fn edit_workflow_accepts_node_id_aliases_end_to_end() { + let tmp = TempDir::new().unwrap(); + let tool = EditWorkflowTool::new(test_config(&tmp)); + // A valid ops array using the `node_id` alias (the natural agent guess) + // applies cleanly through edit_workflow. + let result = tool + .execute(json!({ + "graph": valid_graph(), + "name": "Aliased edit", + "ops": [ + { "op": "update_node_config", "node_id": "a", "config": { "prompt": "aliased" } }, + { "op": "set_node_name", "node_id": "a", "name": "Aliased step" } + ] + })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["type"], "workflow_proposal"); + let nodes = parsed["graph"]["nodes"].as_array().unwrap(); + let agent = nodes.iter().find(|n| n["id"] == "a").unwrap(); + assert_eq!(agent["config"]["prompt"], "aliased"); + assert_eq!(agent["name"], "Aliased step"); +} + #[tokio::test] async fn edit_workflow_rejects_a_result_that_is_structurally_invalid() { let tmp = TempDir::new().unwrap(); @@ -1724,3 +2287,147 @@ fn phase4_write_tools_have_the_right_permissions() { PermissionLevel::None ); } + +// ── WS2: unified draft_id|flow_id|graph handles + explicit persistence state ── + +#[tokio::test] +async fn edit_workflow_by_flow_id_seeds_a_retrievable_draft_and_marks_unpersisted() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + // A saved flow to edit — editing it must NOT write onto the flow (the WS2 + // bug: a flow_id edit used to persist nothing and return no handle). + let flow = ops::flows_create(&config, "Base flow".to_string(), valid_graph(), false) + .await + .unwrap() + .value; + + let tool = EditWorkflowTool::new(config.clone()); + let result = tool + .execute(json!({ + "flow_id": flow.id, + "ops": [ { "op": "set_node_name", "id": "a", "name": "Renamed step" } ] + })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + + // The edit lives on a NEW draft, is explicitly NOT persisted, and echoes the + // flow it derives from plus a `next` hint naming the draft. + assert_eq!(parsed["persisted"], false); + assert_eq!(parsed["flow_id"], flow.id.as_str()); + let draft_id = parsed["draft_id"] + .as_str() + .expect("edit_workflow by flow_id returns a draft_id") + .to_string(); + assert!(parsed["next"].as_str().unwrap().contains(&draft_id)); + + // The draft is retrievable via ops::flows_draft_get and holds the EDITED + // graph, linked back to the source flow. + let draft = ops::flows_draft_get(&config, &draft_id).unwrap().value; + assert_eq!(draft.flow_id.as_deref(), Some(flow.id.as_str())); + let agent = draft.graph["nodes"] + .as_array() + .unwrap() + .iter() + .find(|n| n["id"] == "a") + .unwrap(); + assert_eq!(agent["name"], "Renamed step"); + + // The SAVED flow is untouched — the whole point of WS2. + let saved = ops::flows_get(&config, &flow.id).await.unwrap().value; + let saved_graph = serde_json::to_value(&saved.graph).unwrap(); + let saved_agent = saved_graph["nodes"] + .as_array() + .unwrap() + .iter() + .find(|n| n["id"] == "a") + .unwrap(); + assert_eq!( + saved_agent["name"], "Summarize", + "the flow must not be edited" + ); +} + +#[tokio::test] +async fn dry_run_workflow_by_flow_id_runs_the_saved_flow_graph() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = ops::flows_create(&config, "Runnable".to_string(), valid_graph(), false) + .await + .unwrap() + .value; + let tool = DryRunWorkflowTool::new(policy(AutonomyLevel::Supervised), config.clone()); + let result = tool.execute(json!({ "flow_id": flow.id })).await.unwrap(); + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["sandbox"], true); + assert_eq!(parsed["ok"], true); +} + +#[tokio::test] +async fn validate_workflow_by_draft_id_checks_the_draft_graph() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let draft = ops::flows_draft_create( + &config, + None, + "Draft".to_string(), + valid_graph(), + crate::openhuman::flows::DraftOrigin::Chat, + ) + .unwrap() + .value; + let tool = ValidateWorkflowTool::new(config.clone()); + let result = tool.execute(json!({ "draft_id": draft.id })).await.unwrap(); + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["ok"], true); + assert_eq!(parsed["structurally_valid"], true); +} + +#[tokio::test] +async fn save_workflow_by_draft_id_persists_the_draft_graph_onto_the_flow() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + // A flow seeded with a bare 1-node graph. + let flow_id = seed_flow(&config, "Blank flow").await; + // A draft holding the richer 2-node valid graph, linked to that flow. + let draft = ops::flows_draft_create( + &config, + Some(flow_id.clone()), + "Draft".to_string(), + valid_graph(), + crate::openhuman::flows::DraftOrigin::Chat, + ) + .unwrap() + .value; + + let tool = SaveWorkflowTool::new(config.clone()); + let result = tool + .execute(json!({ "flow_id": flow_id, "draft_id": draft.id })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["type"], "workflow_saved"); + assert_eq!(parsed["persisted"], true); + assert_eq!(parsed["node_count"], 2); + + // The draft's graph really landed on the flow. + let saved = ops::flows_get(&config, &flow_id).await.unwrap().value; + assert_eq!(saved.graph.nodes.len(), 2); +} + +#[tokio::test] +async fn revise_workflow_proposal_is_marked_unpersisted() { + let tmp = TempDir::new().unwrap(); + let tool = ReviseWorkflowTool::new(test_config(&tmp)); + let result = tool + .execute(json!({ "name": "R", "graph": valid_graph() })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["persisted"], false); +} diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index 9f5bca5b12..69b39cd5aa 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -142,6 +142,15 @@ pub(crate) async fn run_builder_gates(config: &Config, graph: &WorkflowGraph) -> if !binding_errors.is_empty() { return binding_errors; } + // Async, live connection list: a tool_call whose `connection_ref` names the + // wrong toolkit for its slug, or a connection id the user doesn't actually + // have (WS3 — the transcript bug where a TIKTOK connection id was wired onto + // Twitter/Gmail nodes and every author-time gate returned ok). Cheap: + // one connection-list fetch, no per-node catalog round trips. + let connection_ref_errors = validate_connection_refs(config, graph).await; + if !connection_ref_errors.is_empty() { + return connection_ref_errors; + } // Async, live catalog: a tool_call whose slug isn't a real Composio action // or whose real required args aren't all wired. let contract_errors = validate_tool_contracts(config, graph).await; @@ -192,11 +201,20 @@ pub(crate) async fn strict_gate(config: &Config, graph_json: &Value) -> Result<( /// the tool in the "fix … and call `` again" guidance so each caller's /// error text points the agent back at the right tool. /// +/// `draft_id` / `flow_id` are OPTIONAL persistence-state context echoed onto +/// the payload (the draft this proposal's edit lives on, and the saved flow it +/// derives from / targets). The payload ALWAYS carries `"persisted": false` so +/// a proposal can never be mistaken for a save confirmation — the exact false +/// belief the WS2 audit caught (an agent read a proposal as "written onto the +/// saved flow"). Actual persistence only happens via `save_workflow` / +/// `create_workflow` / `flows_draft_promote`. +/// /// Returns `Ok(payload)` on success, or `Err(message)` with a /// model-consumable, fix-and-retry error when a gate rejects the graph. The /// caller is responsible for structural validation (`validate_and_migrate_graph` /// / `validate_all`) *before* calling this — these gates assume a compilable /// graph. +#[allow(clippy::too_many_arguments)] pub(crate) async fn build_builder_proposal( config: &Config, retry_tool: &str, @@ -205,6 +223,8 @@ pub(crate) async fn build_builder_proposal( require_approval: bool, revision: bool, instruction: Option, + draft_id: Option, + flow_id: Option, ) -> Result { // The full builder hard-gate stack, run through the single canonical // runner so every proposal/save/strict-RPC path gates identically (F3). @@ -238,6 +258,10 @@ pub(crate) async fn build_builder_proposal( let mut payload = json!({ "type": "workflow_proposal", "revision": revision, + // A proposal is NEVER a persisted flow — it is a candidate the user + // still has to accept/save. Stamp this unconditionally so the payload + // can't be misread as a save confirmation (WS2 audit). + "persisted": false, "name": name, "graph": graph_value, "require_approval": require_approval, @@ -248,6 +272,14 @@ pub(crate) async fn build_builder_proposal( if let Some(instruction) = instruction { payload["instruction"] = json!(instruction); } + // Echo the persistence-state handles so the agent can iterate/persist + // against the right ids (the draft the edit lives on; the flow it targets). + if let Some(draft_id) = draft_id { + payload["draft_id"] = json!(draft_id); + } + if let Some(flow_id) = flow_id { + payload["flow_id"] = json!(flow_id); + } Ok(payload) } @@ -1102,10 +1134,23 @@ pub(crate) fn validate_binding_resolvability(graph: &WorkflowGraph) -> Vec bool { + use crate::openhuman::memory_sync::composio::providers::{catalog_for_toolkit, get_provider}; + get_provider(toolkit) + .and_then(|p| p.curated_tools()) + .or_else(|| catalog_for_toolkit(toolkit)) + .is_some() +} + pub(crate) async fn validate_tool_contracts(config: &Config, graph: &WorkflowGraph) -> Vec { - use crate::openhuman::memory_sync::composio::providers::{ - catalog_for_toolkit, get_provider, toolkit_from_slug, - }; + use crate::openhuman::memory_sync::composio::providers::toolkit_from_slug; use crate::openhuman::tinyflows::caps::{ fetch_live_toolkit_catalog, missing_required_args, unsupported_arg_names, }; @@ -1166,10 +1211,7 @@ pub(crate) async fn validate_tool_contracts(config: &Config, graph: &WorkflowGra // action on a curated toolkit and then fail every run with "tool // not permitted". Hold authoring to the same bar the runtime gate // enforces instead of loosening the runtime gate. - let has_static_catalog = get_provider(&toolkit) - .and_then(|p| p.curated_tools()) - .or_else(|| catalog_for_toolkit(&toolkit)) - .is_some(); + let has_static_catalog = toolkit_has_curated_catalog(&toolkit); if has_static_catalog && !contract.is_curated { tracing::warn!( target: "flows", @@ -1273,6 +1315,248 @@ pub(crate) async fn validate_tool_contracts(config: &Config, graph: &WorkflowGra errors } +// ───────────────────────────────────────────────────────────────────────────── +// Connection-ref gate (WS3): a Composio tool_call's `connection_ref` must name +// a real connected account of the RIGHT toolkit +// ───────────────────────────────────────────────────────────────────────────── +// +// Transcript audit: the user's connections were `twitter → +// composio:twitter:ca_JX6QU88UfSk4`, `gmail → composio:gmail:ca_vX_WA8FsqNmE`, +// `tiktok → composio:tiktok:ca_LPCp3WQpaDma`. The agent wired +// `composio:twitter:ca_LPCp3WQpaDma` and `composio:gmail:ca_LPCp3WQpaDma` (the +// TIKTOK id) onto the Twitter and Gmail tool_call nodes. dry_run / validate / +// propose all returned ok:true — nothing cross-checked the id against the user's +// real connections, nor the ref's toolkit segment against the slug — and it +// would fail on the first real run. This gate closes that gap: it parses the +// ref, enforces the toolkit segment matches the slug (needs no I/O), and — when +// the live connection list is reachable — that the id names a real connected +// account of that toolkit, naming the correct ref when it can. + +/// Parses a `composio::` connection_ref into its `(toolkit, id)` +/// segments. Mirrors [`crate::openhuman::tinyflows::caps::composio_connection_id`]'s +/// rsplit for the id (everything after the LAST `:`), taking everything between +/// the `composio:` prefix and that last `:` as the toolkit. Returns `None` for +/// anything that isn't this shape (missing `composio:` prefix, no `:` after it, +/// or an empty toolkit/id segment). +fn parse_composio_connection_ref(conn_ref: &str) -> Option<(&str, &str)> { + let rest = conn_ref.strip_prefix("composio:")?; + let (toolkit, id) = rest.rsplit_once(':')?; + if toolkit.trim().is_empty() || id.trim().is_empty() { + return None; + } + Some((toolkit.trim(), id.trim())) +} + +/// First connected account `connection_ref` for `toolkit` (case-insensitive) +/// from `conns`, used to name the correct ref in a rejection's "did you mean" +/// hint. `None` when the toolkit has no connection at all. +fn first_connection_ref_for_toolkit(conns: &[FlowConnection], toolkit: &str) -> Option { + conns + .iter() + .find(|c| { + c.toolkit + .as_deref() + .is_some_and(|t| t.eq_ignore_ascii_case(toolkit)) + }) + .map(|c| c.connection_ref.clone()) +} + +/// Hard gate: for every Composio `tool_call` node carrying a `connection_ref`, +/// prove the ref names a real connected account of the SAME toolkit as the +/// slug. Fetches the live connection list once (same source +/// [`flows_list_connections`] reads) and delegates the pure matching to +/// [`validate_connection_refs_against`]. +/// +/// Fail-open on I/O: if the Composio connection list is unreachable (backend +/// outage), the id-existence check is SKIPPED (a `tracing::debug!` records it) +/// so a real connection is never false-rejected during an outage — but the +/// toolkit-mismatch check, which needs no I/O, still runs. +pub(crate) async fn validate_connection_refs( + config: &Config, + graph: &WorkflowGraph, +) -> Vec { + let connections: Option> = + match crate::openhuman::composio::ops::composio_list_connections(config).await { + Ok(outcome) => Some(build_flow_connections( + outcome.value.connections, + Vec::new(), + )), + Err(e) => { + tracing::debug!( + target: "flows", + error = %e, + "[flows] connection-ref check: composio connection list unavailable — \ + skipping id-existence check (fail-open); toolkit-mismatch check still runs" + ); + None + } + }; + validate_connection_refs_against(graph, connections.as_deref()) +} + +/// Pure connection-ref validator (no I/O) so the gate's decision logic is +/// unit-testable without a live Composio backend. `connections` is `Some(list)` +/// when the live connection list was fetched (possibly empty — a genuine "no +/// connections" state), or `None` when it was unavailable (fail-open: the +/// id-existence check is skipped, only the toolkit-mismatch check runs). +fn validate_connection_refs_against( + graph: &WorkflowGraph, + connections: Option<&[FlowConnection]>, +) -> Vec { + use crate::openhuman::memory_sync::composio::providers::toolkit_from_slug; + + let mut errors = Vec::new(); + for node in &graph.nodes { + if node.kind != NodeKind::ToolCall { + continue; + } + let Some(slug) = node.config.get("slug").and_then(Value::as_str) else { + continue; + }; + // `=`-derived slugs resolve at runtime; native `oh:` tools have no + // Composio connection to name. + if slug.starts_with('=') || slug.starts_with("oh:") { + continue; + } + // A MISSING `connection_ref` stays allowed (unchanged): a Composio + // tool_call with no ref runs against the ambient signed-in account and + // the flow prompts for a connection at first run. + let Some(conn_ref) = node.config.get("connection_ref").and_then(Value::as_str) else { + continue; + }; + if conn_ref.trim().is_empty() { + continue; + } + let Some(slug_toolkit) = toolkit_from_slug(slug) else { + continue; + }; + + let Some((ref_toolkit, ref_id)) = parse_composio_connection_ref(conn_ref) else { + tracing::debug!( + target: "flows", + node = %node.id, + %slug, + toolkit = %slug_toolkit, + %conn_ref, + matched = false, + "[flows] connection-ref check: malformed ref — rejecting" + ); + errors.push(format!( + "Node '{}': `connection_ref` `{conn_ref}` is malformed — a Composio account ref \ + must look like `composio::` (e.g. \ + `composio:{slug_toolkit}:`). Call list_flow_connections and copy a \ + `connection_ref` value verbatim.", + node.id + )); + continue; + }; + + // Toolkit segment vs the slug's toolkit — needs no I/O. + if !ref_toolkit.eq_ignore_ascii_case(&slug_toolkit) { + let suggestion = connections + .and_then(|conns| first_connection_ref_for_toolkit(conns, &slug_toolkit)); + tracing::debug!( + target: "flows", + node = %node.id, + %slug, + toolkit = %slug_toolkit, + %ref_toolkit, + %ref_id, + matched = false, + "[flows] connection-ref check: toolkit segment does not match the slug's toolkit — rejecting" + ); + let hint = match suggestion { + Some(r) => format!(" — did you mean `{r}`?"), + None => format!( + " — no `{slug_toolkit}` account is connected; connect one with \ + composio_connect (or ask the user to), then use its `connection_ref`" + ), + }; + errors.push(format!( + "Node '{}': `connection_ref` `{conn_ref}` names the `{ref_toolkit}` toolkit but the \ + tool_call slug `{slug}` is a `{slug_toolkit}` action{hint}.", + node.id + )); + continue; + } + + // Existence check: the id must name a real connected account of this + // toolkit. Skipped (fail-open) when the connection list is unavailable. + let Some(conns) = connections else { + tracing::debug!( + target: "flows", + node = %node.id, + %slug, + toolkit = %slug_toolkit, + %ref_id, + "[flows] connection-ref check: toolkit matches; id-existence check skipped (connections unavailable)" + ); + continue; + }; + // The id must belong to a connection OF THIS TOOLKIT — not merely + // exist somewhere. The transcript bug was a real TIKTOK connection id + // stamped onto a `composio:twitter:` ref: the id exists globally, but + // it is not a Twitter account, so it must still be rejected. + let id_exists = conns.iter().any(|c| { + c.toolkit + .as_deref() + .is_some_and(|t| t.eq_ignore_ascii_case(&slug_toolkit)) + && parse_composio_connection_ref(&c.connection_ref) + .is_some_and(|(_, cid)| cid.eq_ignore_ascii_case(ref_id)) + }); + if id_exists { + tracing::debug!( + target: "flows", + node = %node.id, + %slug, + toolkit = %slug_toolkit, + %ref_id, + matched = true, + "[flows] connection-ref check: ref resolves to a real connected account — ok" + ); + continue; + } + // Unknown id. Name the right ref for this toolkit if one exists. + match first_connection_ref_for_toolkit(conns, &slug_toolkit) { + Some(r) => { + tracing::debug!( + target: "flows", + node = %node.id, + %slug, + toolkit = %slug_toolkit, + %ref_id, + matched = false, + "[flows] connection-ref check: unknown id; toolkit has a different connected account — rejecting" + ); + errors.push(format!( + "Node '{}': `connection_ref` `{conn_ref}` does not match any connected \ + `{slug_toolkit}` account — did you mean `{r}`? Call list_flow_connections and \ + copy a `connection_ref` value verbatim.", + node.id + )); + } + None => { + tracing::debug!( + target: "flows", + node = %node.id, + %slug, + toolkit = %slug_toolkit, + %ref_id, + matched = false, + "[flows] connection-ref check: no connected account for this toolkit — rejecting" + ); + errors.push(format!( + "Node '{}': `connection_ref` `{conn_ref}` names a `{slug_toolkit}` account, but \ + no `{slug_toolkit}` account is connected — connect one with composio_connect \ + (or ask the user to), then use its `connection_ref`.", + node.id + )); + } + } + } + errors +} + // ───────────────────────────────────────────────────────────────────────────── // Required-arg resolvability gate (issue B18) // ───────────────────────────────────────────────────────────────────────────── @@ -1348,13 +1632,19 @@ const REQUIRED_ARG_NULL_CHECK_TIMEOUT_SECS: u64 = 15; /// check only ever adds a diagnostic the sandbox actually observed. pub(crate) async fn validate_required_arg_resolvability(graph: &WorkflowGraph) -> Vec { use crate::openhuman::flows::builder_tools::CapturingObserver; - use crate::openhuman::tinyflows::caps::SchemaAwareMockAgentRunner; + use crate::openhuman::tinyflows::caps::{SchemaAwareMockAgentRunner, SchemaAwareMockLlm}; let Ok(compiled) = tinyflows::compiler::compile(graph) else { return Vec::new(); }; - let caps = tinyflows::caps::mock::mock_capabilities_with_agent(SchemaAwareMockAgentRunner); + let mut caps = tinyflows::caps::mock::mock_capabilities_with_agent(SchemaAwareMockAgentRunner); + // Same fix as `DryRunWorkflowTool`: a plain agent node (no `agent_ref`) + // routes to the `llm` slot, not the runner above, so the vendored `MockLlm` + // echo would fail its `output_parser.schema` sub-port and make this gate + // reject a correct graph (which is why `propose_workflow` was rejecting + // valid graphs). The schema-aware mock LLM honors the schema instead. + caps.llm = Arc::new(SchemaAwareMockLlm); let observer = Arc::new(CapturingObserver::default()); let observer_dyn: Arc = observer.clone(); @@ -1426,6 +1716,34 @@ pub(crate) async fn validate_required_arg_resolvability(graph: &WorkflowGraph) - ); continue; } + // A null bound to the OUTPUT of an upstream Composio `tool_call` + // node is UNVERIFIABLE in this echo sandbox — the mock renders a + // Composio `tool_call` as `{tool, args, connection}` and can NEVER + // produce its real output fields (`.item.json.data.`), so a + // downstream binding to one resolves `null` here even when the + // wiring is perfectly correct. Hard-rejecting it (WS6) would block + // a possibly-correct graph from ever being proposed — the exact + // false-negative the transcript audit caught. Downgrade to a + // debug-logged skip; `dry_run_workflow` remains the surface that + // reports it (as an `unverifiable` diagnostic the agent can act on + // via get_tool_contract / get_tool_output_sample). + if let Some(upstream) = + composio_tool_call_upstream_ref(&diag.expression, graph, &step.node_id) + { + tracing::debug!( + target: "flows", + node = %step.node_id, + %slug, + %field, + upstream = %upstream, + expression = %diag.expression, + "[flows] required-arg resolvability check: arg binds to a Composio \ + tool_call's output — UNVERIFIABLE in the echo sandbox (the mock cannot \ + produce real tool output fields), not rejecting; dry_run_workflow \ + reports it instead" + ); + continue; + } tracing::warn!( target: "flows", node = %step.node_id, @@ -1541,6 +1859,72 @@ fn is_trigger_scoped_expression( predecessors.peek().is_some() && predecessors.all(|e| e.from_node == trigger_id) } +/// If a null-resolved config expression on `node_id` is bound to the OUTPUT of +/// an upstream **Composio `tool_call`** node (a `tool_call` whose `slug` is a +/// real Composio action — not `=`-derived, not native `oh:`), returns that +/// upstream node's id; otherwise `None`. +/// +/// The dry-run / gate sandbox renders a Composio `tool_call` as a deterministic +/// echo (`{tool, args, connection}`) and can NEVER produce its real output +/// fields, so a downstream binding to `.item.json.data.` off such a node +/// resolves `null` in the sandbox **even when the wiring is correct** — the +/// binding is UNVERIFIABLE here, not necessarily broken. Callers use this to +/// tell that honest-uncertainty case apart from a genuinely broken binding +/// (one wired to an `agent` / `transform` / `code` / trigger upstream, whose +/// real output the sandbox DOES produce, so a null there IS a real bug). +/// +/// Handles both addressing forms the engine can trace: +/// - explicit `=nodes....` / `=.nodes[""]...` (parsed via +/// [`explicit_nodes_ref`]), and +/// - implicit `=item...` / `=items...`, resolved against `node_id`'s direct +/// predecessor — but only when there is exactly ONE incoming edge, so an +/// ambiguous fan-in is never mis-attributed to a single upstream node. +/// +/// Anything else (a `=run...` trigger reference, a jq expression not rooted at +/// one of the above, or a reference to a non-`tool_call` / native / dynamic +/// node) returns `None`. +pub(crate) fn composio_tool_call_upstream_ref<'a>( + expr: &str, + graph: &'a WorkflowGraph, + node_id: &str, +) -> Option<&'a str> { + let referenced_id: String = if let Some(id) = explicit_nodes_ref(expr) { + id.to_string() + } else { + let body = expr.strip_prefix('=').unwrap_or(expr).trim(); + let body = body.strip_prefix('.').unwrap_or(body); + let is_item_scoped = body == "item" + || body.starts_with("item.") + || body.starts_with("item[") + || body == "items" + || body.starts_with("items.") + || body.starts_with("items["); + if !is_item_scoped { + return None; + } + let mut preds = graph + .edges + .iter() + .filter(|e| e.to_node == node_id) + .map(|e| e.from_node.as_str()); + let first = preds.next()?; + if preds.next().is_some() { + // Ambiguous fan-in — cannot attribute the null to one upstream node. + return None; + } + first.to_string() + }; + let node = graph.nodes.iter().find(|n| n.id == referenced_id)?; + if node.kind != NodeKind::ToolCall { + return None; + } + let slug = node.config.get("slug").and_then(Value::as_str)?; + if slug.starts_with('=') || slug.starts_with("oh:") { + return None; + } + Some(node.id.as_str()) +} + /// Validates a candidate graph without persisting it — the same /// migrate/validate path `flows_create` and `ProposeWorkflowTool` use — and /// reports structural errors alongside non-fatal trigger warnings diff --git a/src/openhuman/flows/ops_tests.rs b/src/openhuman/flows/ops_tests.rs index 4349f0523f..27731b6374 100644 --- a/src/openhuman/flows/ops_tests.rs +++ b/src/openhuman/flows/ops_tests.rs @@ -2059,6 +2059,162 @@ async fn validate_tool_contracts_passes_a_fully_wired_real_slug() { assert!(errors.is_empty(), "{errors:?}"); } +// ── validate_connection_refs (WS3) ────────────────────────────────────────── +// +// The transcript bug: the user's connections were twitter → +// `composio:twitter:ca_JX6QU88UfSk4`, gmail → `composio:gmail:ca_vX_WA8FsqNmE`, +// tiktok → `composio:tiktok:ca_LPCp3WQpaDma`. The agent wired +// `composio:twitter:ca_LPCp3WQpaDma` (the TIKTOK id) onto a Twitter node and +// every author-time gate returned ok. These tests exercise the pure matcher so +// no live Composio backend is touched. + +/// Build a composio `FlowConnection` fixture (the exact shape +/// `build_flow_connections` produces). +fn ws3_flow_conn(toolkit: &str, id: &str) -> FlowConnection { + FlowConnection { + connection_ref: format!("composio:{toolkit}:{id}"), + kind: "composio".to_string(), + display: toolkit.to_string(), + toolkit: Some(toolkit.to_string()), + scheme: None, + } +} + +/// The user's real connected set from the transcript. +fn ws3_transcript_connections() -> Vec { + vec![ + ws3_flow_conn("twitter", "ca_JX6QU88UfSk4"), + ws3_flow_conn("gmail", "ca_vX_WA8FsqNmE"), + ws3_flow_conn("tiktok", "ca_LPCp3WQpaDma"), + ] +} + +/// A single tool_call node graph with `slug` + optional `connection_ref`. +fn ws3_tool_call_graph(slug: &str, connection_ref: Option<&str>) -> WorkflowGraph { + let mut config = json!({ "slug": slug, "args": {} }); + if let Some(cr) = connection_ref { + config["connection_ref"] = json!(cr); + } + graph(json!({ + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Manual" }, + { "id": "act", "kind": "tool_call", "name": "Act", "config": config } + ], + "edges": [ { "from_node": "t", "to_node": "act" } ] + })) +} + +#[test] +fn connection_refs_reject_the_transcript_wrong_id_naming_the_right_ref() { + // Twitter node carrying the TIKTOK connection id: toolkit segment matches + // (twitter == twitter) but the id belongs to no Twitter account. + let g = ws3_tool_call_graph( + "TWITTER_CREATION_OF_A_POST", + Some("composio:twitter:ca_LPCp3WQpaDma"), + ); + let conns = ws3_transcript_connections(); + let errors = validate_connection_refs_against(&g, Some(&conns)); + assert_eq!(errors.len(), 1, "{errors:?}"); + assert!(errors[0].contains("act"), "{}", errors[0]); + assert!( + errors[0].contains("composio:twitter:ca_JX6QU88UfSk4"), + "must name the correct ref verbatim: {}", + errors[0] + ); + assert!(errors[0].contains("did you mean"), "{}", errors[0]); +} + +#[test] +fn connection_refs_reject_a_toolkit_mismatch_naming_the_right_ref() { + // A literal `composio:tiktok:...` ref stamped onto a Twitter node. + let g = ws3_tool_call_graph( + "TWITTER_CREATION_OF_A_POST", + Some("composio:tiktok:ca_LPCp3WQpaDma"), + ); + let conns = ws3_transcript_connections(); + let errors = validate_connection_refs_against(&g, Some(&conns)); + assert_eq!(errors.len(), 1, "{errors:?}"); + assert!(errors[0].contains("tiktok"), "{}", errors[0]); + assert!( + errors[0].contains("composio:twitter:ca_JX6QU88UfSk4"), + "{}", + errors[0] + ); +} + +#[test] +fn connection_refs_reject_an_unknown_id_when_the_toolkit_has_no_connection() { + // Gmail slug, but no gmail account connected at all → point at composio_connect. + let g = ws3_tool_call_graph("GMAIL_SEND_EMAIL", Some("composio:gmail:ca_missing")); + let conns = vec![ws3_flow_conn("twitter", "ca_JX6QU88UfSk4")]; + let errors = validate_connection_refs_against(&g, Some(&conns)); + assert_eq!(errors.len(), 1, "{errors:?}"); + assert!(errors[0].contains("composio_connect"), "{}", errors[0]); + assert!(!errors[0].contains("did you mean"), "{}", errors[0]); +} + +#[test] +fn connection_refs_pass_the_correct_ref() { + let g = ws3_tool_call_graph( + "TWITTER_CREATION_OF_A_POST", + Some("composio:twitter:ca_JX6QU88UfSk4"), + ); + let conns = ws3_transcript_connections(); + let errors = validate_connection_refs_against(&g, Some(&conns)); + assert!(errors.is_empty(), "{errors:?}"); +} + +#[test] +fn connection_refs_reject_a_malformed_ref() { + let g = ws3_tool_call_graph("GMAIL_SEND_EMAIL", Some("gmail-ca_vX_WA8FsqNmE")); + let conns = ws3_transcript_connections(); + let errors = validate_connection_refs_against(&g, Some(&conns)); + assert_eq!(errors.len(), 1, "{errors:?}"); + assert!(errors[0].contains("malformed"), "{}", errors[0]); +} + +#[test] +fn connection_refs_skip_oh_and_refless_and_expression_nodes() { + // Native oh: tool with a ref → skipped. + let g_oh = ws3_tool_call_graph("oh:memory_search", Some("composio:twitter:whatever")); + assert!( + validate_connection_refs_against(&g_oh, Some(&ws3_transcript_connections())).is_empty() + ); + // Composio tool_call with NO connection_ref stays allowed (prompts at run). + let g_refless = ws3_tool_call_graph("TWITTER_CREATION_OF_A_POST", None); + assert!( + validate_connection_refs_against(&g_refless, Some(&ws3_transcript_connections())) + .is_empty() + ); + // `=`-derived slug → skipped. + let g_expr = ws3_tool_call_graph("=item.slug", Some("composio:twitter:ca_LPCp3WQpaDma")); + assert!( + validate_connection_refs_against(&g_expr, Some(&ws3_transcript_connections())).is_empty() + ); +} + +#[test] +fn connection_refs_fail_open_on_unavailable_connections_but_keep_mismatch() { + // Connections unavailable (None): the id-existence check is SKIPPED — a + // toolkit-matched ref with an unknown id passes rather than false-reject. + let g_ok = ws3_tool_call_graph( + "TWITTER_CREATION_OF_A_POST", + Some("composio:twitter:ca_anything"), + ); + assert!( + validate_connection_refs_against(&g_ok, None).is_empty(), + "unknown id must be skipped when connections are unavailable" + ); + // ...but the toolkit-mismatch check needs no I/O and still fires. + let g_mismatch = ws3_tool_call_graph( + "TWITTER_CREATION_OF_A_POST", + Some("composio:tiktok:ca_LPCp3WQpaDma"), + ); + let errors = validate_connection_refs_against(&g_mismatch, None); + assert_eq!(errors.len(), 1, "{errors:?}"); + assert!(errors[0].contains("tiktok"), "{}", errors[0]); +} + // ── validate_required_arg_resolvability (issue B18) ───────────────────────── // // `validate_tool_contracts`'s `missing_required_args` only proves an arg is @@ -2190,6 +2346,95 @@ async fn validate_required_arg_resolvability_rejects_an_explicit_nodes_reference assert!(errors[0].contains("nodes.build_body"), "{}", errors[0]); } +/// A required tool arg wired to a PLAIN agent node's (`no agent_ref`) +/// `output_parser.schema` field must pass this sandbox gate: the schema-aware +/// mock LLM (wired above via `caps.llm = SchemaAwareMockLlm`) synthesizes a +/// schema-valid completion, so the agent's output-parser sub-port succeeds and +/// the downstream `=nodes..item.json.` binding resolves to a typed +/// placeholder (non-null) instead of the run aborting on a schema-validation +/// failure. Without the mock LLM this gate would sink `propose_workflow`/`save` +/// on a correctly-built graph (the vendored `MockLlm` echo fails the sub-port). +#[tokio::test] +async fn validate_required_arg_resolvability_accepts_a_schema_agent_field_binding() { + let g = graph(json!({ + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Manual" }, + { "id": "summarize", "kind": "agent", "name": "Summarize", + "config": { "prompt": "summarize the thread", + "output_parser": { "schema": { "type": "object", + "required": ["channel"], + "properties": { "channel": { "type": "string" } } } } } }, + { "id": "post", "kind": "tool_call", "name": "Post", + "config": { "slug": "SLACK_SEND_MESSAGE", + "args": { "channel": "=nodes.summarize.item.json.channel" } } } + ], + "edges": [ + { "from_node": "t", "to_node": "summarize" }, + { "from_node": "summarize", "to_node": "post" } + ] + })); + let errors = validate_required_arg_resolvability(&g).await; + assert!(errors.is_empty(), "{errors:?}"); +} + +/// WS6: a required arg wired to the OUTPUT of an upstream Composio `tool_call` +/// must NOT be hard-rejected by this gate. The echo sandbox renders a Composio +/// `tool_call` as `{tool, args, connection}` and can never produce its real +/// output fields, so `=nodes..item.json.data.` resolves `null` +/// here even when the wiring is perfectly correct — rejecting it would block a +/// possibly-correct graph from ever being proposed (the transcript false +/// negative). Contrast `..._rejects_an_explicit_nodes_reference` above, where +/// the same explicit-`nodes` form addresses a `code` node (whose real output +/// the sandbox DOES produce) and stays a hard reject. +#[tokio::test] +async fn validate_required_arg_resolvability_downgrades_a_composio_tool_call_upstream_binding() { + let g = graph(json!({ + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Manual" }, + { "id": "get_me", "kind": "tool_call", "name": "Who am I", + "config": { "slug": "TWITTER_USER_LOOKUP_ME", "args": {} } }, + { "id": "post", "kind": "tool_call", "name": "Post", + "config": { "slug": "GMAIL_SEND_EMAIL", + "args": { "recipient_email": "a@b.com", "subject": "hi", + "body": "=nodes.get_me.item.json.data.username" } } } + ], + "edges": [ + { "from_node": "t", "to_node": "get_me" }, + { "from_node": "get_me", "to_node": "post" } + ] + })); + let errors = validate_required_arg_resolvability(&g).await; + assert!( + errors.is_empty(), + "a binding to a Composio tool_call's output is UNVERIFIABLE, not a hard reject: {errors:?}" + ); +} + +/// WS6 companion: the implicit `=item...` form of the same case — `post`'s only +/// predecessor is a Composio `tool_call`, so `=item.json.data.username` +/// addresses that node's (echo-only) output and is likewise unverifiable, not a +/// reject. +#[tokio::test] +async fn validate_required_arg_resolvability_downgrades_an_item_scoped_composio_upstream_binding() { + let g = graph(json!({ + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Manual" }, + { "id": "get_me", "kind": "tool_call", "name": "Who am I", + "config": { "slug": "TWITTER_USER_LOOKUP_ME", "args": {} } }, + { "id": "post", "kind": "tool_call", "name": "Post", + "config": { "slug": "GMAIL_SEND_EMAIL", + "args": { "recipient_email": "a@b.com", "subject": "hi", + "body": "=item.json.data.username" } } } + ], + "edges": [ + { "from_node": "t", "to_node": "get_me" }, + { "from_node": "get_me", "to_node": "post" } + ] + })); + let errors = validate_required_arg_resolvability(&g).await; + assert!(errors.is_empty(), "{errors:?}"); +} + /// (Codex feedback on this PR) `notion` ships a static curated catalog /// (`catalog_for_toolkit`), so at RUNTIME `flow_tool_allowed`'s Path A /// hard-rejects any slug `find_curated` doesn't recognize — even a real, diff --git a/src/openhuman/flows/tools.rs b/src/openhuman/flows/tools.rs index 8a36cf5e99..20eb1278d8 100644 --- a/src/openhuman/flows/tools.rs +++ b/src/openhuman/flows/tools.rs @@ -261,6 +261,10 @@ impl Tool for ProposeWorkflowTool { Ok(ToolResult::success(serde_json::to_string_pretty(&json!({ "type": "workflow_proposal", + // A proposal is never a persisted flow — stamp it so the payload + // can't be misread as a save confirmation (WS2 audit). Matches + // `ops::build_builder_proposal`'s unconditional persisted:false. + "persisted": false, "name": name, "graph": graph_value, "require_approval": require_approval, diff --git a/src/openhuman/flows/tools_tests.rs b/src/openhuman/flows/tools_tests.rs index 3ce228532e..59912f2fcf 100644 --- a/src/openhuman/flows/tools_tests.rs +++ b/src/openhuman/flows/tools_tests.rs @@ -58,6 +58,9 @@ async fn valid_graph_returns_workflow_proposal_success() { assert_eq!(parsed["type"], "workflow_proposal"); assert_eq!(parsed["name"], "Daily standup summary"); assert_eq!(parsed["graph"]["nodes"].as_array().unwrap().len(), 3); + // A proposal is never a persisted flow — the payload must say so (WS2) so + // an agent can't misread it as a save confirmation. + assert_eq!(parsed["persisted"], false); } #[tokio::test] diff --git a/src/openhuman/tinyflows/caps.rs b/src/openhuman/tinyflows/caps.rs index 486fb9f864..12d37105a7 100644 --- a/src/openhuman/tinyflows/caps.rs +++ b/src/openhuman/tinyflows/caps.rs @@ -1200,6 +1200,71 @@ impl AgentRunner for SchemaAwareMockAgentRunner { } } +/// A **dry-run-only** [`LlmProvider`] mock that, unlike the vendored crate's +/// `tinyflows::caps::mock::MockLlm`, respects an `agent` node's +/// `config.output_parser.schema` when synthesizing its completion. +/// +/// This closes the OTHER half of the same gap [`SchemaAwareMockAgentRunner`] +/// closes. The vendored `agent` node only routes to an [`AgentRunner`] when the +/// node carries a **non-empty `agent_ref`** AND the host wired an agent registry +/// (`vendor/tinyflows/src/nodes/integration/agent.rs`, `run_turn`: +/// `(Some(agent_ref), Some(runner)) => runner.run_agent(...)`); **every other +/// case** — and builder-generated agent nodes carry NO `agent_ref` — falls back +/// to `ctx.caps.llm.complete(cfg.clone(), conn)`. So in the sandbox those plain +/// agent nodes never reach `SchemaAwareMockAgentRunner` at all: they hit the +/// `llm` slot, which (with the vendored `MockLlm`) echoes +/// `{ "completion": , "connection": }`. The agent node's +/// output-parser sub-port then validates that echo against the declared schema +/// (`schema::parse_and_validate` — it validates the WHOLE completion value, not +/// a `.text` field), no field matches, and it falls to a one-shot LLM auto-fix +/// that the same `MockLlm` also can't satisfy — so the dry run errors with +/// `output_parser: value failed schema validation after auto-fix: missing +/// required property ...` even for a workflow a real run would execute cleanly. +/// This false-failure burned many dry-run cycles for correctly-built graphs. +/// +/// When `request` (the node config the node hands to `complete` — see the +/// `_ => ctx.caps.llm.complete(cfg.clone(), conn)` arm above) carries a non-null +/// `output_parser.schema`, this returns [`placeholder_for_schema`] DIRECTLY. +/// The sub-port receives that already-schema-valid object as its `value` +/// (`validate` returns no errors), so it returns `Ok` WITHOUT ever invoking the +/// auto-fix LLM path — exactly the shape the vendored validator's +/// `type`/`required`/`enum` checks accept, with no real model call. With no +/// schema, it mirrors the vendored `MockLlm` echo shape byte-for-byte +/// (`{ "completion": request, "connection": conn }`) so schema-less agent +/// dry-run behavior — and downstream `=nodes..item.json.completion...` +/// bindings — stay identical to today. +#[derive(Debug, Default, Clone)] +pub struct SchemaAwareMockLlm; + +#[async_trait] +impl LlmProvider for SchemaAwareMockLlm { + async fn complete(&self, request: Value, conn: Option<&str>) -> Result { + let schema = request + .get("output_parser") + .and_then(|parser| parser.get("schema")) + .filter(|schema| !schema.is_null()); + match schema { + Some(schema) => { + let placeholder = placeholder_for_schema(schema); + tracing::debug!( + target: "flows", + "[flows] dry_run: schema-aware mock LLM synthesized a placeholder \ + matching output_parser.schema (plain agent node, no agent_ref)" + ); + Ok(placeholder) + } + None => { + tracing::debug!( + target: "flows", + "[flows] dry_run: schema-aware mock LLM has no output_parser.schema — \ + mirroring the vendored MockLlm echo shape" + ); + Ok(json!({ "completion": request, "connection": conn })) + } + } + } +} + /// Builds a placeholder JSON value satisfying `schema`'s `properties`/`type` /// constraints, for [`SchemaAwareMockAgentRunner`]. Only the shallow, top-level /// `properties` map is populated — enough for the minimal validator in @@ -3578,6 +3643,62 @@ mod tests { assert_eq!(out["request"], request); } + // ── SchemaAwareMockLlm ─────────────────────────────────────────────────── + + #[tokio::test] + async fn schema_aware_mock_llm_mirrors_vendored_echo_without_a_schema() { + // No `output_parser.schema`: byte-identical to the vendored `MockLlm` + // so schema-less agent dry runs (which route to the `llm` slot, not the + // runner) keep today's `{ completion, connection }` shape. + let llm = SchemaAwareMockLlm; + let request = json!({ "prompt": "hi" }); + let out = llm + .complete(request.clone(), Some("conn_1")) + .await + .expect("complete"); + assert_eq!(out["completion"], request); + assert_eq!(out["connection"], "conn_1"); + + let without_conn = llm.complete(request, None).await.expect("complete"); + assert!(without_conn["connection"].is_null()); + } + + #[tokio::test] + async fn schema_aware_mock_llm_synthesizes_a_schema_valid_completion() { + // A plain agent node (no `agent_ref`) hands its config to the `llm` + // slot; the returned object must pass the output-parser sub-port's + // validator directly (no auto-fix hop) for every declared type. + let llm = SchemaAwareMockLlm; + let request = json!({ + "prompt": "extract", + "output_parser": { "schema": { "type": "object", + "required": ["email", "count", "active", "meta", "tags"], + "properties": { + "email": { "type": "string" }, + "count": { "type": "integer" }, + "active": { "type": "boolean" }, + "meta": { "type": "object" }, + "tags": { "type": "array" } + } } } + }); + let out = llm.complete(request, None).await.expect("complete"); + assert_eq!(out["email"], ""); + assert_eq!(out["count"], 0); + assert_eq!(out["active"], false); + assert_eq!(out["meta"], json!({})); + assert_eq!(out["tags"], json!([])); + } + + #[tokio::test] + async fn schema_aware_mock_llm_ignores_null_schema() { + // `output_parser: { schema: null }` is treated as "no schema" — the + // vendored echo shape, same as the runner's null-schema handling. + let llm = SchemaAwareMockLlm; + let request = json!({ "prompt": "hi", "output_parser": { "schema": null } }); + let out = llm.complete(request.clone(), None).await.expect("complete"); + assert_eq!(out["completion"], request); + } + #[test] fn placeholder_for_schema_falls_back_to_type_without_properties() { assert_eq!( diff --git a/vendor/tinyflows b/vendor/tinyflows index f18238904f..e5327de9f6 160000 --- a/vendor/tinyflows +++ b/vendor/tinyflows @@ -1 +1 @@ -Subproject commit f18238904f48c395dce99c0c1d759c3dd1bfb678 +Subproject commit e5327de9f602c1fbbf72d45150729f45b91fa3a8 From 2cbf6302936229c3a52136913151b466f15ab6c2 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:33:31 +0530 Subject: [PATCH 03/18] fix(flows): guarantee a terminal state on every flows_build turn (no silent trail-off) (#4887) --- src/openhuman/flows/ops.rs | 207 +++++++++++++++++++++++++++++-- src/openhuman/flows/ops_tests.rs | 186 +++++++++++++++++++++++++++ 2 files changed, 381 insertions(+), 12 deletions(-) diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index 69b39cd5aa..2868bd8ded 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -4095,26 +4095,25 @@ pub async fn flows_build( } }; - // Emit the terminal chat event so a client viewing the copilot thread stops - // "processing" and finalizes the assistant bubble (the bridge streams only - // intermediate deltas). Success delivers `chat_done`; a run error delivers - // `chat_error`. The blocking return below is unchanged. - if let Some(target) = &stream { - let terminal: Result = match &run_error { - None => Ok(assistant_text.clone()), - Some(err) => Err(err.clone()), - }; - finalize_flow_stream(target, &terminal, &prompt).await; - } - // Capture the proposal from the run's tool history (propose/revise/save all // emit the same self-describing `{ type: "workflow_proposal", … }` payload). + // Extracted BEFORE the stream is finalized below (issue: builder + // convergence): the trail-off backstop needs `proposal`/`capped` to decide + // whether to override `assistant_text`, and the streamed copilot-pane chat + // bubble must render the SAME (possibly-overridden) text as the RPC + // response — the frontend renders from the stream, not the return value, + // so patching only the latter would still leave an interactive user + // staring at the original silent/status-only text. let proposal = extract_workflow_proposal(agent.history()); // A run that both errored AND produced no proposal is a hard failure; a run // that proposed before erroring still returns the proposal for review. if proposal.is_none() { if let Some(err) = &run_error { + if let Some(target) = &stream { + let terminal: Result = Err(err.clone()); + finalize_flow_stream(target, &terminal, &prompt).await; + } return Err(format!("workflow_builder produced no proposal: {err}")); } } @@ -4132,11 +4131,51 @@ pub async fn flows_build( let hit_cap = agent.last_turn_hit_cap(); let capped = hit_cap && proposal.is_none(); + // Terminal-state guarantee (builder convergence fix): a turn can end + // "naturally" (no more tool calls, not capped, no run error) yet still + // produce neither a proposal nor a real question — the model ran out of + // steam mid-build and left a status dump ("Done so far: checked + // connections…") as its final reply. `prompt.md` tells the model to + // always end a building turn in a proposal or a question, but a prompt + // rule can be silently ignored; this is the fail-closed backend backstop + // that makes it a hard invariant regardless of model behavior — the user + // is NEVER left with silence or an unanswerable status note. + let trail_off = !capped && proposal.is_none() && run_error.is_none(); + let assistant_text = if trail_off && !text_looks_like_question(&assistant_text) { + let fallback = build_trail_off_fallback(agent.history()); + tracing::warn!( + target: "flows", + flow_id = req.flow_id.as_deref().unwrap_or(""), + original_len = assistant_text.len(), + fallback_len = fallback.len(), + "[flows] flows_build: trail-off detected (no proposal, no cap, no question) — \ + guaranteeing a fallback question instead of silence" + ); + fallback + } else { + assistant_text + }; + + // Emit the terminal chat event so a client viewing the copilot thread stops + // "processing" and finalizes the assistant bubble (the bridge streams only + // intermediate deltas). Success delivers `chat_done`; a run error delivers + // `chat_error`. The blocking return below is unchanged. Uses the + // (possibly trail-off-overridden) `assistant_text` above. + if let Some(target) = &stream { + let terminal: Result = match &run_error { + None => Ok(assistant_text.clone()), + Some(err) => Err(err.clone()), + }; + finalize_flow_stream(target, &terminal, &prompt).await; + } + tracing::info!( target: "flows", + flow_id = req.flow_id.as_deref().unwrap_or(""), has_proposal = proposal.is_some(), hit_cap, capped, + trail_off, "[flows] flows_build: workflow_builder turn complete" ); Ok(RpcOutcome::single_log( @@ -4145,11 +4184,155 @@ pub async fn flows_build( "assistant_text": assistant_text, "error": run_error, "capped": capped, + "trail_off": trail_off, }), "workflow builder turn complete", )) } +/// Heuristic: does `text` already end with a clear, answerable question? +/// Conservative by design (issue: builder convergence) — a false negative (an +/// actual question this misses) just wraps it in the trail-off fallback, +/// which still includes the blocker context, so the safe failure mode is +/// "over-wrap", never "under-detect and stay silent". +fn text_looks_like_question(text: &str) -> bool { + let trimmed = text + .trim() + .trim_end_matches(|c: char| matches!(c, '"' | '\'' | ')' | ']' | '*' | '_' | '`' | '.')) + .trim_end(); + if trimmed.is_empty() { + return false; + } + if trimmed.ends_with('?') { + return true; + } + // The question may not be the literal last character (trailing markdown + // like a closing code fence or list marker on its own line) — fall back + // to the last non-blank line. This does NOT catch a question followed by + // a further trailing sentence ("...channel?\n\nLet me know!") — that's + // an accepted false negative: the turn still ends in a real (if + // over-eagerly replaced) question, never in silence, which is the + // invariant this function exists to protect. + trimmed + .lines() + .filter(|line| !line.trim().is_empty()) + .next_back() + .is_some_and(|last_line| last_line.trim_end().ends_with('?')) +} + +/// Builder-authoring tools whose result body can explain a trail-off — the +/// authoring belt `dry_run_workflow`/`validate_workflow`/`propose_workflow`/ +/// `revise_workflow`/`edit_workflow`/`save_workflow` all report either a hard +/// gate rejection (`ToolResult::error`) or a self-reported broken-graph +/// result (`"ok": false` in a successful body), so a plain-text read-only +/// tool's output is never misattributed as the blocker. +const TRAIL_OFF_BLOCKER_TOOLS: &[&str] = &[ + "dry_run_workflow", + "validate_workflow", + "propose_workflow", + "revise_workflow", + "edit_workflow", + "save_workflow", +]; + +/// Synthesizes a guaranteed, user-facing fallback for a trail-off turn (no +/// proposal, not capped, no run error, and the model's own text isn't a +/// question). Scans the run's tool history for the last builder-tool result +/// that looks like a blocker (a hard-gate rejection, or a `dry_run_workflow`/ +/// `validate_workflow` report with `"ok": false`) and asks the user about it; +/// falls back to a generic "what should I focus on" question when no such +/// blocker is found (the model may have simply stopped with nothing to point +/// to). +fn build_trail_off_fallback( + history: &[crate::openhuman::inference::provider::ConversationMessage], +) -> String { + match last_builder_tool_blocker(history) { + Some(blocker) => format!( + "I wasn't able to finish building this workflow. Here's where I got stuck:\n\n{blocker}\n\n\ + Could you tell me how you'd like me to resolve that, or share more detail about what's needed here?" + ), + None => "I wasn't able to finish building this workflow in this turn. Could you describe \ + what you'd like in more detail, or tell me which part to focus on?" + .to_string(), + } +} + +/// Scans `history` in reverse for the last result from a +/// [`TRAIL_OFF_BLOCKER_TOOLS`] call that reads as a failure — a plain-text +/// error message (gate rejection), or a JSON body with `"ok": false` — and +/// returns a truncated, human-readable description of it. Tool names are +/// resolved by correlating each `ToolResults` entry's `tool_call_id` back to +/// the `AssistantToolCalls` message that issued it, so this never +/// misattributes an unrelated read-only tool's plain-text output as a +/// blocker. +fn last_builder_tool_blocker( + history: &[crate::openhuman::inference::provider::ConversationMessage], +) -> Option { + use crate::openhuman::inference::provider::ConversationMessage; + + let mut call_names: std::collections::HashMap = + std::collections::HashMap::new(); + for message in history { + if let ConversationMessage::AssistantToolCalls { tool_calls, .. } = message { + for call in tool_calls { + call_names.insert(call.id.clone(), call.name.clone()); + } + } + } + + for message in history.iter().rev() { + let ConversationMessage::ToolResults(results) = message else { + continue; + }; + for result in results.iter().rev() { + let Some(name) = call_names.get(&result.tool_call_id) else { + continue; + }; + if !TRAIL_OFF_BLOCKER_TOOLS.contains(&name.as_str()) { + continue; + } + // This is the MOST RECENT authoring-belt tool result in the + // turn (results are scanned newest-first). Whatever it reads as + // is authoritative: a success/progress result here means any + // earlier failure from the same tool was already resolved + // within this turn, so we must stop at this result rather than + // keep walking backward and surfacing a stale, already-fixed + // blocker (see review discussion on this PR). + return describe_tool_result_blocker(&result.content) + .map(|desc| crate::openhuman::util::truncate_with_ellipsis(&desc, 500)); + } + } + None +} + +/// Reads one builder tool result's content as a failure description, or +/// `None` when it reads as success/progress (a `workflow_proposal` payload, +/// or an `"ok": true` report). The whole body is the description, never one +/// hardcoded field, so this stays correct regardless of which fields a given +/// tool uses to explain its failure. +fn describe_tool_result_blocker(content: &str) -> Option { + let trimmed = content.trim(); + if trimmed.is_empty() { + return None; + } + if let Ok(value) = serde_json::from_str::(trimmed) { + if value.get("type").and_then(Value::as_str) == Some("workflow_proposal") { + return None; // Success: a proposal was emitted. + } + if let Some(ok) = value.get("ok").and_then(Value::as_bool) { + return if ok { None } else { Some(value.to_string()) }; + } + // Some other structured payload with no `ok`/`type` marker this + // function recognises — not confidently a blocker, skip it. + return None; + } + // Non-JSON content: a hard-gate rejection (`ToolResult::error`) puts the + // plain error message straight into the content — since every builder + // tool's SUCCESS shape is JSON (a proposal or a `{ ok, ... }` report), a + // bare string here is, by elimination, an error message. + Some(trimmed.to_string()) +} + /// Scans an agent run's conversation history for the workflow proposal a builder /// tool emitted. `propose_workflow` / `revise_workflow` / `save_workflow` all /// return a self-describing `{ "type": "workflow_proposal", … }` JSON string as diff --git a/src/openhuman/flows/ops_tests.rs b/src/openhuman/flows/ops_tests.rs index 27731b6374..22c5dc8b8a 100644 --- a/src/openhuman/flows/ops_tests.rs +++ b/src/openhuman/flows/ops_tests.rs @@ -4240,3 +4240,189 @@ async fn compute_required_connections_skips_native_and_http_nodes() { "native oh: and http_request need no connection: {required:?}" ); } + +// ───────────────────────────────────────────────────────────────────────────── +// Builder convergence fix — trail-off backstop (`flows_build`'s terminal-state +// guarantee: every turn ends in a proposal or a real question, never silence). +// ───────────────────────────────────────────────────────────────────────────── + +fn builder_tool_call( + id: &str, + name: &str, +) -> crate::openhuman::inference::provider::ConversationMessage { + use crate::openhuman::inference::provider::{ConversationMessage, ToolCall}; + ConversationMessage::AssistantToolCalls { + text: None, + tool_calls: vec![ToolCall { + id: id.to_string(), + name: name.to_string(), + arguments: "{}".to_string(), + extra_content: None, + }], + reasoning_content: None, + extra_metadata: None, + } +} + +fn builder_tool_result( + call_id: &str, + content: &str, +) -> crate::openhuman::inference::provider::ConversationMessage { + use crate::openhuman::inference::provider::{ConversationMessage, ToolResultMessage}; + ConversationMessage::ToolResults(vec![ToolResultMessage { + tool_call_id: call_id.to_string(), + content: content.to_string(), + }]) +} + +#[test] +fn text_looks_like_question_detects_trailing_question_mark() { + assert!(text_looks_like_question( + "Which Slack channel should I post to?" + )); + assert!(text_looks_like_question("Which channel?\n")); + // Trailing markdown/punctuation noise after the '?' shouldn't defeat it. + assert!(text_looks_like_question("Which channel should I use?\"")); + // A trailing blank line after the question is still detected (the last + // NON-BLANK line is what's checked). + assert!(text_looks_like_question( + "Which channel should I post to?\n\n" + )); +} + +/// A question followed by a further trailing sentence on its own line +/// ("...channel?\n\nLet me know!") is an accepted false negative — the +/// heuristic is deliberately conservative (see the function doc). Pin that +/// this case is NOT detected so a future "improvement" doesn't silently +/// change the accepted trade-off without a matching design review. +#[test] +fn text_looks_like_question_accepts_false_negative_on_trailing_pleasantry() { + assert!(!text_looks_like_question( + "Which channel should I post to?\n\nLet me know!" + )); +} + +#[test] +fn text_looks_like_question_rejects_status_dumps_and_silence() { + assert!(!text_looks_like_question( + "## Done so far\n- Checked connections\n- Verified contracts" + )); + assert!(!text_looks_like_question("")); + assert!(!text_looks_like_question(" ")); + assert!(!text_looks_like_question("I'll continue working on this.")); +} + +/// The terminal-state guarantee's core invariant: whatever `build_trail_off_fallback` +/// returns, it must ALWAYS read as a question — the user is never left with +/// silence, regardless of what (if anything) the tool history contains. +#[test] +fn build_trail_off_fallback_always_yields_a_question() { + let fallback = build_trail_off_fallback(&[]); + assert!( + text_looks_like_question(&fallback), + "fallback with no tool history must still be a question: {fallback}" + ); + assert!(!fallback.trim().is_empty()); +} + +#[test] +fn build_trail_off_fallback_surfaces_last_dry_run_blocker() { + let history = vec![ + builder_tool_call("call_1", "dry_run_workflow"), + builder_tool_result( + "call_1", + r#"{"ok": false, "null_resolutions": [{"node_id": "send", "path": "args.channel"}]}"#, + ), + ]; + let fallback = build_trail_off_fallback(&history); + assert!( + text_looks_like_question(&fallback), + "blocker fallback must still end in a question: {fallback}" + ); + assert!( + fallback.contains("null_resolutions"), + "fallback should surface the actual dry-run blocker, got: {fallback}" + ); +} + +#[test] +fn build_trail_off_fallback_surfaces_gate_rejection_error_text() { + let history = vec![ + builder_tool_call("call_1", "propose_workflow"), + builder_tool_result( + "call_1", + "propose_workflow rejected: tool slug 'slack:not_a_real_action' does not exist", + ), + ]; + let fallback = build_trail_off_fallback(&history); + assert!(text_looks_like_question(&fallback)); + assert!(fallback.contains("does not exist")); +} + +#[test] +fn build_trail_off_fallback_ignores_unrelated_read_tool_output() { + // A plain-text result from a tool OUTSIDE the builder authoring belt (e.g. + // a read-only history lookup) must never be misattributed as the blocker + // — this stays tool-agnostic within the authoring belt, not "any tool". + let history = vec![ + builder_tool_call("call_1", "get_flow_history"), + builder_tool_result("call_1", "no prior revisions found"), + ]; + let fallback = build_trail_off_fallback(&history); + assert!(text_looks_like_question(&fallback)); + assert!( + !fallback.contains("no prior revisions found"), + "must not surface an unrelated read-tool's output as the blocker: {fallback}" + ); +} + +#[test] +fn build_trail_off_fallback_ignores_a_successful_proposal_payload() { + let history = vec![ + builder_tool_call("call_1", "propose_workflow"), + builder_tool_result( + "call_1", + r#"{"type": "workflow_proposal", "name": "demo", "graph": {}}"#, + ), + ]; + let fallback = build_trail_off_fallback(&history); + assert!(text_looks_like_question(&fallback)); + assert!(!fallback.contains("workflow_proposal")); +} + +#[test] +fn build_trail_off_fallback_picks_the_most_recent_blocker() { + // Two dry-run failures in the history: the fallback should describe the + // LAST one (the one the agent was still stuck on), not the first. + let history = vec![ + builder_tool_call("call_1", "dry_run_workflow"), + builder_tool_result("call_1", r#"{"ok": false, "errors": ["first issue"]}"#), + builder_tool_call("call_2", "dry_run_workflow"), + builder_tool_result("call_2", r#"{"ok": false, "errors": ["second issue"]}"#), + ]; + let fallback = build_trail_off_fallback(&history); + assert!(fallback.contains("second issue")); + assert!(!fallback.contains("first issue")); +} + +/// Regression for review feedback (chatgpt-codex-connector, PR #4887): a +/// dry-run failure that the agent goes on to FIX later in the same turn +/// (a later `{"ok": true}` from the same authoring belt) must not be +/// resurfaced as "here's where I got stuck" — that failure is already +/// resolved. The scan must stop at the most recent authoring-belt result, +/// not keep walking backward past a success to an older, stale blocker. +#[test] +fn build_trail_off_fallback_does_not_resurface_a_resolved_blocker() { + let history = vec![ + builder_tool_call("call_1", "dry_run_workflow"), + builder_tool_result("call_1", r#"{"ok": false, "errors": ["first issue"]}"#), + builder_tool_call("call_2", "dry_run_workflow"), + builder_tool_result("call_2", r#"{"ok": true, "warnings": []}"#), + ]; + let fallback = build_trail_off_fallback(&history); + assert!( + !fallback.contains("first issue"), + "must not surface an already-resolved blocker: {fallback}" + ); + assert!(text_looks_like_question(&fallback)); +} From ece34201f540fd2598867367c93bd424b4857b3a Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:48:45 +0530 Subject: [PATCH 04/18] =?UTF-8?q?fix(flows):=20save-safety=20=E2=80=94=20n?= =?UTF-8?q?o=20silent=20live-arming=20on=20update,=20flag=20empty=20runs,?= =?UTF-8?q?=20close=20resume=5Fflow=5Frun=20HITL=20bypass=20(#4889)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/openhuman/flows/ops.rs | 165 +++++++++++++++-- src/openhuman/flows/ops_tests.rs | 277 ++++++++++++++++++++++++++--- src/openhuman/flows/store.rs | 18 +- src/openhuman/flows/store_tests.rs | 2 + 4 files changed, 418 insertions(+), 44 deletions(-) diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index 2868bd8ded..3f5f7bae3f 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -366,6 +366,24 @@ pub(crate) fn graph_has_outbound_side_effect(graph: &WorkflowGraph) -> bool { }) } +/// Whether `graph` has anything for [`flows_run`] to actually *do* — i.e. at +/// least one non-`trigger` node, wired up by at least one edge. A graph made +/// of nothing but a bare `trigger` node (or a `trigger` plus unreachable/ +/// disconnected nodes with no edges at all) can compile and "run" cleanly +/// while producing no work whatsoever — the exact live finding this guards: +/// a trigger-only flow reported `status="completed" pending_approvals=0` +/// having done nothing, which reads as a successful automation to anyone not +/// staring at the node count. Used by `flows_run` to attach a +/// human-readable note to an otherwise-silent "success". +pub(crate) fn graph_has_actionable_nodes(graph: &WorkflowGraph) -> bool { + let non_trigger_nodes = graph + .nodes + .iter() + .filter(|n| n.kind != NodeKind::Trigger) + .count(); + non_trigger_nodes > 0 && !graph.edges.is_empty() +} + /// Produces host-side, **non-fatal** validation warnings for a graph — today /// exactly one: "this trigger kind does not fire automatically yet". Returns /// an empty vec when the trigger fires (`manual`/`schedule`/`app_event`), when @@ -2462,6 +2480,26 @@ fn map_flow_update_error(e: store::FlowUpdateError) -> String { /// old cadence, or a newly-added schedule would never get bound at all. /// Skipped entirely for a name/`require_approval`-only update (no /// `graph_json` supplied), since the trigger definitely didn't change. +/// +/// **B29 Rule 1 analogue for saves** (save/enable safety — same issue +/// `flows_create` guards at creation time, see its doc): `flows_create` +/// refuses to persist an automatic-trigger graph (`schedule` / `app_event` / +/// `webhook`, see [`trigger_is_automatic`]) as `enabled`, but that guard only +/// runs once, at creation. Without an equivalent here, a flow created +/// `enabled: true` with a manual/no-op trigger could later have an +/// automatic-trigger graph saved onto it — via the `save_workflow` agent +/// tool, the canvas Save button, a proposal apply, or any other +/// `flows_update` caller — and go LIVE immediately with no user review +/// (confirmed live: a flow started firing on an unreviewed 8am schedule). +/// So: when the *new* graph's trigger is automatic, the flow is *currently* +/// enabled, and the *previous* graph's trigger was NOT automatic (a +/// manual/none → automatic transition), this forces the persisted `enabled` +/// back to `false` in the same store write — the user must explicitly +/// re-arm via `flows_set_enabled` after reviewing the new trigger. +/// Deliberately narrower than Rule 1's at-create version: a flow that was +/// already an enabled *automatic*-trigger flow being legitimately re-edited +/// (e.g. tweaking a cron expression) is left alone — the user already opted +/// in once, and re-disarming on every edit would just be friction. pub async fn flows_update( config: &Config, id: &str, @@ -2485,17 +2523,47 @@ pub async fn flows_update( } }; + // B29 Rule 1 analogue: only disarm a manual/none → automatic transition + // on an already-enabled flow. An automatic → automatic re-edit, or a + // flow that isn't enabled to begin with, is untouched. + let was_auto = trigger_is_automatic(&existing.graph); + let now_auto = trigger_is_automatic(&graph); + let should_disarm = now_auto && existing.enabled && !was_auto; + let enabled_override = should_disarm.then_some(false); + tracing::debug!( + target: "flows", + flow_id = %id, + was_auto, + now_auto, + currently_enabled = existing.enabled, + should_disarm, + "[flows] flows_update: auto-trigger disarm decision inputs" + ); + tracing::debug!(target: "flows", flow_id = %id, has_expected = expected_version.is_some(), "[flows] flows_update: persisting changes"); + // `enabled_override` is threaded into the same guarded UPDATE as the + // graph/name/require_approval write (see `store::update_flow_graph`) + // rather than a follow-up `flows_set_enabled` call, so the disarm can + // never race a concurrent read/write of `enabled`. let updated = store::update_flow_graph( config, id, new_name, graph, new_require_approval, + enabled_override, expected_version.as_deref(), ) .map_err(map_flow_update_error)?; + if should_disarm { + tracing::info!( + target: "flows", + flow_id = %id, + "[flows] flows_update: auto-disabled — graph changed manual→automatic trigger on an enabled flow" + ); + } + if graph_changed && updated.enabled { let trigger_unchanged = bus::extract_trigger_kind(&existing) == bus::extract_trigger_kind(&updated) @@ -2508,10 +2576,16 @@ pub async fn flows_update( } publish_flow_changed(id, "updated", "system"); - Ok(RpcOutcome::single_log( - updated, - format!("flow updated: {id}"), - )) + let mut logs = vec![format!("flow updated: {id}")]; + if should_disarm { + logs.push( + "Flow was auto-disabled because its trigger changed from manual to automatic \ + (schedule / app_event / webhook). Enable it explicitly (flows_set_enabled) once \ + you've reviewed the new trigger." + .to_string(), + ); + } + Ok(RpcOutcome::new(updated, logs)) } /// Lists a flow's revision history (prior graph snapshots), newest first, @@ -2856,6 +2930,24 @@ pub async fn flows_run( .map_err(|e| e.to_string())? .ok_or_else(|| format!("flow '{flow_id}' not found"))?; + // Live finding: a graph with no actionable nodes (only a `trigger`, or a + // `trigger` plus nodes with no edges wiring them up) compiles and "runs" + // cleanly but does nothing — and previously reported + // `status="completed" pending_approvals=0` indistinguishably from a real + // run, reading as "triggered but nothing happened" was actually a + // success. Surface it loudly instead of letting it pass silently: warn + // now (independent of how the run below turns out), and attach a + // human-readable note to the returned outcome so the UI can show + // "nothing to run" rather than a bare "completed". + let no_actionable_nodes = !graph_has_actionable_nodes(&flow.graph); + if no_actionable_nodes { + tracing::warn!( + target: "flows", + flow_id = %flow_id, + "[flows] flows_run: flow has no actionable nodes — nothing to execute" + ); + } + // `store::get_flow` already ran the stored `graph_json` through // `tinyflows::migrate::migrate` before deserializing, so `flow.graph` is // always on the current schema here. @@ -3011,17 +3103,26 @@ pub async fn flows_run( flow_id = %flow_id, status, pending_approvals = outcome.pending_approvals.len(), + no_actionable_nodes, "[flows] flows_run: finished" ); - Ok(RpcOutcome::single_log( - json!({ - "output": outcome.output, - "pending_approvals": outcome.pending_approvals, - "thread_id": thread_id, - }), - format!("flow run {status}"), - )) + const NO_ACTIONABLE_NODES_NOTE: &str = "This flow's graph has no actionable nodes beyond \ + its trigger (no downstream action nodes, or no edges connecting them) — the run \ + completed without doing anything. Add and wire up at least one action node."; + + let mut result = json!({ + "output": outcome.output, + "pending_approvals": outcome.pending_approvals, + "thread_id": thread_id, + }); + let mut logs = vec![format!("flow run {status}")]; + if no_actionable_nodes { + result["note"] = json!(NO_ACTIONABLE_NODES_NOTE); + logs.push(NO_ACTIONABLE_NODES_NOTE.to_string()); + } + + Ok(RpcOutcome::new(result, logs)) } /// Resumes a `flows_run` that paused at a human-in-the-loop approval gate, @@ -3949,7 +4050,9 @@ pub async fn flows_discover( const FLOW_BUILD_TIMEOUT_SECS: u64 = 600; /// Tools stripped from the `workflow_builder` belt on the direct `flows_build` -/// RPC path (issue #4593). +/// RPC path (issue #4593; widened for `resume_flow_run`/`cancel_flow_run` +/// alongside issue #4881, which added both to the belt without extending +/// this list). /// /// `flows_build` runs the builder under [`AgentTurnOrigin::Cli`] so the approval /// gate does not fail-closed in a headless/streamed run — but that same origin @@ -3969,22 +4072,46 @@ const FLOW_BUILD_TIMEOUT_SECS: u64 = 600; /// name (now the unrelated harness spawn tool) is listed too as belt-and-braces /// against a re-rename or the name ever leaking back onto this belt; /// `hide_tools` no-ops on a name that isn't present. -const FLOWS_BUILD_HIDDEN_TOOLS: &[&str] = &["run_workflow", "run_flow"]; +/// +/// `resume_flow_run` ([`builder_tools::ResumeFlowRunTool`]) is the exact same +/// concern as `run_flow`, one hop later: it is `external_effect() == true` +/// (its own description says "This ADVANCES A REAL RUN — approved outbound +/// nodes will fire") and would be auto-allowed by the same `Cli`-origin gate +/// bypass, letting an authoring turn (or a confused/prompt-injected model) +/// approve a live run's parked Slack/Gmail/HTTP node with zero human +/// confirmation — the exact HITL hole #4593 closed, reopened by #4881 +/// widening the belt. +/// +/// `cancel_flow_run` fires no new outbound effect +/// (`external_effect() == false`), so it isn't a gate-bypass concern the same +/// way — but an authoring turn still has no business tearing down a run the +/// *user* started, so it is hidden alongside the two above out of caution. +/// +/// `create_workflow` / `duplicate_flow` are deliberately **left visible**: +/// both are hard-forced **born disabled** (see [`builder_tools::CreateWorkflowTool`] +/// / [`builder_tools::DuplicateFlowTool`]), so even an unattended call can't +/// leave anything live — lower risk than the run/resume/cancel trio above. +const FLOWS_BUILD_HIDDEN_TOOLS: &[&str] = &[ + "run_workflow", + "run_flow", + "resume_flow_run", + "cancel_flow_run", +]; -/// Strip the live-run tool(s) in [`FLOWS_BUILD_HIDDEN_TOOLS`] from `agent`'s -/// callable set for the direct `flows_build` RPC path. +/// Strip the live-run / resume / cancel tool(s) in [`FLOWS_BUILD_HIDDEN_TOOLS`] +/// from `agent`'s callable set for the direct `flows_build` RPC path. /// /// Delegates to [`crate::openhuman::agent::Agent::hide_tools`], which removes /// the names from the builder's (already narrow) visible belt and rebuilds the /// session's `ToolPolicySession` so they resolve to `Deny` at the tool-call /// boundary — a hard execution guarantee even if the model requests the tool. -/// The authoring tools (`propose`/`revise`/`save`/`dry_run`/reads) are all -/// `external_effect() == false` and untouched, so the turn never fail-closes. +/// The authoring tools (`propose`/`revise`/`save`/`dry_run`/reads/`create_workflow`/ +/// `duplicate_flow`) stay visible and untouched, so the turn never fail-closes. fn restrict_builder_toolset(agent: &mut crate::openhuman::agent::Agent) { tracing::debug!( target: "flows", hidden = ?FLOWS_BUILD_HIDDEN_TOOLS, - "[flows] flows_build: hiding live-run tools from builder belt" + "[flows] flows_build: hiding live-run/resume/cancel tools from builder belt" ); agent.hide_tools(FLOWS_BUILD_HIDDEN_TOOLS); } diff --git a/src/openhuman/flows/ops_tests.rs b/src/openhuman/flows/ops_tests.rs index 22c5dc8b8a..a8a789d02a 100644 --- a/src/openhuman/flows/ops_tests.rs +++ b/src/openhuman/flows/ops_tests.rs @@ -228,6 +228,79 @@ async fn flows_run_completes_trigger_only_graph() { assert!(reloaded.value.last_run_at.is_some()); } +/// Live finding: a trigger-only graph (no downstream action nodes at all) +/// used to report `status="completed" pending_approvals=0` from `flows_run` +/// completely indistinguishably from a run that actually did something — +/// "triggered but nothing happened" read as a plain success. This asserts +/// the run still completes (running an empty flow isn't an error), but now +/// carries a human-readable `note` in the result so the UI can show +/// "nothing to run" instead of a bare "completed". +#[tokio::test] +async fn flows_run_on_trigger_only_graph_surfaces_no_actionable_nodes_note() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let created = flows_create(&config, "empty".to_string(), trigger_only_graph(), false) + .await + .unwrap(); + + let outcome = flows_run(&config, &created.value.id, json!({}), FlowRunTrigger::Rpc) + .await + .unwrap(); + + let note = outcome.value["note"] + .as_str() + .expect("trigger-only run must carry a human-readable 'note' field"); + assert!( + note.contains("no actionable nodes") || note.to_lowercase().contains("nothing"), + "note should explain that nothing ran, got: {note}" + ); + assert!( + outcome.logs.iter().any(|l| l.contains("no actionable")), + "the note should also surface via the RpcOutcome logs, got: {:?}", + outcome.logs + ); + + // Still a completed run, not an error — an empty flow isn't a failure, + // just a no-op that must not masquerade as having done real work. + let reloaded = flows_get(&config, &created.value.id).await.unwrap(); + assert_eq!(reloaded.value.last_status.as_deref(), Some("completed")); +} + +/// A graph with a real downstream node, wired up by an edge, must NOT carry +/// the "nothing to run" note — only a graph with no actionable nodes at all. +/// Uses `output_parser` nodes (like the approval-gated fixture above) rather +/// than an `agent`/`tool_call` node so the run completes deterministically +/// without needing a configured LLM provider or network access. +#[tokio::test] +async fn flows_run_on_graph_with_actionable_nodes_has_no_empty_flow_note() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let graph = json!({ + "name": "has-work", + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Trigger" }, + { "id": "downstream", "kind": "output_parser", "name": "Downstream" } + ], + "edges": [ + { "from_node": "t", "to_node": "downstream" } + ] + }); + let created = flows_create(&config, "has-work".to_string(), graph, false) + .await + .unwrap(); + + let outcome = flows_run(&config, &created.value.id, json!({}), FlowRunTrigger::Rpc) + .await + .unwrap(); + + assert!( + outcome.value.get("note").is_none(), + "a graph with real downstream nodes must not get the empty-flow note, got: {:?}", + outcome.value.get("note") + ); +} + #[tokio::test] async fn flows_run_reports_pending_approval_and_blocks_downstream() { let tmp = TempDir::new().unwrap(); @@ -559,6 +632,141 @@ async fn flows_update_does_not_rebind_when_graph_is_not_supplied() { assert_eq!(job.expression, old_job.expression); } +// ── flows_update B29 Rule 1 analogue (save/enable safety on update) ─────── +// +// `flows_create` already refuses to persist an automatic-trigger graph as +// `enabled` (Rule 1, above). Live finding: `flows_update` had no equivalent +// — a flow created `enabled: true` with a manual trigger could later have an +// automatic-trigger graph (schedule / app_event / webhook) saved onto it via +// `flows_update` and go LIVE immediately with no user review. These tests +// cover the manual→automatic transition (must disarm), automatic→automatic +// re-edit (must NOT disarm — the user already opted in), and manual→manual +// (never touched). + +#[tokio::test] +async fn flows_update_disables_on_manual_to_automatic_trigger_transition_when_enabled() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + // A manual-trigger flow persists enabled straight from create (Rule 1 + // only gates automatic triggers). + let created = flows_create( + &config, + "manual-then-scheduled".to_string(), + manual_trigger_graph(), + false, + ) + .await + .unwrap(); + assert!(created.value.enabled, "manual-trigger flows create enabled"); + + // Saving an automatic-trigger graph onto that enabled flow must disarm + // it — not go live unattended. + let updated = flows_update( + &config, + &created.value.id, + None, + Some(schedule_trigger_graph("0 8 * * *")), + None, + None, + ) + .await + .unwrap(); + + assert!( + !updated.value.enabled, + "an enabled flow whose trigger just changed from manual to automatic must be \ + auto-disabled, not armed live" + ); + assert!( + updated.logs.iter().any(|l| l.contains("auto-disabled")), + "the disarm must be surfaced in the outcome logs, got: {:?}", + updated.logs + ); + + // Persisted, not just returned in-memory. + let reloaded = flows_get(&config, &created.value.id).await.unwrap(); + assert!(!reloaded.value.enabled); + + // And no cron job was left bound — the flow never actually went live. + assert!( + crate::openhuman::cron::find_flow_schedule_job(&config, &created.value.id) + .unwrap() + .is_none(), + "an auto-disabled flow must not have its schedule cron job bound" + ); +} + +#[tokio::test] +async fn flows_update_preserves_enabled_when_already_automatic() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + // Rule 1 creates an automatic-trigger flow disabled; the user arms it + // explicitly — this IS the "already reviewed and opted in" state. + let created = flows_create( + &config, + "scheduled".to_string(), + schedule_trigger_graph("0 9 * * *"), + false, + ) + .await + .unwrap(); + assert!(!created.value.enabled); + flows_set_enabled(&config, &created.value.id, true) + .await + .unwrap(); + + // A legitimate re-edit (still an automatic trigger, just a new cron + // expression) must NOT be treated as a fresh unattended arm. + let updated = flows_update( + &config, + &created.value.id, + None, + Some(schedule_trigger_graph("30 8 * * *")), + None, + None, + ) + .await + .unwrap(); + + assert!( + updated.value.enabled, + "re-editing an already-enabled automatic-trigger flow must not disarm it — the \ + user already opted in once" + ); + assert!(!updated.logs.iter().any(|l| l.contains("auto-disabled"))); +} + +#[tokio::test] +async fn flows_update_preserves_enabled_for_manual_target() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let created = flows_create(&config, "manual".to_string(), manual_trigger_graph(), false) + .await + .unwrap(); + assert!(created.value.enabled); + + // manual → manual: no automatic trigger ever enters the picture, so + // `enabled` must be left completely untouched. + let mut new_graph = manual_trigger_graph(); + new_graph["name"] = json!("manual-renamed"); + let updated = flows_update( + &config, + &created.value.id, + None, + Some(new_graph), + None, + None, + ) + .await + .unwrap(); + + assert!(updated.value.enabled); + assert!(!updated.logs.iter().any(|l| l.contains("auto-disabled"))); +} + // ── flows_resume (issue B2) ─────────────────────────────────────────────── fn approval_gated_graph() -> Value { @@ -3454,21 +3662,25 @@ fn finalize_terminal_status_no_error_when_clean() { assert_eq!(error, None); } -/// Regression for issue #4593: the `flows_build` builder turn runs under -/// `AgentTurnOrigin::Cli`, which makes the `ApprovalGate` auto-allow every -/// `external_effect` tool. The flows live-runner executes a *live* saved flow, -/// so it must be unreachable on this path — `restrict_builder_toolset` drops it -/// from the builder's callable belt while leaving the authoring tools in place -/// so the turn still functions (never fail-closes). +/// Regression for issue #4593 (widened for #4881's `resume_flow_run`/ +/// `cancel_flow_run` addition to the belt): the `flows_build` builder turn +/// runs under `AgentTurnOrigin::Cli`, which makes the `ApprovalGate` +/// auto-allow every `external_effect` tool. The flows live-runner (`run_flow`) +/// and the run-resume tool (`resume_flow_run`) both execute/advance a *live* +/// saved flow's real outbound effects, so both must be unreachable on this +/// path — `restrict_builder_toolset` drops them (plus `cancel_flow_run`, out +/// of caution) from the builder's callable belt while leaving the authoring +/// tools in place so the turn still functions (never fail-closes). #[tokio::test] async fn flows_build_hides_the_live_run_tool_from_the_builder_belt() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - // Document WHY the live-runner must be hidden: running a saved flow fires - // real Slack/Gmail/HTTP/code effects, so it is an external-effect tool. This - // pins that invariant independently of belt name-resolution so the - // hide-list can't silently stop covering a live-run tool. + // Document WHY each run-advancing tool must be hidden: running or + // resuming a saved flow fires real Slack/Gmail/HTTP/code effects, so both + // are external-effect tools. This pins that invariant independently of + // belt name-resolution so the hide-list can't silently stop covering a + // live-run/resume tool. use crate::openhuman::tools::Tool as _; let live_runner = crate::openhuman::flows::tools::RunFlowTool::new(std::sync::Arc::new(config.clone())); @@ -3476,6 +3688,14 @@ async fn flows_build_hides_the_live_run_tool_from_the_builder_belt() { live_runner.external_effect(), "the flows live-runner must be external-effect for the #4593 concern to apply" ); + let resumer = crate::openhuman::flows::builder_tools::ResumeFlowRunTool::new( + std::sync::Arc::new(config.clone()), + ); + assert!( + resumer.external_effect(), + "resume_flow_run advances a real run's outbound effects, so it must be \ + external-effect for the same #4593/#4881 concern to apply" + ); crate::openhuman::agent::harness::AgentDefinitionRegistry::init_global(&config.workspace_dir) .expect("agent registry init"); @@ -3484,27 +3704,36 @@ async fn flows_build_hides_the_live_run_tool_from_the_builder_belt() { .expect("build workflow_builder agent"); agent.set_agent_definition_name("workflow_builder".to_string()); - // Precondition: the builder advertises the live-run tool (`run_flow`) on its - // belt before restriction — the exact tool #4593 is about. - assert!( - agent.visible_tool_names_for_test().contains("run_flow"), - "precondition: workflow_builder belt should advertise the live-run tool `run_flow`; \ - visible = {:?}", - agent.visible_tool_names_for_test() - ); + // Precondition: the builder advertises all four run-advancing tools on its + // belt before restriction — the exact set #4593/#4881 are about. + let visible_before = agent.visible_tool_names_for_test(); + for present in ["run_flow", "resume_flow_run", "cancel_flow_run"] { + assert!( + visible_before.contains(present), + "precondition: workflow_builder belt should advertise `{present}`; visible = \ + {visible_before:?}" + ); + } restrict_builder_toolset(&mut agent); - // After restriction neither the current name nor the post-rename name is - // callable on the flows_build path — the hide-list covers both (#4593). + // After restriction none of the run-advancing tools are callable on the + // flows_build path — the hide-list covers all of them (#4593 + #4881). let visible = agent.visible_tool_names_for_test(); - for hidden in ["run_workflow", "run_flow"] { + for hidden in [ + "run_workflow", + "run_flow", + "resume_flow_run", + "cancel_flow_run", + ] { assert!( !visible.contains(hidden), - "live-run tool `{hidden}` must be hidden on the flows_build path; visible = {visible:?}" + "run-advancing tool `{hidden}` must be hidden on the flows_build path; visible = \ + {visible:?}" ); } - // Authoring / read tools stay reachable so the builder turn still works + // Authoring / read tools — including the born-disabled `create_workflow` + // and `duplicate_flow` — stay reachable so the builder turn still works // headlessly under the CLI origin (no fail-close). for keep in [ "propose_workflow", @@ -3512,6 +3741,8 @@ async fn flows_build_hides_the_live_run_tool_from_the_builder_belt() { "save_workflow", "dry_run_workflow", "list_flows", + "create_workflow", + "duplicate_flow", ] { assert!( visible.contains(keep), diff --git a/src/openhuman/flows/store.rs b/src/openhuman/flows/store.rs index 9d9b95f990..3eb7e0c1bf 100644 --- a/src/openhuman/flows/store.rs +++ b/src/openhuman/flows/store.rs @@ -374,12 +374,22 @@ impl std::fmt::Display for FlowUpdateError { /// flow's `updated_at` no longer matches — so an agent save and a concurrent /// canvas save can't silently clobber each other. `None` keeps the prior /// last-write-wins behaviour for callers that don't track a version. +/// +/// `enabled_override`, when `Some`, forces the persisted `enabled` flag to +/// that value in the *same* guarded `UPDATE` as the graph/name/ +/// `require_approval` write — used by `ops::flows_update`'s B29 Rule 1 +/// analogue (auto-disarming a flow whose trigger just changed from manual to +/// automatic) so the disarm can never race a concurrent read/write of +/// `enabled` (a separate `set_enabled` call after this one would leave a +/// TOCTOU window). `None` leaves `enabled` untouched, matching the previous +/// behaviour for every other caller. pub fn update_flow_graph( config: &Config, id: &str, name: String, graph: tinyflows::model::WorkflowGraph, require_approval: bool, + enabled_override: Option, expected_updated_at: Option<&str>, ) -> std::result::Result { let current = get_flow(config, id) @@ -400,21 +410,25 @@ pub fn update_flow_graph( let prior_graph_json = serde_json::to_string(¤t.graph).unwrap_or_else(|_| "null".to_string()); let now = Utc::now().to_rfc3339(); + let new_enabled = enabled_override.unwrap_or(current.enabled); with_connection(config, |conn| { // Guarded UPDATE keyed on the observed updated_at (race-safe even // without an explicit expected version) — a concurrent writer that // moved updated_at makes this match 0 rows. Targeted columns only, so a - // concurrent set_enabled/record_run isn't clobbered. + // concurrent set_enabled/record_run isn't clobbered (unless this call + // itself carries an `enabled_override`, in which case `enabled` is + // one of the targeted columns by design). let changed = conn .execute( "UPDATE flow_definitions SET name = ?1, graph_json = ?2, updated_at = ?3, \ - require_approval = ?4 WHERE id = ?5 AND updated_at = ?6", + require_approval = ?4, enabled = ?5 WHERE id = ?6 AND updated_at = ?7", params![ name, graph_json, now, if require_approval { 1 } else { 0 }, + if new_enabled { 1 } else { 0 }, id, current.updated_at, ], diff --git a/src/openhuman/flows/store_tests.rs b/src/openhuman/flows/store_tests.rs index 8192cb0e73..778712a0e9 100644 --- a/src/openhuman/flows/store_tests.rs +++ b/src/openhuman/flows/store_tests.rs @@ -98,6 +98,7 @@ fn update_flow_graph_bumps_updated_at_and_preserves_created_at() { new_graph, false, None, + None, ) .unwrap(); @@ -204,6 +205,7 @@ fn update_flow_graph_can_change_require_approval() { trigger_graph(), true, None, + None, ) .unwrap(); assert!(updated.require_approval); From 1ddd6751d6e12e8fb27de76ff353643f18ad7daa Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:53:45 +0530 Subject: [PATCH 05/18] =?UTF-8?q?fix(flows):=20copilot=20proposals=20reach?= =?UTF-8?q?=20the=20canvas=20=E2=80=94=20recognition,=20race-guard,=20titl?= =?UTF-8?q?e=20(#4886)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/pages/FlowCanvasPage.tsx | 99 +++++- .../pages/__tests__/FlowCanvasPage.test.tsx | 284 ++++++++++++++++++ app/src/providers/ChatRuntimeProvider.tsx | 35 +-- .../__tests__/ChatRuntimeProvider.test.tsx | 82 +++++ app/src/store/chatRuntimeSlice.test.ts | 70 +++++ app/src/store/chatRuntimeSlice.ts | 11 +- 6 files changed, 550 insertions(+), 31 deletions(-) diff --git a/app/src/pages/FlowCanvasPage.tsx b/app/src/pages/FlowCanvasPage.tsx index 4123b9d95a..cd81a4db3a 100644 --- a/app/src/pages/FlowCanvasPage.tsx +++ b/app/src/pages/FlowCanvasPage.tsx @@ -158,6 +158,19 @@ function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } +/** + * True when `title` is "unclaimed" — either blank or exactly the localized + * generic placeholder (`t('flows.page.newWorkflow')`, "New workflow") — i.e. + * nothing user-meaningful has been set yet. Used to decide whether accepting + * a copilot proposal is allowed to adopt `proposal.name` as the flow title: + * it must never clobber a user-chosen or description-derived name. Pure and + * exported for direct unit testing without rendering `FlowEditor`. + */ +export function isPlaceholderTitle(title: string, placeholder: string): boolean { + const trimmed = title.trim(); + return trimmed === '' || trimmed === placeholder.trim(); +} + function BackIcon() { return ( (editorFlow.name); const commitRename = useCallback(async () => { const trimmed = titleDraft.trim(); @@ -311,6 +332,7 @@ function FlowEditor({ log('rename: flow id=%s name=%s', flowId, trimmed); await updateFlow(flowId, { name: trimmed }); setName(trimmed); + persistedNameRef.current = trimmed; } catch (err) { log('rename failed: id=%s err=%o', flowId, err); setTitleDraft(name); @@ -422,12 +444,44 @@ function FlowEditor({ [draftGraph] ); - const handleAcceptProposal = useCallback((proposal: WorkflowProposal) => { - log('copilot proposal accepted'); - setDraftGraph(proposal.graph as WorkflowGraph); - setPreview(null); - setCanvasVersion(v => v + 1); - }, []); + const handleAcceptProposal = useCallback( + (proposal: WorkflowProposal) => { + log('copilot proposal accepted'); + setDraftGraph(proposal.graph as WorkflowGraph); + setPreview(null); + setCanvasVersion(v => v + 1); + + // Adopt the proposal's name into the flow title, but ONLY while the + // title is still the generic placeholder — never clobber a user-chosen + // or description-derived meaningful name. This does not persist by + // itself (matching the "accept doesn't persist" invariant below) — it + // rides into the next Save via `name`/`handleSave`. + // + // Check the VISIBLE `titleDraft`, not the committed `name` — `name` + // only updates on blur/Enter via `commitRename`, so if the user is + // mid-typing a custom title (or a rename is still in flight) when a + // proposal is accepted, `name` can still read as the stale placeholder + // while `titleDraft` already holds the user's real input. Deciding off + // `name` would silently clobber that in-progress input. Also skip + // entirely while `renaming` is true — an in-flight `commitRename` + // persist must not race with a local proposal-driven rename. + const proposedName = proposal.name?.trim(); + if ( + proposedName && + !renaming && + isPlaceholderTitle(titleDraft, t('flows.page.newWorkflow')) + ) { + // Log shape, not the user-authored name (no PII in logs). + log( + 'copilot proposal accepted: adopting proposed name into placeholder title, isDraft=%s', + isDraft + ); + setName(proposedName); + setTitleDraft(proposedName); + } + }, + [titleDraft, renaming, t, isDraft] + ); const handleRejectProposal = useCallback(() => { log('copilot proposal rejected'); @@ -453,9 +507,15 @@ function FlowEditor({ () => ({ schema_version: graph.schema_version, id: flowId ?? undefined, name }), [graph.schema_version, flowId, name] ); + // Also dirty when a copilot-adopted proposal name has changed the flow's + // `name` without yet persisting it (`persistedNameRef` only advances on a + // real Save/rename) — a name-only proposal (same graph, new name) must + // still enable Save, or the adopted title can never be persisted. const initialDirty = useMemo( - () => JSON.stringify(editorGraph) !== JSON.stringify(persistedGraphRef.current), - [editorGraph] + () => + JSON.stringify(editorGraph) !== JSON.stringify(persistedGraphRef.current) || + name !== persistedNameRef.current, + [editorGraph, name] ); // Repair seed for the copilot: bind the run context to the CURRENT draft. @@ -503,11 +563,30 @@ function FlowEditor({ navigate(`/flows/${created.id}`, { replace: true }); return; } - log('save: flow id=%s nodes=%d edges=%d', flowId, next.nodes.length, next.edges.length); - const updated = await updateFlow(flowId, { graph: next }); + // Only include `name` in the update payload when it actually diverges + // from the last-known-persisted baseline (a manual rename already + // persisted it via `commitRename`; a copilot-adopted placeholder name + // has not) — keeps the update metadata-safe and avoids needless renames. + const nameChanged = name !== persistedNameRef.current; + log( + 'save: flow id=%s nodes=%d edges=%d nameChanged=%s', + flowId, + next.nodes.length, + next.edges.length, + nameChanged + ); + const updated = await updateFlow(flowId, { graph: next, ...(nameChanged ? { name } : {}) }); const persisted = updated.graph as WorkflowGraph; persistedGraphRef.current = persisted; + persistedNameRef.current = updated.name; setDraftGraph(persisted); + if (updated.name !== name) { + // Re-sync BOTH title states from the response — leaving `titleDraft` + // stale would show the pre-save value in the input and could + // resubmit it verbatim on a later blur. + setName(updated.name); + setTitleDraft(updated.name); + } setCanvasVersion(v => v + 1); log( 'save: flow id=%s persisted — canvas re-synced from response nodes=%d edges=%d', diff --git a/app/src/pages/__tests__/FlowCanvasPage.test.tsx b/app/src/pages/__tests__/FlowCanvasPage.test.tsx index 0303e31bbd..62c416ce8e 100644 --- a/app/src/pages/__tests__/FlowCanvasPage.test.tsx +++ b/app/src/pages/__tests__/FlowCanvasPage.test.tsx @@ -10,10 +10,12 @@ import { createMemoryRouter, MemoryRouter, Route, RouterProvider, Routes } from import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Flow } from '../../services/api/flowsApi'; +import type { WorkflowProposal } from '../../store/chatRuntimeSlice'; import FlowCanvasPage, { asCopilotBuildSeed, asCopilotPrefillSeed, FlowCanvasDraftPage, + isPlaceholderTitle, } from '../FlowCanvasPage'; const getFlow = vi.hoisted(() => vi.fn()); @@ -388,6 +390,288 @@ describe('FlowCanvasPage', () => { }); }); +describe('isPlaceholderTitle', () => { + it('treats an empty or whitespace-only title as a placeholder', () => { + expect(isPlaceholderTitle('', 'New workflow')).toBe(true); + expect(isPlaceholderTitle(' ', 'New workflow')).toBe(true); + }); + + it('treats the localized generic placeholder as a placeholder', () => { + expect(isPlaceholderTitle('New workflow', 'New workflow')).toBe(true); + expect(isPlaceholderTitle(' New workflow ', 'New workflow')).toBe(true); + }); + + it('does not treat a user-chosen or description-derived name as a placeholder', () => { + expect(isPlaceholderTitle('My flow', 'New workflow')).toBe(false); + expect(isPlaceholderTitle('Standup reminder', 'New workflow')).toBe(false); + }); +}); + +// ----------------------------------------------------------------------------- +// Copilot proposal name adoption — accepting a `propose_workflow` proposal +// carries a top-level `name` the canvas previously dropped, leaving the flow +// titled the generic placeholder even when the agent proposed a real name. +// ----------------------------------------------------------------------------- +function makeProposal(overrides: Partial = {}): WorkflowProposal { + return { + name: 'Standup reminder', + graph: { + schema_version: 1, + name: 'Standup reminder', + nodes: [ + { + id: 't', + kind: 'trigger', + name: 'Start', + config: {}, + ports: [], + position: { x: 0, y: 0 }, + }, + { + id: 'a', + kind: 'agent', + name: 'Send reminder', + config: {}, + ports: [], + position: { x: 80, y: 80 }, + }, + ], + edges: [], + }, + requireApproval: false, + summary: { trigger: 'manual', steps: [] }, + ...overrides, + }; +} + +describe('FlowCanvasPage copilot proposal name adoption', () => { + beforeEach(() => { + copilotPanelProps.current = null; + getFlow.mockReset(); + updateFlow.mockReset(); + createFlow.mockReset(); + validateFlow.mockReset(); + listFlowConnections.mockReset(); + validateFlow.mockResolvedValue({ valid: true, errors: [], warnings: [] }); + listFlowConnections.mockResolvedValue([]); + }); + + function renderEditor(id = 'test-id') { + return render( + + + } /> + Flows list} /> + + + ); + } + + it('adopts the proposal name when the title is the generic placeholder', async () => { + getFlow.mockResolvedValue(makeFlow({ name: 'New workflow' })); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('New workflow'); + + act(() => { + (copilotPanelProps.current?.onAccept as (p: WorkflowProposal) => void)(makeProposal()); + }); + + await waitFor(() => + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('Standup reminder') + ); + }); + + it('adopts the proposal name when the title is blank', async () => { + getFlow.mockResolvedValue(makeFlow({ name: '' })); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + act(() => { + (copilotPanelProps.current?.onAccept as (p: WorkflowProposal) => void)(makeProposal()); + }); + + await waitFor(() => + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('Standup reminder') + ); + }); + + it('does not clobber a user-set title when accepting a proposal', async () => { + getFlow.mockResolvedValue(makeFlow({ name: 'My flow' })); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + act(() => { + (copilotPanelProps.current?.onAccept as (p: WorkflowProposal) => void)(makeProposal()); + }); + + // Give any (incorrect) state update a chance to flush, then assert the + // title is unchanged. + await Promise.resolve(); + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('My flow'); + }); + + // Regression (CodeRabbit on #4886): the committed `name` only updates on + // blur/Enter (`commitRename`), so while the user is still typing a custom + // title the committed `name` can read as the stale placeholder even though + // the visible input already holds real user input. Adoption must check the + // VISIBLE `titleDraft`, or it clobbers in-progress typing. + it('does not clobber an in-progress (uncommitted) title edit when accepting a proposal', async () => { + getFlow.mockResolvedValue(makeFlow({ name: 'New workflow' })); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + // User is mid-typing a custom title — not yet committed via blur/Enter. + fireEvent.change(screen.getByTestId('flow-canvas-title'), { + target: { value: 'My in-progress title' }, + }); + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('My in-progress title'); + + act(() => { + (copilotPanelProps.current?.onAccept as (p: WorkflowProposal) => void)(makeProposal()); + }); + + await Promise.resolve(); + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('My in-progress title'); + }); + + it('includes the adopted name in the flows_update payload on Save (persisted flow)', async () => { + getFlow.mockResolvedValue(makeFlow({ name: 'New workflow' })); + updateFlow.mockResolvedValue(makeFlow({ name: 'Standup reminder' })); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + act(() => { + (copilotPanelProps.current?.onAccept as (p: WorkflowProposal) => void)(makeProposal()); + }); + await waitFor(() => + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('Standup reminder') + ); + + fireEvent.click(screen.getByTestId('flow-editor-save')); + fireEvent.click(screen.getByTestId('flow-action-confirm-accept')); + + await waitFor(() => expect(updateFlow).toHaveBeenCalledTimes(1)); + const [calledId, update] = updateFlow.mock.calls[0]; + expect(calledId).toBe('test-id'); + expect(update.name).toBe('Standup reminder'); + expect(update.graph).toBeDefined(); + }); + + // Regression (CodeRabbit on #4886): accepting a proposal that changes only + // the top-level `name` (graph unchanged) previously left the editor's dirty + // state false — since the graph-only diff saw no change — so Save stayed + // disabled and the adopted title could never be persisted. + it('marks the editor dirty when an accepted proposal changes only the name', async () => { + const flow = makeFlow({ name: 'New workflow' }); + getFlow.mockResolvedValue(flow); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + // Clean on load. + expect(screen.queryByTestId('flow-editor-dirty')).not.toBeInTheDocument(); + + act(() => { + (copilotPanelProps.current?.onAccept as (p: WorkflowProposal) => void)( + makeProposal({ name: 'Standup reminder', graph: flow.graph }) + ); + }); + + await waitFor(() => + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('Standup reminder') + ); + // Graph is byte-identical to the persisted baseline — only the name + // changed — but the editor must still report dirty so Save is enabled. + await waitFor(() => expect(screen.getByTestId('flow-editor-dirty')).toBeInTheDocument()); + + fireEvent.click(screen.getByTestId('flow-editor-save')); + fireEvent.click(screen.getByTestId('flow-action-confirm-accept')); + + await waitFor(() => expect(updateFlow).toHaveBeenCalledTimes(1)); + const [, update] = updateFlow.mock.calls[0]; + expect(update.name).toBe('Standup reminder'); + }); + + // Regression (CodeRabbit on #4886): when the backend returns a name that + // differs from what was submitted (server-side normalization), the title + // input must re-sync to the persisted value too — not just the committed + // `name` — or the stale draft can be resubmitted verbatim on a later blur. + it('re-syncs titleDraft from the persisted response name on Save', async () => { + getFlow.mockResolvedValue(makeFlow({ name: 'New workflow' })); + updateFlow.mockResolvedValue(makeFlow({ name: 'Standup Reminder (normalized)' })); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + act(() => { + (copilotPanelProps.current?.onAccept as (p: WorkflowProposal) => void)(makeProposal()); + }); + await waitFor(() => + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('Standup reminder') + ); + + fireEvent.click(screen.getByTestId('flow-editor-save')); + fireEvent.click(screen.getByTestId('flow-action-confirm-accept')); + + await waitFor(() => expect(updateFlow).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('Standup Reminder (normalized)') + ); + }); + + it('passes the adopted name to createFlow on Save (draft flow)', async () => { + createFlow.mockResolvedValue(makeFlow({ id: 'created-id', name: 'Standup reminder' })); + render( + + + } /> + } /> + + + ); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('New workflow'); + + act(() => { + (copilotPanelProps.current?.onAccept as (p: WorkflowProposal) => void)(makeProposal()); + }); + await waitFor(() => + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('Standup reminder') + ); + + fireEvent.click(screen.getByTestId('flow-editor-save')); + fireEvent.click(screen.getByTestId('flow-action-confirm-accept')); + + await waitFor(() => expect(createFlow).toHaveBeenCalledTimes(1)); + const [name] = createFlow.mock.calls[0]; + expect(name).toBe('Standup reminder'); + expect(updateFlow).not.toHaveBeenCalled(); + }); +}); + describe('asCopilotBuildSeed', () => { it('accepts a copilotBuild state with a non-empty description', () => { expect(asCopilotBuildSeed({ copilotBuild: { description: 'digest my Slack' } })).toEqual({ diff --git a/app/src/providers/ChatRuntimeProvider.tsx b/app/src/providers/ChatRuntimeProvider.tsx index e93b985077..ba137661d4 100644 --- a/app/src/providers/ChatRuntimeProvider.tsx +++ b/app/src/providers/ChatRuntimeProvider.tsx @@ -272,30 +272,27 @@ function chatTurnUsagePayload(event: ChatDoneEvent): { * card. */ /** - * Tool names whose successful `output` carries a `workflow_proposal` payload. - * `propose_workflow` (first draft) and `revise_workflow` (iterative refine) - * both return the identical wire shape (see `src/openhuman/flows/builder_tools.rs`), - * so the runtime surfaces a `WorkflowProposalCard` from either. These run inside - * the `workflow_builder` specialist — reached either as the main agent's own - * tool or, in the Flows copilot / prompt-bar flow, as a delegated subagent - * (`build_workflow`) — so BOTH `onToolResult` and `onSubagentToolResult` funnel - * through {@link maybeParseWorkflowProposalTool}. - */ -const WORKFLOW_PROPOSAL_TOOLS = new Set(['propose_workflow', 'revise_workflow']); - -/** - * If a completed tool result is a successful workflow-builder proposal - * (`propose_workflow`/`revise_workflow`), parse it. Returns `null` for anything - * else so callers can cheaply gate on it. Keyed by the tool NAME + success, not - * by agent, so a proposal surfaces whether the tool ran in the main agent or in - * the delegated `workflow_builder` worker. + * Recognition is content-based, not name-based: ANY workflow-builder tool + * (`propose_workflow`, `revise_workflow`, `edit_workflow`, and any future + * addition) whose successful `output` is `{ type: "workflow_proposal", ... }` + * (see `src/openhuman/flows/builder_tools.rs` / `ops::build_builder_proposal`) + * is surfaced as a `WorkflowProposalCard`. This mirrors the Rust-side blocking + * path's `extract_workflow_proposal`, which also scans tool results by + * payload `type` rather than tool name — a name allowlist here can silently + * drop proposals from newly added tools (as happened when `edit_workflow` was + * added without updating this list). `parseWorkflowProposal`'s own + * `obj.type !== 'workflow_proposal'` check is the only gate needed. These + * tools run inside the `workflow_builder` specialist — reached either as the + * main agent's own tool or, in the Flows copilot / prompt-bar flow, as a + * delegated subagent (`build_workflow`) — so BOTH `onToolResult` and + * `onSubagentToolResult` funnel through {@link maybeParseWorkflowProposalTool}. */ function maybeParseWorkflowProposalTool( - toolName: string, + _toolName: string, success: boolean, output: string | undefined ): WorkflowProposal | null { - if (!success || !WORKFLOW_PROPOSAL_TOOLS.has(toolName) || !output) return null; + if (!success || !output) return null; return parseWorkflowProposal(output); } diff --git a/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx b/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx index 353a349a20..ed25001852 100644 --- a/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx +++ b/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx @@ -1490,6 +1490,88 @@ describe('ChatRuntimeProvider — dedupe, proactive resolution, mid-turn invaria summary: { trigger: 'signup.created' }, }); }); + + // Regression (#4876 fallout): `edit_workflow` is the prompt-preferred way + // to iterate on an existing draft, and returns the identical + // `{ type: "workflow_proposal", ... }` payload as `propose_workflow` / + // `revise_workflow`. Proposal recognition is content-based (on the + // payload's `type`), not gated on a fixed tool-name allowlist, so this + // must surface a proposal exactly like the other two tools do — a + // name-based allowlist previously dropped it silently. + it('surfaces a workflow proposal from the main-agent edit_workflow tool', () => { + const listeners = renderProvider(); + const threadId = 't-edit-workflow'; + + act(() => { + listeners.onToolCall?.({ + thread_id: threadId, + request_id: 'r1', + round: 0, + tool_name: 'edit_workflow', + skill_id: 'flows', + args: {}, + tool_call_id: 'call-edit-1', + }); + listeners.onToolResult?.({ + thread_id: threadId, + request_id: 'r1', + round: 0, + tool_name: 'edit_workflow', + skill_id: 'flows', + success: true, + output: JSON.stringify({ + type: 'workflow_proposal', + name: 'Digest patch', + graph: { nodes: [], edges: [] }, + require_approval: true, + draft_id: 'draft-42', + summary: { trigger: 'manual', steps: [] }, + }), + tool_call_id: 'call-edit-1', + }); + }); + + const proposal = store.getState().chatRuntime.pendingWorkflowProposalsByThread[threadId]; + expect(proposal).toMatchObject({ name: 'Digest patch', requireApproval: true }); + }); + + // Any future tool that returns `{ type: "workflow_proposal" }` must be + // recognised too — the gate is the payload shape, not a tool-name list. + it('surfaces a workflow proposal from an unrecognised future tool name', () => { + const listeners = renderProvider(); + const threadId = 't-future-tool'; + + act(() => { + listeners.onToolCall?.({ + thread_id: threadId, + request_id: 'r1', + round: 0, + tool_name: 'some_future_builder_tool', + skill_id: 'flows', + args: {}, + tool_call_id: 'call-future-1', + }); + listeners.onToolResult?.({ + thread_id: threadId, + request_id: 'r1', + round: 0, + tool_name: 'some_future_builder_tool', + skill_id: 'flows', + success: true, + output: JSON.stringify({ + type: 'workflow_proposal', + name: 'Future proposal', + graph: { nodes: [], edges: [] }, + require_approval: true, + summary: { trigger: 'manual', steps: [] }, + }), + tool_call_id: 'call-future-1', + }); + }); + + const proposal = store.getState().chatRuntime.pendingWorkflowProposalsByThread[threadId]; + expect(proposal).toMatchObject({ name: 'Future proposal' }); + }); }); // Regression: on Windows users report being "locked out" of the composer diff --git a/app/src/store/chatRuntimeSlice.test.ts b/app/src/store/chatRuntimeSlice.test.ts index b41ff78290..05c97de39a 100644 --- a/app/src/store/chatRuntimeSlice.test.ts +++ b/app/src/store/chatRuntimeSlice.test.ts @@ -18,6 +18,7 @@ import chatRuntimeReducer, { setPendingApprovalForThread, setQueueStatusForThread, setToolTimelineForThread, + setWorkflowProposalForThread, } from './chatRuntimeSlice'; function makeRun(id: string, status: AgentRunStatus): AgentRun { @@ -689,6 +690,75 @@ describe('hydrateRuntimeFromSnapshot — live-driver guard', () => { }); }); +// Regression: `fetchAndHydrateTurnState` (via `hydrateRuntimeFromSnapshot`) +// fires on thread rehydration (e.g. the always-open Flows copilot re-opening +// a persisted thread, #4874). A workflow proposal is a client-only flag with +// no server-side record, so a rehydrate must not resurrect a *stale* one from +// a crashed prior session — but it must also not wipe a proposal the +// streaming/blocking path set THIS session, moments before a `completed` +// snapshot for the same settled turn lands. Only `interrupted` (genuine +// crashed-mid-flight cleanup) should clear it. +describe('hydrateRuntimeFromSnapshot — workflow proposal race guard', () => { + function makeProposal(name: string) { + return { + name, + graph: { nodes: [], edges: [] }, + requireApproval: true, + summary: { trigger: 'manual', steps: [] }, + }; + } + + function makeSnapshot( + threadId: string, + lifecycle: PersistedTurnState['lifecycle'] + ): PersistedTurnState { + return { + threadId, + requestId: 'req-1', + lifecycle, + iteration: 3, + maxIterations: 10, + streamingText: '', + thinking: '', + toolTimeline: [], + startedAt: '2026-06-23T00:00:00Z', + updatedAt: '2026-06-23T00:00:00Z', + }; + } + + it('clears a pending proposal on an interrupted (crashed prior-session) snapshot', () => { + const store = makeStore(); + store.dispatch( + setWorkflowProposalForThread({ threadId: 't-crashed', proposal: makeProposal('Stale') }) + ); + + store.dispatch( + hydrateRuntimeFromSnapshot({ snapshot: makeSnapshot('t-crashed', 'interrupted') }) + ); + + expect( + store.getState().chatRuntime.pendingWorkflowProposalsByThread['t-crashed'] + ).toBeUndefined(); + }); + + it('preserves a pending proposal on a completed snapshot from this session', () => { + const store = makeStore(); + // The streaming/blocking path just set this moments before the + // rehydration thunk's `completed` snapshot lands for the same turn. + store.dispatch( + setWorkflowProposalForThread({ threadId: 't-settled', proposal: makeProposal('Fresh') }) + ); + + store.dispatch( + hydrateRuntimeFromSnapshot({ snapshot: makeSnapshot('t-settled', 'completed') }) + ); + + expect(store.getState().chatRuntime.pendingWorkflowProposalsByThread['t-settled']).toEqual( + makeProposal('Fresh') + ); + }); +}); + describe('hydrateRuntimeFromSnapshot — persisted tool result output', () => { it('maps the persisted output onto parent and sub-agent rows as result', () => { const store = makeStore(); diff --git a/app/src/store/chatRuntimeSlice.ts b/app/src/store/chatRuntimeSlice.ts index 549daf4517..5ca4e06890 100644 --- a/app/src/store/chatRuntimeSlice.ts +++ b/app/src/store/chatRuntimeSlice.ts @@ -1969,8 +1969,15 @@ const chatRuntimeSlice = createSlice({ delete state.pendingPlanReviewByThread[threadId]; // Same for a workflow proposal (B4) — it's a client-only "should the // card render" flag with no server-side record, so a rehydrate must - // not resurrect one left over from a previous session. - delete state.pendingWorkflowProposalsByThread[threadId]; + // not resurrect one left over from a previous session. But only clear + // it on a genuinely stale snapshot (`interrupted` = crashed mid-flight + // in a prior process): a `completed` snapshot can be this session's own + // just-settled turn, racing against the streaming/blocking path that + // set the proposal moments ago — clearing unconditionally here would + // wipe a proposal that's still pending the user's Accept/Reject. + if (snapshot.lifecycle === 'interrupted') { + delete state.pendingWorkflowProposalsByThread[threadId]; + } if (snapshot.taskBoard) { state.taskBoardByThread[threadId] = snapshot.taskBoard; } From fe2a30445a7d5408dbf1a2765e6234d4e5861802 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:54:51 +0530 Subject: [PATCH 06/18] fix(flows): live-updating runs lists + row-action cleanup (enable toggle, delete in overflow) (#4890) --- app/src/components/flows/FlowListRow.test.tsx | 17 +- app/src/components/flows/FlowListRow.tsx | 97 ++++-------- .../components/flows/FlowRunsDrawer.test.tsx | 78 +++++++++- app/src/components/flows/FlowRunsDrawer.tsx | 44 +++++- app/src/components/flows/FlowRunsSidebar.tsx | 8 +- .../__tests__/useFlowRunsLiveRefresh.test.ts | 145 ++++++++++++++++++ app/src/hooks/useFlowRunsLiveRefresh.ts | 100 ++++++++++++ app/src/pages/FlowsPage.test.tsx | 6 +- app/src/pages/WorkflowRunsPage.test.tsx | 67 +++++++- app/src/pages/WorkflowRunsPage.tsx | 24 ++- 10 files changed, 497 insertions(+), 89 deletions(-) create mode 100644 app/src/hooks/__tests__/useFlowRunsLiveRefresh.test.ts create mode 100644 app/src/hooks/useFlowRunsLiveRefresh.ts diff --git a/app/src/components/flows/FlowListRow.test.tsx b/app/src/components/flows/FlowListRow.test.tsx index 05e7c24105..6405143cff 100644 --- a/app/src/components/flows/FlowListRow.test.tsx +++ b/app/src/components/flows/FlowListRow.test.tsx @@ -50,13 +50,13 @@ describe('FlowListRow', () => { it('renders the flow name and reflects enabled state on the toggle', () => { renderRow(); expect(screen.getByText('Daily digest')).toBeInTheDocument(); - // The toggle is an icon button; state is conveyed via aria-pressed, not text. - expect(screen.getByTestId('flow-toggle-flow-1')).toHaveAttribute('aria-pressed', 'true'); + // The toggle is a SettingsSwitch (role=switch); state is conveyed via aria-checked. + expect(screen.getByTestId('flow-toggle-flow-1')).toHaveAttribute('aria-checked', 'true'); }); it('reflects paused state on the toggle when disabled', () => { renderRow({ flow: makeFlow({ enabled: false }) }); - expect(screen.getByTestId('flow-toggle-flow-1')).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByTestId('flow-toggle-flow-1')).toHaveAttribute('aria-checked', 'false'); }); it('shows "Never run" when the flow has no last_run_at', () => { @@ -147,9 +147,16 @@ describe('FlowListRow', () => { expect(onDuplicate).toHaveBeenCalledWith(makeFlow()); }); - it('deletes via the direct Delete icon (not the menu)', () => { + it('routes Delete through the overflow menu', () => { const { onDelete } = renderRow(); - fireEvent.click(screen.getByTestId('flow-delete-flow-1')); + // Delete is a destructive secondary action now — behind the "⋯" menu, not + // a standalone icon button in the flat row. + expect(screen.queryByTestId('flow-delete-flow-1')).not.toBeInTheDocument(); + fireEvent.click(screen.getByTestId('flow-menu-flow-1')); + + const deleteItem = screen.getByTestId('flow-delete-flow-1'); + expect(deleteItem).toHaveTextContent('Delete'); + fireEvent.click(deleteItem); expect(onDelete).toHaveBeenCalledWith(makeFlow()); }); }); diff --git a/app/src/components/flows/FlowListRow.tsx b/app/src/components/flows/FlowListRow.tsx index ea063788a3..b1c6e5dcc5 100644 --- a/app/src/components/flows/FlowListRow.tsx +++ b/app/src/components/flows/FlowListRow.tsx @@ -3,14 +3,17 @@ * * Mirrors the row layout of `CoreJobList` * (`app/src/components/settings/panels/cron/CoreJobList.tsx`): name + status - * badge header, a line of run metadata, then a row of `Button` actions. Swaps - * the cron "pause/resume" text button for a `SettingsSwitch` toggle (the - * canonical boolean control — see `components/settings/controls`) since - * enable/disable here is a persistent setting, not a one-off action. + * badge header, a line of run metadata, then a row of `Button` actions. Uses + * the canonical `SettingsSwitch` boolean control (`components/settings/controls`) + * for enable/disable, since that's a persistent setting, not a one-off action — + * not an icon `Button`, so its state reads at a glance instead of needing a + * hover/title to disambiguate on vs. off. * * "View runs" (issue B5a.1) opens `FlowRunsDrawer` (mounted by `FlowsPage`) * for this flow's run history — re-added now that B3b's run inspector has - * landed and the drawer has somewhere to send the user. + * landed and the drawer has somewhere to send the user. Delete lives in the + * same overflow menu (destructive actions shouldn't sit in the flat button + * row next to Run/toggle, where a mis-click is one tap away). * * The flow name (issue B5b.1) is itself the "View" affordance for the new * read-only Workflow Canvas: it's rendered as a button that calls `onView`, @@ -22,6 +25,7 @@ */ import { useT } from '../../lib/i18n/I18nContext'; import type { Flow } from '../../services/api/flowsApi'; +import SettingsSwitch from '../settings/controls/SettingsSwitch'; import Button from '../ui/Button'; import FlowRowMenu from './FlowRowMenu'; @@ -33,41 +37,6 @@ function PlayIcon() { ); } -function PowerIcon() { - // On/off — enabled vs. paused (distinct from Run's play triangle). - return ( - - ); -} - -function TrashIcon() { - return ( - - ); -} - /** Which of this row's actions currently has a request in flight, if any. */ export type FlowListRowBusy = 'toggle' | 'run' | null; @@ -153,27 +122,18 @@ const FlowListRow = ({ {/* All controls sit together on the right: the toggle (enabled/paused — - the switch alone communicates state), then Run, Delete, and an overflow - menu with the secondary actions (view runs / export / duplicate). */} + the switch alone communicates state), then Run, and an overflow menu + with the secondary/destructive actions (view runs / export / + duplicate / delete). */}
- + aria-label={t('flows.list.toggleEnabled')} + data-testid={`flow-toggle-${flow.id}`} + /> - onDuplicate(flow), testId: `flow-duplicate-${flow.id}`, }, + { + key: 'delete', + label: t('flows.list.delete'), + onSelect: () => onDelete(flow), + danger: true, + testId: `flow-delete-${flow.id}`, + }, ]} />
diff --git a/app/src/components/flows/FlowRunsDrawer.test.tsx b/app/src/components/flows/FlowRunsDrawer.test.tsx index ad61f6ee90..43cf8096a4 100644 --- a/app/src/components/flows/FlowRunsDrawer.test.tsx +++ b/app/src/components/flows/FlowRunsDrawer.test.tsx @@ -12,7 +12,7 @@ * `FlowRunInspectorDrawer.test.tsx`) so this suite only exercises the * run-history list + the nesting contract between the two drawers. */ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { Provider } from 'react-redux'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -207,4 +207,80 @@ describe('FlowRunsDrawer', () => { fireEvent.keyDown(document, { key: 'Escape' }); expect(onClose).not.toHaveBeenCalled(); }); + + it('discards a stale background refetch after the drawer flips to a different flow (race guard)', async () => { + // Regression test for a codex review finding on this PR: the live-refresh + // `refetch` in FlowRunsDrawer didn't guard against a response landing + // after the drawer had already switched to a different flowId, so a slow + // flow-A refetch could clobber flow-B's already-rendered runs. + vi.useFakeTimers(); + try { + let flowACalls = 0; + let resolveStaleA: ((runs: FlowRun[]) => void) | undefined; + listFlowRuns.mockImplementation((flowId: string) => { + if (flowId === 'flow-a') { + flowACalls += 1; + if (flowACalls === 1) { + // Initial load: one active run so the live-refresh poll subscribes. + return Promise.resolve([ + makeRun({ id: 'run-a', flow_id: 'flow-a', status: 'running' }), + ]); + } + // The poll-triggered refetch — stays pending until resolved below, + // simulating a slow response that outlives the flow switch. + return new Promise(resolve => { + resolveStaleA = resolve; + }); + } + if (flowId === 'flow-b') { + return Promise.resolve([ + makeRun({ id: 'run-b', flow_id: 'flow-b', status: 'completed' }), + ]); + } + return Promise.resolve([]); + }); + + const { rerender } = render( + + + + ); + + // Flush the initial load's already-resolved promise. + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByTestId('flow-run-row-run-a')).toBeInTheDocument(); + + // Trigger the live-refresh poll fallback — issues the second, hanging + // listFlowRuns('flow-a') call. + await act(async () => { + vi.advanceTimersByTime(5_000); + }); + expect(flowACalls).toBe(2); + + // Flip the drawer to a different flow while that refetch is still in flight. + rerender( + + + + ); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByTestId('flow-run-row-run-b')).toBeInTheDocument(); + + // Now let the stale flow-a response land. + await act(async () => { + resolveStaleA?.([makeRun({ id: 'run-a-late', flow_id: 'flow-a', status: 'completed' })]); + await Promise.resolve(); + }); + + // Flow-b's runs must be unaffected by the late flow-a response. + expect(screen.getByTestId('flow-run-row-run-b')).toBeInTheDocument(); + expect(screen.queryByTestId('flow-run-row-run-a-late')).not.toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/app/src/components/flows/FlowRunsDrawer.tsx b/app/src/components/flows/FlowRunsDrawer.tsx index d2d55bd8ac..8f44848162 100644 --- a/app/src/components/flows/FlowRunsDrawer.tsx +++ b/app/src/components/flows/FlowRunsDrawer.tsx @@ -8,10 +8,11 @@ * to-close + Escape-to-close via `useEscapeKey`) so it renders as a fixed * overlay regardless of where the parent mounts it. * - * Data is a one-shot fetch via `listFlowRuns` — no polling here. The run - * inspector already polls a single run's live status via `useFlowRunPoller`; - * polling the whole list here would duplicate that logic for no benefit - * (the list only needs to be fresh when the drawer opens). + * Data loads via `listFlowRuns` on open, then stays live via + * {@link useFlowRunsLiveRefresh} while any run in the list is still active — + * so a run stuck on "Running" here updates without the user having to close + * and reopen the drawer. The run inspector separately polls a single run's + * live status via `useFlowRunPoller`; that's unrelated to this list refresh. * * Clicking a run sets `selectedRunId` and renders the existing * `FlowRunInspectorDrawer` stacked on top: both are `fixed inset-0 z-50` @@ -24,9 +25,10 @@ * single Escape press closes only the topmost overlay (the inspector) first. */ import debug from 'debug'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useEscapeKey } from '../../hooks/useEscapeKey'; +import { useFlowRunsLiveRefresh } from '../../hooks/useFlowRunsLiveRefresh'; import { useT } from '../../lib/i18n/I18nContext'; import { type FlowRun, listFlowRuns } from '../../services/api/flowsApi'; import { @@ -72,8 +74,15 @@ export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Pr const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [selectedRunId, setSelectedRunId] = useState(null); + // Tracks the flowId this drawer instance is *currently* showing, so an + // in-flight `refetch()` started for a previous flow (see below) can detect + // it's stale once the drawer flips to a new flowId and bail instead of + // clobbering the new flow's already-loaded runs. + const currentFlowIdRef = useRef(flowId); useEffect(() => { + currentFlowIdRef.current = flowId; + // Reset for the new target so a previous flow's runs/error can't linger // under a different flowId while the new fetch is in flight. setSelectedRunId(null); @@ -109,6 +118,31 @@ export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Pr }; }, [flowId]); + // Background refresh for the live-update hook below — deliberately doesn't + // touch `loading`/`error` so a poll tick or progress event never flashes + // the loading state or clobbers a real load error with a transient one. + // Guards against a stale response: if the drawer flips from flow A to flow + // B while an A refetch is still in flight, the late A response must not + // overwrite B's already-loaded runs (mirrors the `cancelled` guard on the + // main load effect above). + const refetch = useCallback(() => { + if (!flowId) return; + const requestFlowId = flowId; + listFlowRuns(requestFlowId) + .then(result => { + if (currentFlowIdRef.current !== requestFlowId) return; + setRuns(result); + log('refetched runs: flowId=%s count=%d', requestFlowId, result.length); + }) + .catch(err => { + if (currentFlowIdRef.current !== requestFlowId) return; + const msg = err instanceof Error ? err.message : String(err); + log('refetch failed: flowId=%s err=%s', requestFlowId, msg); + }); + }, [flowId]); + + useFlowRunsLiveRefresh(runs, refetch); + useEscapeKey( () => { log('escape: closing flowId=%s', flowId); diff --git a/app/src/components/flows/FlowRunsSidebar.tsx b/app/src/components/flows/FlowRunsSidebar.tsx index 42edb95c96..5decc46c1c 100644 --- a/app/src/components/flows/FlowRunsSidebar.tsx +++ b/app/src/components/flows/FlowRunsSidebar.tsx @@ -3,8 +3,9 @@ * dynamic left sidebar while a flow is open on the canvas (`/flows/:id`). A * compact, scannable run history (status dot + status + relative time); clicking * a run opens the full {@link FlowRunInspectorDrawer} (which polls its live - * status). One-shot fetch via `listFlowRuns` with a manual refresh — the engine - * emits no list-level socket events, so this mirrors `FlowRunsDrawer`'s model. + * status). Fetches via `listFlowRuns`, with a manual refresh button plus + * {@link useFlowRunsLiveRefresh} keeping the list itself live while any run + * shown here is still active (no manual refresh/navigate-away required). * * Rendered by `FlowCanvasPage` inside a `SidebarContent` portal, so it only * appears for a persisted flow (a draft has no runs yet). @@ -13,6 +14,7 @@ import createDebug from 'debug'; import { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { useFlowRunsLiveRefresh } from '../../hooks/useFlowRunsLiveRefresh'; import { useT } from '../../lib/i18n/I18nContext'; import { type FlowRun, listFlowRuns } from '../../services/api/flowsApi'; import { CenteredLoadingState, ErrorBanner } from '../ui/LoadingState'; @@ -98,6 +100,8 @@ export default function FlowRunsSidebar({ flowId }: FlowRunsSidebarProps) { void load(); }, [load]); + useFlowRunsLiveRefresh(runs, load); + return (
diff --git a/app/src/hooks/__tests__/useFlowRunsLiveRefresh.test.ts b/app/src/hooks/__tests__/useFlowRunsLiveRefresh.test.ts new file mode 100644 index 0000000000..2ba90066a3 --- /dev/null +++ b/app/src/hooks/__tests__/useFlowRunsLiveRefresh.test.ts @@ -0,0 +1,145 @@ +/** + * useFlowRunsLiveRefresh — unit tests. + * + * Verifies: no subscription/poll when every run is terminal, subscribe + + * poll when a run is active, a trailing-debounced refetch on a matching + * `flow:run_progress`/`flow_run_progress` event, teardown once the runs + * settle to all-terminal, and cleanup on unmount. + */ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { FlowRun } from '../../services/api/flowsApi'; +import { useFlowRunsLiveRefresh } from '../useFlowRunsLiveRefresh'; + +const handlers = vi.hoisted(() => new Map void>>()); +const on = vi.hoisted(() => + vi.fn((event: string, cb: (data: unknown) => void) => { + const set = handlers.get(event) ?? new Set(); + set.add(cb); + handlers.set(event, set); + }) +); +const off = vi.hoisted(() => + vi.fn((event: string, cb: (data: unknown) => void) => { + handlers.get(event)?.delete(cb); + }) +); +vi.mock('../../services/socketService', () => ({ socketService: { on, off } })); + +function emit(event: 'flow:run_progress' | 'flow_run_progress', payload: unknown) { + act(() => { + for (const cb of handlers.get(event) ?? []) cb(payload); + }); +} + +function makeRun(overrides: Partial = {}): FlowRun { + return { + id: 'run-1', + flow_id: 'flow-1', + thread_id: 'run-1', + status: 'running', + started_at: '2026-01-01T00:00:00Z', + steps: [], + pending_approvals: [], + ...overrides, + }; +} + +describe('useFlowRunsLiveRefresh', () => { + beforeEach(() => { + handlers.clear(); + on.mockClear(); + off.mockClear(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not subscribe or poll when every run is terminal', () => { + const refetch = vi.fn(); + renderHook(() => + useFlowRunsLiveRefresh( + [makeRun({ status: 'completed' }), makeRun({ id: 'run-2', status: 'failed' })], + refetch + ) + ); + + expect(on).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(20_000); + expect(refetch).not.toHaveBeenCalled(); + }); + + it('subscribes to both event aliases and polls on a fallback interval when a run is active', () => { + const refetch = vi.fn(); + renderHook(() => useFlowRunsLiveRefresh([makeRun({ status: 'running' })], refetch)); + + expect(on).toHaveBeenCalledWith('flow:run_progress', expect.any(Function)); + expect(on).toHaveBeenCalledWith('flow_run_progress', expect.any(Function)); + + vi.advanceTimersByTime(5_000); + expect(refetch).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(5_000); + expect(refetch).toHaveBeenCalledTimes(2); + }); + + it('debounces a burst of flow:run_progress events into a single trailing refetch', () => { + const refetch = vi.fn(); + renderHook(() => useFlowRunsLiveRefresh([makeRun({ status: 'running' })], refetch)); + + // Keep every emit + the final debounce settle comfortably inside the + // first 5s poll tick so the poll fallback doesn't also fire here — that + // interplay is covered separately by the "poll fallback" test above. + emit('flow:run_progress', { run_id: 'run-1', node_id: 'a', status: 'success' }); + vi.advanceTimersByTime(500); + emit('flow:run_progress', { run_id: 'run-1', node_id: 'b', status: 'success' }); + vi.advanceTimersByTime(500); + emit('flow_run_progress', { run_id: 'run-1', node_id: 'c', status: 'success' }); + + // Still within the 3s trailing window from the last event — no refetch yet. + expect(refetch).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(3_000); + expect(refetch).toHaveBeenCalledTimes(1); + }); + + it('tears down the subscription and poll once the runs settle to all-terminal', () => { + const refetch = vi.fn(); + const { rerender } = renderHook(({ runs }) => useFlowRunsLiveRefresh(runs, refetch), { + initialProps: { runs: [makeRun({ status: 'running' })] }, + }); + + expect(on).toHaveBeenCalledTimes(2); + + rerender({ runs: [makeRun({ status: 'completed' })] }); + + expect(off).toHaveBeenCalledWith('flow:run_progress', expect.any(Function)); + expect(off).toHaveBeenCalledWith('flow_run_progress', expect.any(Function)); + + refetch.mockClear(); + vi.advanceTimersByTime(20_000); + expect(refetch).not.toHaveBeenCalled(); + }); + + it('cleans up the subscription, poll, and any pending debounce on unmount', () => { + const refetch = vi.fn(); + const { unmount } = renderHook(() => + useFlowRunsLiveRefresh([makeRun({ status: 'running' })], refetch) + ); + + emit('flow:run_progress', { run_id: 'run-1', node_id: 'a', status: 'success' }); + + unmount(); + + expect(off).toHaveBeenCalledWith('flow:run_progress', expect.any(Function)); + expect(off).toHaveBeenCalledWith('flow_run_progress', expect.any(Function)); + + refetch.mockClear(); + vi.advanceTimersByTime(20_000); + expect(refetch).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/hooks/useFlowRunsLiveRefresh.ts b/app/src/hooks/useFlowRunsLiveRefresh.ts new file mode 100644 index 0000000000..ee96d16687 --- /dev/null +++ b/app/src/hooks/useFlowRunsLiveRefresh.ts @@ -0,0 +1,100 @@ +/** + * useFlowRunsLiveRefresh — keeps a runs LIST fresh while any run in it is + * still active, so "Running" doesn't go stale until the user manually + * refreshes or navigates away. + * + * The backend's `FlowRunObserver` publishes `DomainEvent::FlowRunProgress` on + * each finished step, and the core socket bridge re-emits it to the frontend + * as both `flow:run_progress` and `flow_run_progress` (colon + underscore + * aliases — see `useFlowRunProgress`, whose subscribe/teardown style this + * mirrors). There is, however, no terminal/completion socket event today — + * a run transitioning `running` -> `completed`/`failed`/etc. emits nothing — + * so a step-progress subscription alone can't catch that final refresh. This + * hook therefore layers a 5s poll fallback on top of the socket subscription: + * the socket keeps the refetch snappy while steps are landing, and the poll + * guarantees the terminal transition (which has no event) still gets picked + * up within a few seconds. + * + * This is deliberately dumb about *which* run changed — callers pass the + * list they already fetched and a `refetch` that reloads it; this hook just + * decides *when* to call `refetch` again. `FlowRunsSidebar`, `FlowRunsDrawer`, + * and `WorkflowRunsPage` all wire it onto their existing one-shot fetchers. + */ +import debug from 'debug'; +import { useEffect, useRef } from 'react'; + +import type { FlowRun, FlowRunStatus } from '../services/api/flowsApi'; +import { socketService } from '../services/socketService'; + +const log = debug('flows:runs-live-refresh'); + +/** Socket event aliases the core bridge emits (colon + underscore forms). */ +const EVENT_COLON = 'flow:run_progress'; +const EVENT_UNDERSCORE = 'flow_run_progress'; + +/** Statuses a run never leaves once reached — no further refetch needed for it. */ +const TERMINAL_STATUSES = new Set([ + 'completed', + 'completed_with_warnings', + 'failed', + 'cancelled', +]); + +/** Trailing debounce window for a burst of `flow:run_progress` events. */ +const DEBOUNCE_MS = 3_000; + +/** Poll fallback cadence — catches the terminal transition, which has no socket event. */ +const POLL_INTERVAL_MS = 5_000; + +/** + * Subscribes to live run-progress events (debounced) and polls on a fallback + * interval while `runs` contains at least one non-terminal run, calling + * `refetch` to reload the list. Subscribes to nothing — and tears down any + * existing subscription/poll — once every run has settled or on unmount. + */ +export function useFlowRunsLiveRefresh(runs: FlowRun[], refetch: () => void): void { + // Keep the latest `refetch` available to the effect without retriggering + // subscribe/unsubscribe every time the caller passes a new closure. + const refetchRef = useRef(refetch); + refetchRef.current = refetch; + + const hasActive = runs.some(run => !TERMINAL_STATUSES.has(run.status)); + + useEffect(() => { + if (!hasActive) return; + + let debounceTimer: ReturnType | null = null; + const scheduleRefetch = () => { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + log('debounced refetch firing'); + refetchRef.current(); + }, DEBOUNCE_MS); + }; + + const handleProgress = () => { + log('progress event received — scheduling debounced refetch'); + scheduleRefetch(); + }; + + log('subscribing: at least one active run'); + socketService.on(EVENT_COLON, handleProgress); + socketService.on(EVENT_UNDERSCORE, handleProgress); + + const pollId = setInterval(() => { + log('poll fallback refetch'); + refetchRef.current(); + }, POLL_INTERVAL_MS); + + return () => { + log('tearing down: unsubscribing + clearing poll/debounce timers'); + socketService.off(EVENT_COLON, handleProgress); + socketService.off(EVENT_UNDERSCORE, handleProgress); + clearInterval(pollId); + if (debounceTimer) clearTimeout(debounceTimer); + }; + }, [hasActive]); +} + +export default useFlowRunsLiveRefresh; diff --git a/app/src/pages/FlowsPage.test.tsx b/app/src/pages/FlowsPage.test.tsx index 4b0fb94f73..0e10805680 100644 --- a/app/src/pages/FlowsPage.test.tsx +++ b/app/src/pages/FlowsPage.test.tsx @@ -131,9 +131,9 @@ describe('FlowsPage', () => { fireEvent.click(screen.getByTestId('flow-toggle-flow-1')); expect(setFlowEnabled).toHaveBeenCalledWith('flow-1', false); - // The toggle is an icon button now; state is conveyed via aria-pressed. + // The toggle is a SettingsSwitch (role=switch) now; state is conveyed via aria-checked. await waitFor(() => - expect(screen.getByTestId('flow-toggle-flow-1')).toHaveAttribute('aria-pressed', 'false') + expect(screen.getByTestId('flow-toggle-flow-1')).toHaveAttribute('aria-checked', 'false') ); }); @@ -275,6 +275,8 @@ describe('FlowsPage', () => { deleteFlow.mockResolvedValue('flow-1'); renderWithProviders(, { initialEntries: ['/?view=main'] }); + // Delete now lives behind the row's "⋯" overflow menu, alongside + // Export/Duplicate, rather than a standalone icon button. fireEvent.click(await screen.findByTestId('flow-menu-flow-1')); fireEvent.click(await screen.findByTestId('flow-delete-flow-1')); diff --git a/app/src/pages/WorkflowRunsPage.test.tsx b/app/src/pages/WorkflowRunsPage.test.tsx index 6867115507..d03d4e66bf 100644 --- a/app/src/pages/WorkflowRunsPage.test.tsx +++ b/app/src/pages/WorkflowRunsPage.test.tsx @@ -1,5 +1,5 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import WorkflowRunsPage from './WorkflowRunsPage'; @@ -75,4 +75,67 @@ describe('WorkflowRunsPage', () => { await waitFor(() => expect(screen.getByText('rpc down')).toBeInTheDocument()); }); + + describe('live refresh (useFlowRunsLiveRefresh integration)', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('re-fetches just the runs (not listFlows) on the live-refresh poll while a run is active', async () => { + vi.useFakeTimers(); + listAllFlowRuns + .mockResolvedValueOnce([ + { id: 'r1', flow_id: 'f1', status: 'running', started_at: '2026-01-01T00:00:00Z' }, + ]) + .mockResolvedValueOnce([ + { id: 'r1', flow_id: 'f1', status: 'completed', started_at: '2026-01-01T00:00:00Z' }, + ]); + listFlows.mockResolvedValue([{ id: 'f1', name: 'Daily digest' }]); + + render(); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByTestId('workflow-runs-list')).toBeInTheDocument(); + expect(listAllFlowRuns).toHaveBeenCalledTimes(1); + expect(listFlows).toHaveBeenCalledTimes(1); + + // The 5s poll fallback fires `refetchRuns` while the one run is still 'running'. + await act(async () => { + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + }); + + // The poll fallback re-fetched just the runs — `listFlows` is not called again. + expect(listAllFlowRuns).toHaveBeenCalledTimes(2); + expect(listFlows).toHaveBeenCalledTimes(1); + }); + + it('does not surface an error banner when a background refetch fails', async () => { + vi.useFakeTimers(); + listAllFlowRuns + .mockResolvedValueOnce([ + { id: 'r1', flow_id: 'f1', status: 'running', started_at: '2026-01-01T00:00:00Z' }, + ]) + .mockRejectedValueOnce(new Error('transient rpc blip')); + listFlows.mockResolvedValue([{ id: 'f1', name: 'Daily digest' }]); + + render(); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByTestId('workflow-runs-list')).toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + }); + + expect(listAllFlowRuns).toHaveBeenCalledTimes(2); + // The transient background failure is logged only — the list stays as-is, + // no error banner from the (unrelated) `load` error state. + expect(screen.queryByText('transient rpc blip')).not.toBeInTheDocument(); + expect(screen.getByTestId('workflow-runs-list')).toBeInTheDocument(); + }); + }); }); diff --git a/app/src/pages/WorkflowRunsPage.tsx b/app/src/pages/WorkflowRunsPage.tsx index 84b934e5c8..874dd0de37 100644 --- a/app/src/pages/WorkflowRunsPage.tsx +++ b/app/src/pages/WorkflowRunsPage.tsx @@ -1,13 +1,18 @@ /** * WorkflowRunsPage — the aggregate "All runs" view: every workflow's runs across * the whole `flows` domain, newest first, backed by the `flows_list_all_runs` - * core RPC. Each row links back to its workflow's canvas. + * core RPC. Each row links back to its workflow's canvas. Stays live via + * {@link useFlowRunsLiveRefresh} while any listed run is still active, via a + * lightweight `refetchRuns` (re-fetches just the runs, not `listFlows()` too) + * so a run doesn't sit on "Running" until the user reloads the page. */ +import debug from 'debug'; import { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import PanelPage from '../components/layout/PanelPage'; import { CenteredLoadingState, ErrorBanner } from '../components/ui/LoadingState'; +import { useFlowRunsLiveRefresh } from '../hooks/useFlowRunsLiveRefresh'; import { useT } from '../lib/i18n/I18nContext'; import { type Flow, @@ -17,6 +22,8 @@ import { listFlows, } from '../services/api/flowsApi'; +const log = debug('app:flows:runs-page'); + const STATUS_CLASS: Record = { running: 'bg-primary-500/15 text-primary-600 dark:text-primary-300', completed: 'bg-sage-500/15 text-sage-700 dark:text-sage-300', @@ -56,6 +63,21 @@ export default function WorkflowRunsPage() { void load(); }, [load]); + // Lighter-weight than `load` — only re-fetches the runs (not `listFlows()` + // too), since flow names rarely change mid-run and re-fetching them on + // every live-refresh tick would be wasted work. + const refetchRuns = useCallback(() => { + listAllFlowRuns() + .then(setRuns) + .catch(err => { + // Best-effort background refresh — a transient failure here shouldn't + // clobber the page's existing error/loading state from `load`. + log('refetchRuns failed: %o', err); + }); + }, []); + + useFlowRunsLiveRefresh(runs, refetchRuns); + const statusLabel = (status: FlowRunStatus) => t(`flows.allRuns.status.${status}`, status.replace(/_/g, ' ')); From 390c1ca6c166ef2960acaa71e86e02663811293b Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:03:29 +0530 Subject: [PATCH 07/18] =?UTF-8?q?fix(flows):=20exempt=20workflow=20proposa?= =?UTF-8?q?l=20tools=20from=20tokenjuice=20compaction=20(canvas=20renders?= =?UTF-8?q?=20=E2=89=A54-node=20graphs)=20(#4888)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/openhuman/flows/ops_tests.rs | 95 +++++ src/openhuman/tinyagents/middleware.rs | 519 ++++++++++++++++++++++--- 2 files changed, 562 insertions(+), 52 deletions(-) diff --git a/src/openhuman/flows/ops_tests.rs b/src/openhuman/flows/ops_tests.rs index a8a789d02a..b4c1663c1b 100644 --- a/src/openhuman/flows/ops_tests.rs +++ b/src/openhuman/flows/ops_tests.rs @@ -4472,6 +4472,101 @@ async fn compute_required_connections_skips_native_and_http_nodes() { ); } +// ── extract_workflow_proposal: survives large, tabulation-eligible graphs ───── +// +// Regression coverage for the "blank canvas on ≥4-node graphs" bug: tinyjuice's +// JSON compressor tabulates any uniform object-array of >= 3 rows over ~512 +// bytes, which strips the `"type": "workflow_proposal"` marker this extractor +// keys on. The fix lives in `tinyagents::middleware::ToolOutputMiddleware` +// (COMPACTION_EXEMPT_TOOLS), which keeps proposal-tool results out of +// tokenjuice entirely — so by the time a payload reaches `agent.history()` +// here, it must still be the untabulated, structurally-intact JSON. + +#[test] +fn extract_workflow_proposal_survives_large_graph() { + use crate::openhuman::inference::provider::{ConversationMessage, ToolResultMessage}; + + // 6 nodes, several columns each — comfortably over tinyjuice's MIN_ROWS (3) + // and ~512-byte tabulation thresholds, so an unprotected payload would get + // compacted into a `[json table: …]` marker and lose the `"type"` field. + let nodes: Vec = (0..6) + .map(|i| { + json!({ + "id": format!("node-{i}"), + "kind": if i == 0 { "trigger" } else { "tool_call" }, + "name": format!("Step {i}"), + "config": { + "slug": format!("oh:placeholder_action_{i}"), + "args": { "input": format!("value-{i}"), "note": "generic placeholder payload for size padding" } + } + }) + }) + .collect(); + let edges: Vec = (0..5) + .map(|i| json!({ "from_node": format!("node-{i}"), "to_node": format!("node-{}", i + 1) })) + .collect(); + let proposal_payload = json!({ + "type": "workflow_proposal", + "flow_id": "flow-large-graph", + "graph": { "nodes": nodes, "edges": edges }, + }); + let payload_str = serde_json::to_string(&proposal_payload).unwrap(); + assert!( + payload_str.len() > 512, + "test payload must exceed tinyjuice's tabulation byte threshold: {} bytes", + payload_str.len() + ); + + let history = vec![ConversationMessage::ToolResults(vec![ToolResultMessage { + tool_call_id: "call-1".to_string(), + content: payload_str, + }])]; + + let proposal = extract_workflow_proposal(&history).expect("proposal should be extractable"); + assert_eq!( + proposal.get("type").and_then(serde_json::Value::as_str), + Some("workflow_proposal") + ); + assert_eq!( + proposal["graph"]["nodes"].as_array().unwrap().len(), + 6, + "all 6 nodes must survive intact: {proposal}" + ); +} + +#[test] +fn extract_workflow_proposal_returns_the_latest_of_multiple_results() { + use crate::openhuman::inference::provider::{ConversationMessage, ToolResultMessage}; + + let first = json!({ "type": "workflow_proposal", "flow_id": "first" }); + let second = json!({ "type": "workflow_proposal", "flow_id": "second" }); + let history = vec![ + ConversationMessage::ToolResults(vec![ToolResultMessage { + tool_call_id: "call-1".to_string(), + content: first.to_string(), + }]), + ConversationMessage::ToolResults(vec![ToolResultMessage { + tool_call_id: "call-2".to_string(), + content: second.to_string(), + }]), + ]; + + let proposal = extract_workflow_proposal(&history).expect("proposal should be extractable"); + assert_eq!(proposal["flow_id"], "second"); +} + +#[test] +fn extract_workflow_proposal_ignores_non_proposal_tool_results() { + use crate::openhuman::inference::provider::{ConversationMessage, ToolResultMessage}; + + let history = vec![ConversationMessage::ToolResults(vec![ToolResultMessage { + tool_call_id: "call-1".to_string(), + content: json!({ "type": "search_results", "items": [] }).to_string(), + }])]; + + assert!(extract_workflow_proposal(&history).is_none()); +} + // ───────────────────────────────────────────────────────────────────────────── // Builder convergence fix — trail-off backstop (`flows_build`'s terminal-state // guarantee: every turn ends in a proposal or a real question, never silence). diff --git a/src/openhuman/tinyagents/middleware.rs b/src/openhuman/tinyagents/middleware.rs index 559c029734..dee3415cc8 100644 --- a/src/openhuman/tinyagents/middleware.rs +++ b/src/openhuman/tinyagents/middleware.rs @@ -718,6 +718,65 @@ impl Middleware<()> for PromptCacheSegmentMiddleware { } } +/// Tools whose results are self-describing JSON payloads that downstream +/// extractors and the frontend canvas parse structurally (the `type` marker +/// must survive). Compacting/summarizing them destroys the contract and +/// serves no purpose — the model doesn't benefit from a tabulated graph and +/// the payload is the turn's final output, not intermediate context. +/// +/// These tools are exempt from *every* content-rewriting stage below — +/// tokenjuice compaction (steps 1+2) **and** the per-tool char cap / shared +/// byte-budget backstop (steps 3+4, see [`is_truncation_exempt`]). Both +/// `flows::ops::extract_workflow_proposal` and the frontend's +/// `parseWorkflowProposal` parse this content as a single whole-string JSON +/// document; a byte-cap truncation at a UTF-8 boundary produces invalid JSON +/// just as surely as tokenjuice tabulation strips the `"type"` marker — both +/// end in a silent `proposal: None` and a blank canvas. A ≥10-node graph +/// routinely clears the ~16 KiB shared budget, so the truncation exemption +/// matters just as much as the compaction one. +const COMPACTION_EXEMPT_TOOLS: &[&str] = &[ + "propose_workflow", + "revise_workflow", + "edit_workflow", + "save_workflow", + "create_workflow", +]; + +/// Tools whose results the model reads to derive an exact schema (e.g. +/// `primary_array_path` / `output_fields`) from a *real* sampled tool +/// response, per the B12 output-probe contract (`flows::builder_tools`). +/// TokenJuice's array-elision tabulation defeats their purpose outright — a +/// tabulated sample hides the very array shape the model is calling the tool +/// to observe, so it derives a wrong or nonexistent `split_out.path` from the +/// summary instead of the real response. They're compaction-exempt +/// ([`is_compaction_exempt`]) for that reason. +/// +/// Unlike [`COMPACTION_EXEMPT_TOOLS`], their payload is intermediate context +/// the model reasons over — not the turn's final machine-parsed output — and +/// samples can be genuinely large (a full API response body). So they stay +/// subject to the per-tool char cap / shared byte-budget backstop +/// ([`is_truncation_exempt`] returns `false` for them): a truncated-but-not- +/// tabulated sample is still a usable (if partial) real response, and the +/// backstop keeps these calls from blowing the context budget. +const SAMPLING_TOOLS: &[&str] = &["get_tool_output_sample", "get_tool_contract"]; + +/// Steps 1 (payload summarizer) + 2 (tokenjuice compaction) exemption: +/// proposal tools (final-output contract, see [`COMPACTION_EXEMPT_TOOLS`]) +/// plus sampling tools (tabulation would corrupt the schema they exist to +/// reveal, see [`SAMPLING_TOOLS`]). +fn is_compaction_exempt(name: &str) -> bool { + COMPACTION_EXEMPT_TOOLS.contains(&name) || SAMPLING_TOOLS.contains(&name) +} + +/// Steps 3 (per-tool char cap) + 4 (shared byte-budget backstop) exemption: +/// proposal tools only. Their JSON is parsed as a single whole-string +/// document downstream, so any truncation — not just tokenjuice tabulation — +/// breaks the parse. Sampling tools are deliberately *not* in this set: see +/// [`SAMPLING_TOOLS`] for why the byte cap stays in force for them. +fn is_truncation_exempt(name: &str) -> bool { + COMPACTION_EXEMPT_TOOLS.contains(&name) +} + /// `after_tool`: apply the semantic payload summarizer (when configured) and /// then the hard per-tool-result byte cap to each tool result's model-facing /// content, before it enters the transcript. The graph analogue of the byte cap @@ -758,75 +817,120 @@ impl Middleware<()> for ToolOutputMiddleware { _state: &(), result: &mut TaToolResult, ) -> TaResult<()> { + // Proposal-/persistence-emitting workflow tools return a self-describing + // `{ "type": "workflow_proposal", … }` JSON payload that `flows::ops`' + // `extract_workflow_proposal` (and the frontend's content-based + // recognition) parse structurally. Sampling tools (`get_tool_contract` / + // `get_tool_output_sample`) return a real API response the model reads + // to derive an exact array path/schema. All four stages below are + // content-*rewriting*: tokenjuice (steps 1+2) tabulates any uniform + // object-array of ≥3 rows over ~512 bytes into a `[json table: …]` + // marker (stripping the `"type"` field on graphs with enough nodes, or + // eliding the array a sample exists to reveal); the char cap and shared + // byte-budget backstop (steps 3+4) truncate at a UTF-8 boundary, which + // breaks the whole-string JSON parse both proposal consumers do. See + // [`is_compaction_exempt`]/[`is_truncation_exempt`] for which stages + // each tool family skips and why. + let compaction_exempt = is_compaction_exempt(&result.name); + let truncation_exempt = is_truncation_exempt(&result.name); + if compaction_exempt { + tracing::debug!( + tool = %result.name, + bytes = result.content.len(), + "[tinyagents::mw] compaction-exempt: skipping payload summarizer + tokenjuice" + ); + } + if truncation_exempt { + tracing::debug!( + tool = %result.name, + bytes = result.content.len(), + "[tinyagents::mw] truncation-exempt: skipping per-tool char cap + shared byte-budget backstop" + ); + } + // 1. Semantic summarization (progressive disclosure) — swap the raw // payload for a compressed summary when the summarizer opts in. // Failures never break the tool call (the trait swallows them). - if let Some(ps) = &self.payload_summarizer { - if let Ok(Some(payload)) = ps - .maybe_summarize_in_parent(ctx, &result.name, None, &result.content) - .await - { - tracing::info!( - tool = %result.name, - from_bytes = payload.original_bytes, - to_bytes = payload.summary_bytes, - "[tinyagents::mw] payload_summarizer compressed tool output" - ); + if !compaction_exempt { + if let Some(ps) = &self.payload_summarizer { + if let Ok(Some(payload)) = ps + .maybe_summarize_in_parent(ctx, &result.name, None, &result.content) + .await + { + tracing::info!( + tool = %result.name, + from_bytes = payload.original_bytes, + to_bytes = payload.summary_bytes, + "[tinyagents::mw] payload_summarizer compressed tool output" + ); + ctx.emit(AgentEvent::Compressed { + from_tokens: estimate_output_tokens(payload.original_bytes), + to_tokens: estimate_output_tokens(payload.summary_bytes), + }); + result.content = payload.summary; + } + } + + // 2. TokenJuice content-aware compaction. This mirrors the legacy + // `agent_tool_exec` stage that ran after semantic summarization and + // before the hard output caps. + let before_tokenjuice_bytes = result.content.len(); + let compacted = crate::openhuman::tokenjuice::compact_output_with_policy( + std::mem::take(&mut result.content), + &result.name, + self.tokenjuice_compaction_enabled, + self.tokenjuice_compression, + ) + .await; + result.content = compacted; + let after_tokenjuice_bytes = result.content.len(); + if after_tokenjuice_bytes < before_tokenjuice_bytes { ctx.emit(AgentEvent::Compressed { - from_tokens: estimate_output_tokens(payload.original_bytes), - to_tokens: estimate_output_tokens(payload.summary_bytes), + from_tokens: estimate_output_tokens(before_tokenjuice_bytes), + to_tokens: estimate_output_tokens(after_tokenjuice_bytes), }); - result.content = payload.summary; } } - // 2. TokenJuice content-aware compaction. This mirrors the legacy - // `agent_tool_exec` stage that ran after semantic summarization and - // before the hard output caps. - let before_tokenjuice_bytes = result.content.len(); - let compacted = crate::openhuman::tokenjuice::compact_output_with_policy( - std::mem::take(&mut result.content), - &result.name, - self.tokenjuice_compaction_enabled, - self.tokenjuice_compression, - ) - .await; - result.content = compacted; - let after_tokenjuice_bytes = result.content.len(); - if after_tokenjuice_bytes < before_tokenjuice_bytes { - ctx.emit(AgentEvent::Compressed { - from_tokens: estimate_output_tokens(before_tokenjuice_bytes), - to_tokens: estimate_output_tokens(after_tokenjuice_bytes), - }); - } - // 3. Per-tool **char** cap — a tool that declares `max_result_size_chars` // caps its own output in characters, with the tool-cap marker the model // was taught to read (legacy engine parity). Distinct from the generic - // byte budget below: the tool cap is the tool's own contract. + // byte budget below: the tool cap is the tool's own contract. Skipped + // for truncation-exempt tools (see [`is_truncation_exempt`]) — the tool + // cap is still *computed* below (step 4's "no cap of its own" check + // reads it), just not applied to `result.content`. let tool_cap = self.tool_char_cap(&result.name); - if let Some(cap) = tool_cap { - let char_count = result.content.chars().count(); - if char_count > cap { - let truncated: String = result.content.chars().take(cap).collect(); - let dropped = char_count - cap; - tracing::debug!( - tool = %result.name, - cap, - char_count, - dropped, - "[tinyagents::mw] per-tool char cap applied" - ); - result.content = format!( - "{truncated}\n\n[truncated by tool cap: {dropped} more chars not shown]" - ); + if !truncation_exempt { + if let Some(cap) = tool_cap { + let char_count = result.content.chars().count(); + if char_count > cap { + let truncated: String = result.content.chars().take(cap).collect(); + let dropped = char_count - cap; + tracing::debug!( + tool = %result.name, + cap, + char_count, + dropped, + "[tinyagents::mw] per-tool char cap applied" + ); + result.content = format!( + "{truncated}\n\n[truncated by tool cap: {dropped} more chars not shown]" + ); + } } } // 4. Shared byte-cap backstop — truncate at a UTF-8 boundary with a marker. // Only for tools with no cap of their own (a capped tool already bounded - // itself above; stacking the two markers would double-truncate). - if tool_cap.is_none() && self.budget_bytes > 0 { + // itself above; stacking the two markers would double-truncate), and + // never for truncation-exempt tools. This is a per-result cap only — + // `apply_per_result_persistence` takes a single `content: String` and a + // fixed `self.budget_bytes`, with no shared/global accumulator across + // tool calls (the aggregate-spill variant, `spill_aggregate_tool_results`, + // is a separate legacy code path not wired into this middleware) — so + // exempting these tools' own contribution here cannot perturb any other + // tool's budget accounting. + if !truncation_exempt && tool_cap.is_none() && self.budget_bytes > 0 { let (capped, outcome) = apply_per_result_persistence( std::mem::take(&mut result.content), self.artifact_store.as_ref(), @@ -3599,6 +3703,317 @@ mod tests { ); } + // ── ToolOutputMiddleware: COMPACTION_EXEMPT_TOOLS (workflow proposals) ─── + + /// A `workflow_proposal` payload with enough uniform-object rows to clear + /// tinyjuice's `MIN_ROWS` (3) and default ~512-byte tabulation floor — + /// i.e. exactly the shape that used to get its `"type"` marker stripped by + /// the `[json table: …]` rewrite before the middleware exemption existed. + fn large_workflow_proposal_json() -> String { + let nodes: Vec = (0..6) + .map(|i| { + json!({ + "id": format!("node-{i}"), + "kind": if i == 0 { "trigger" } else { "tool_call" }, + "name": format!("Step {i}"), + "config": { + "slug": format!("oh:placeholder_action_{i}"), + "args": { "input": format!("value-{i}"), "note": "generic placeholder payload for size padding" } + } + }) + }) + .collect(); + serde_json::to_string(&json!({ + "type": "workflow_proposal", + "flow_id": "flow-large-graph", + "graph": { "nodes": nodes, "edges": [] }, + })) + .unwrap() + } + + fn compaction_enabled_mw() -> ToolOutputMiddleware { + ToolOutputMiddleware { + budget_bytes: 1_000_000, + payload_summarizer: None, + artifact_store: None, + tokenjuice_compaction_enabled: true, + tokenjuice_compression: AgentTokenjuiceCompression::Full, + tool_policies: HashMap::new(), + } + } + + #[test] + fn compaction_exempt_tools_contains_every_proposal_tool() { + for tool in [ + "propose_workflow", + "revise_workflow", + "edit_workflow", + "save_workflow", + "create_workflow", + ] { + assert!( + COMPACTION_EXEMPT_TOOLS.contains(&tool), + "{tool} must be exempt from tokenjuice/summarizer compaction" + ); + } + } + + #[tokio::test] + async fn tool_output_tabulates_a_large_graph_for_a_non_exempt_tool() { + // Sanity baseline proving this test's payload actually exercises real + // tinyjuice tabulation (and isn't just below-threshold): a tool name + // NOT in COMPACTION_EXEMPT_TOOLS loses the `"type"` marker. + let mw = compaction_enabled_mw(); + let payload = large_workflow_proposal_json(); + let mut result = tool_result("some_other_tool", &payload); + mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); + assert_ne!( + result.content, payload, + "a non-exempt tool's large uniform-array payload should be rewritten by tokenjuice" + ); + let reparsed: Result = serde_json::from_str(&result.content); + let marker_survived = reparsed + .ok() + .and_then(|v| v.get("type").and_then(|t| t.as_str().map(str::to_string))) + == Some("workflow_proposal".to_string()); + assert!( + !marker_survived, + "baseline expectation: tabulation strips the type marker for non-exempt tools" + ); + } + + #[tokio::test] + async fn tool_output_leaves_propose_workflow_byte_for_byte_intact() { + let mw = compaction_enabled_mw(); + let payload = large_workflow_proposal_json(); + let mut result = tool_result("propose_workflow", &payload); + mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); + assert_eq!( + result.content, payload, + "propose_workflow results must pass through compaction untouched" + ); + let reparsed: serde_json::Value = serde_json::from_str(&result.content).unwrap(); + assert_eq!(reparsed["type"], "workflow_proposal"); + assert_eq!(reparsed["graph"]["nodes"].as_array().unwrap().len(), 6); + } + + #[tokio::test] + async fn tool_output_leaves_every_exempt_tool_name_intact() { + let mw = compaction_enabled_mw(); + let payload = large_workflow_proposal_json(); + for tool in COMPACTION_EXEMPT_TOOLS { + let mut result = tool_result(tool, &payload); + mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); + assert_eq!( + result.content, payload, + "{tool}'s result must pass through compaction untouched" + ); + } + } + + // ── ToolOutputMiddleware: truncation exemption (#4888 follow-up, gap 1) ── + + /// A `workflow_proposal` payload with `node_count` nodes, each padded with + /// a 500-byte `note`, so the caller can force the serialized size past the + /// ~16 KiB shared byte-budget backstop (`DEFAULT_TOOL_RESULT_BUDGET_BYTES`) + /// — the size class a real ≥10-node graph proposal routinely reaches, and + /// exactly what used to get UTF-8-boundary-truncated into unparseable JSON + /// before the truncation exemption existed. + fn oversized_workflow_proposal_json(node_count: usize) -> String { + let nodes: Vec = (0..node_count) + .map(|i| { + json!({ + "id": format!("node-{i}"), + "kind": if i == 0 { "trigger" } else { "tool_call" }, + "name": format!("Step {i}"), + "config": { + "slug": format!("oh:placeholder_action_{i}"), + "args": { "input": format!("value-{i}"), "note": "a".repeat(500) } + } + }) + }) + .collect(); + serde_json::to_string(&json!({ + "type": "workflow_proposal", + "flow_id": "flow-oversized-graph", + "graph": { "nodes": nodes, "edges": [] }, + })) + .unwrap() + } + + /// Middleware config isolating the byte-cap stages (3+4): tokenjuice off, + /// no tool-declared char cap, the real `DEFAULT_TOOL_RESULT_BUDGET_BYTES` + /// (~16 KiB) as the shared backstop, and no artifact store (so an + /// over-budget non-exempt tool falls straight to inline truncation instead + /// of being persisted — deterministic to assert on). + fn truncation_probe_mw() -> ToolOutputMiddleware { + ToolOutputMiddleware { + budget_bytes: DEFAULT_TOOL_RESULT_BUDGET_BYTES, + payload_summarizer: None, + artifact_store: None, + tokenjuice_compaction_enabled: false, + tokenjuice_compression: AgentTokenjuiceCompression::Off, + tool_policies: HashMap::new(), + } + } + + #[tokio::test] + async fn tool_output_leaves_an_oversized_propose_workflow_byte_for_byte_intact() { + // Gap 1: a ≥10-node proposal routinely exceeds the ~16 KiB shared + // byte-budget backstop. Before the truncation exemption, step 4 + // truncated it at a UTF-8 boundary — invalid JSON, so both + // `flows::ops::extract_workflow_proposal` and the frontend's + // `parseWorkflowProposal` silently fell back to `proposal: None` and a + // blank canvas. This must survive byte-for-byte regardless of size. + let mw = truncation_probe_mw(); + let payload = oversized_workflow_proposal_json(30); + assert!( + payload.len() > DEFAULT_TOOL_RESULT_BUDGET_BYTES, + "test payload must exceed the shared byte budget to exercise step 4: {} bytes", + payload.len() + ); + let mut result = tool_result("propose_workflow", &payload); + mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); + assert_eq!( + result.content, payload, + "an oversized propose_workflow result must not be truncated by the shared byte-budget backstop" + ); + let reparsed: serde_json::Value = serde_json::from_str(&result.content) + .expect("must still be valid JSON after passing through after_tool"); + assert_eq!(reparsed["type"], "workflow_proposal"); + assert_eq!(reparsed["graph"]["nodes"].as_array().unwrap().len(), 30); + } + + #[tokio::test] + async fn tool_output_truncates_the_same_oversized_payload_for_a_non_exempt_tool() { + // Baseline pairing with the test above: proves the identical oversized + // payload IS truncated (and consequently unparseable) for a tool that + // is NOT truncation-exempt, so the exemption test isn't vacuously true + // because the payload never actually crossed the budget. + let mw = truncation_probe_mw(); + let payload = oversized_workflow_proposal_json(30); + let mut result = tool_result("some_other_tool", &payload); + mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); + assert_ne!( + result.content, payload, + "a non-exempt tool's oversized payload should be truncated by the shared byte-budget backstop" + ); + assert!( + result.content.contains("truncated by tool_result_budget"), + "expected the byte-budget truncation marker: {}", + result.content + ); + assert!( + serde_json::from_str::(&result.content).is_err(), + "truncated JSON should no longer parse as a whole document" + ); + } + + // ── ToolOutputMiddleware: sampling tools (#4888 follow-up, gap 2) ──────── + + /// A large uniform-array JSON payload shaped like a real sampled tool + /// response (no `workflow_proposal` envelope) — what `get_tool_output_sample` + /// / `get_tool_contract` actually return so the model can derive an exact + /// `primary_array_path`/`output_fields` from the real shape. `row_count` + /// rows of ≥3 clear tinyjuice's tabulation threshold. + fn large_sample_response_json(row_count: usize) -> String { + let rows: Vec = (0..row_count) + .map(|i| { + json!({ + "id": i, + "title": format!("Issue {i}"), + "state": "open", + "body": "padding padding padding padding padding padding", + }) + }) + .collect(); + serde_json::to_string(&json!({ "items": rows })).unwrap() + } + + #[tokio::test] + async fn get_tool_output_sample_is_compaction_exempt() { + // Gap 2: tokenjuice tabulation elides the very array the model calls + // this tool to observe, so it would derive a wrong or nonexistent + // `split_out.path` from the tabulated summary instead of the real + // response shape. The sample must reach the model untabulated. + let mw = compaction_enabled_mw(); + let payload = large_sample_response_json(10); + let mut result = tool_result("get_tool_output_sample", &payload); + mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); + assert_eq!( + result.content, payload, + "get_tool_output_sample's response must not be tokenjuice-tabulated" + ); + } + + #[tokio::test] + async fn get_tool_contract_is_compaction_exempt() { + let mw = compaction_enabled_mw(); + let payload = large_sample_response_json(10); + let mut result = tool_result("get_tool_contract", &payload); + mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); + assert_eq!( + result.content, payload, + "get_tool_contract's response must not be tokenjuice-tabulated" + ); + } + + #[tokio::test] + async fn sampling_tool_output_still_hits_the_byte_budget_backstop() { + // Unlike the proposal tools, sampling tools are deliberately NOT + // truncation-exempt: a truncated-but-untabulated sample is still a + // usable (if partial) real response, and these calls can be genuinely + // large, so the shared byte-budget backstop keeps protecting the + // context budget for them. + let mw = truncation_probe_mw(); + let payload = large_sample_response_json(400); + assert!( + payload.len() > DEFAULT_TOOL_RESULT_BUDGET_BYTES, + "test payload must exceed the shared byte budget: {} bytes", + payload.len() + ); + let mut result = tool_result("get_tool_output_sample", &payload); + mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); + assert_ne!( + result.content, payload, + "get_tool_output_sample must still be subject to the shared byte-budget backstop" + ); + assert!( + result.content.contains("truncated by tool_result_budget"), + "expected the byte-budget truncation marker: {}", + result.content + ); + } + + #[test] + fn compaction_and_truncation_exempt_sets_are_distinct() { + // Proposal tools: exempt from both compaction and truncation. + for tool in COMPACTION_EXEMPT_TOOLS { + assert!( + is_compaction_exempt(tool), + "{tool} must be compaction-exempt" + ); + assert!( + is_truncation_exempt(tool), + "{tool} must be truncation-exempt" + ); + } + // Sampling tools: exempt from compaction only. + for tool in SAMPLING_TOOLS { + assert!( + is_compaction_exempt(tool), + "{tool} must be compaction-exempt" + ); + assert!( + !is_truncation_exempt(tool), + "{tool} must remain subject to the char cap / shared byte-budget backstop" + ); + } + // An arbitrary non-listed tool: exempt from neither. + assert!(!is_compaction_exempt("some_other_tool")); + assert!(!is_truncation_exempt("some_other_tool")); + } + // ── CostBudgetMiddleware ──────────────────────────────────────────────── #[tokio::test] From 0e86b85c9a7c4f6fa3b0ec113d42ff8589caace0 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:29:34 +0530 Subject: [PATCH 08/18] fix(flows): close stale-read race in flows_update disarm + actionable-node reachability (#4891) --- src/openhuman/flows/ops.rs | 102 ++++++++++++++++++++-------- src/openhuman/flows/ops_tests.rs | 90 +++++++++++++++++++++++++ src/openhuman/flows/store_tests.rs | 104 +++++++++++++++++++++++++++++ 3 files changed, 268 insertions(+), 28 deletions(-) diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index 3f5f7bae3f..3704acdad9 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -367,21 +367,46 @@ pub(crate) fn graph_has_outbound_side_effect(graph: &WorkflowGraph) -> bool { } /// Whether `graph` has anything for [`flows_run`] to actually *do* — i.e. at -/// least one non-`trigger` node, wired up by at least one edge. A graph made -/// of nothing but a bare `trigger` node (or a `trigger` plus unreachable/ -/// disconnected nodes with no edges at all) can compile and "run" cleanly -/// while producing no work whatsoever — the exact live finding this guards: -/// a trigger-only flow reported `status="completed" pending_approvals=0` -/// having done nothing, which reads as a successful automation to anyone not -/// staring at the node count. Used by `flows_run` to attach a -/// human-readable note to an otherwise-silent "success". +/// least one non-`trigger` node **reachable from the trigger** by following +/// directed edges. A graph made of nothing but a bare `trigger` node (or a +/// `trigger` plus unreachable/disconnected nodes — even ones wired to each +/// other by their own edges, just not to the trigger) can compile and "run" +/// cleanly while producing no work whatsoever — the exact live finding this +/// guards: a trigger-only flow reported `status="completed" +/// pending_approvals=0` having done nothing, which reads as a successful +/// automation to anyone not staring at the node count. Used by `flows_run` +/// to attach a human-readable note to an otherwise-silent "success". +/// +/// Deliberately a reachability walk rather than "any edge at all exists": +/// `nodes.len() > 1 && !edges.is_empty()` would count a disconnected +/// component's internal edges as actionable even though nothing downstream +/// of the trigger ever runs. pub(crate) fn graph_has_actionable_nodes(graph: &WorkflowGraph) -> bool { - let non_trigger_nodes = graph - .nodes - .iter() - .filter(|n| n.kind != NodeKind::Trigger) - .count(); - non_trigger_nodes > 0 && !graph.edges.is_empty() + let Some(trigger) = graph.trigger() else { + // No single resolvable trigger to walk from — fall back to the + // coarse "any non-trigger node wired up by an edge" check so a + // malformed/ambiguous-trigger graph doesn't spuriously suppress the + // empty-flow note. + return graph.nodes.iter().any(|n| n.kind != NodeKind::Trigger) && !graph.edges.is_empty(); + }; + + let mut visited: std::collections::HashSet<&str> = std::collections::HashSet::new(); + let mut stack = vec![trigger.id.as_str()]; + while let Some(current) = stack.pop() { + if !visited.insert(current) { + continue; + } + for next in graph.successors(current) { + if !visited.contains(next) { + stack.push(next); + } + } + } + + visited + .into_iter() + .filter_map(|id| graph.node(id)) + .any(|n| n.kind != NodeKind::Trigger) } /// Produces host-side, **non-fatal** validation warnings for a graph — today @@ -2491,15 +2516,29 @@ fn map_flow_update_error(e: store::FlowUpdateError) -> String { /// tool, the canvas Save button, a proposal apply, or any other /// `flows_update` caller — and go LIVE immediately with no user review /// (confirmed live: a flow started firing on an unreviewed 8am schedule). -/// So: when the *new* graph's trigger is automatic, the flow is *currently* -/// enabled, and the *previous* graph's trigger was NOT automatic (a -/// manual/none → automatic transition), this forces the persisted `enabled` -/// back to `false` in the same store write — the user must explicitly -/// re-arm via `flows_set_enabled` after reviewing the new trigger. -/// Deliberately narrower than Rule 1's at-create version: a flow that was -/// already an enabled *automatic*-trigger flow being legitimately re-edited -/// (e.g. tweaking a cron expression) is left alone — the user already opted -/// in once, and re-disarming on every edit would just be friction. +/// So: when the *new* graph's trigger is automatic and the *previous* +/// graph's trigger was NOT automatic (a manual/none → automatic +/// transition), this forces the persisted `enabled` back to `false` in the +/// same store write — the user must explicitly re-arm via +/// `flows_set_enabled` after reviewing the new trigger. An automatic → +/// automatic re-edit (e.g. tweaking a cron expression) is left alone — the +/// user already opted in once, and re-disarming on every edit would just be +/// friction. +/// +/// The override is applied **unconditionally** on a manual/none → automatic +/// transition — it does *not* gate on whether the flow *looked* enabled in +/// the `existing` read above. That read is a snapshot taken before +/// `store::update_flow_graph`'s own guarded UPDATE re-reads the row; a +/// concurrent `flows_set_enabled(id, true)` landing in the gap would leave +/// this snapshot stale while the row is actually enabled by the time the +/// guarded UPDATE runs — and since `set_enabled` bumps `updated_at` too, +/// such a race wouldn't even trip the optimistic-concurrency conflict, it +/// would just silently persist the automatic graph as enabled (the exact +/// bug this rule exists to close). Gating on the stale `existing.enabled` +/// re-opens that race; forcing the override on every transition, enabled-or- +/// not, is exactly as safe as Rule 1's at-create version — a transition on +/// an already-disabled flow is just a no-op write of `enabled=false` over +/// `enabled=false`. pub async fn flows_update( config: &Config, id: &str, @@ -2523,19 +2562,26 @@ pub async fn flows_update( } }; - // B29 Rule 1 analogue: only disarm a manual/none → automatic transition - // on an already-enabled flow. An automatic → automatic re-edit, or a - // flow that isn't enabled to begin with, is untouched. + // B29 Rule 1 analogue: disarm every manual/none → automatic trigger + // transition, unconditionally — see the doc comment above for why this + // must NOT gate on the (possibly stale) `existing.enabled` read. let was_auto = trigger_is_automatic(&existing.graph); let now_auto = trigger_is_automatic(&graph); - let should_disarm = now_auto && existing.enabled && !was_auto; - let enabled_override = should_disarm.then_some(false); + let is_manual_to_auto_transition = now_auto && !was_auto; + let enabled_override = is_manual_to_auto_transition.then_some(false); + // Best-effort flag for the info log / result message below: whether the + // flow *appeared* live going into this update. Not used for the + // override decision itself (that's unconditional, see above) — only to + // avoid telling the user "flow was auto-disabled" when it was already + // disabled going in. + let should_disarm = is_manual_to_auto_transition && existing.enabled; tracing::debug!( target: "flows", flow_id = %id, was_auto, now_auto, currently_enabled = existing.enabled, + is_manual_to_auto_transition, should_disarm, "[flows] flows_update: auto-trigger disarm decision inputs" ); diff --git a/src/openhuman/flows/ops_tests.rs b/src/openhuman/flows/ops_tests.rs index b4c1663c1b..93409a4793 100644 --- a/src/openhuman/flows/ops_tests.rs +++ b/src/openhuman/flows/ops_tests.rs @@ -301,6 +301,46 @@ async fn flows_run_on_graph_with_actionable_nodes_has_no_empty_flow_note() { ); } +/// `graph_has_actionable_nodes` must walk from the trigger, not merely check +/// "any non-trigger node plus any edge". A component with edges of its own, +/// but no path back to the trigger, is unreachable and must still surface +/// the "nothing to run" note — a naive count-based check would have missed +/// this and wrongly suppressed the note. +#[tokio::test] +async fn flows_run_on_graph_with_disconnected_component_still_surfaces_empty_flow_note() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let graph = json!({ + "name": "disconnected", + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Trigger" }, + { "id": "a", "kind": "output_parser", "name": "Orphan A" }, + { "id": "b", "kind": "output_parser", "name": "Orphan B" } + ], + "edges": [ + // "a" -> "b" is wired up, but neither is reachable from "t" — the + // trigger has no outgoing edges at all. + { "from_node": "a", "to_node": "b" } + ] + }); + let created = flows_create(&config, "disconnected".to_string(), graph, false) + .await + .unwrap(); + + let outcome = flows_run(&config, &created.value.id, json!({}), FlowRunTrigger::Rpc) + .await + .unwrap(); + + let note = outcome.value["note"] + .as_str() + .expect("a component disconnected from the trigger must still surface the empty-flow note"); + assert!( + note.contains("no actionable nodes") || note.to_lowercase().contains("nothing"), + "note should explain that nothing ran, got: {note}" + ); +} + #[tokio::test] async fn flows_run_reports_pending_approval_and_blocks_downstream() { let tmp = TempDir::new().unwrap(); @@ -697,6 +737,56 @@ async fn flows_update_disables_on_manual_to_automatic_trigger_transition_when_en ); } +/// Regression: the manual→automatic disarm must apply unconditionally, not +/// only when `flows_update`'s own `existing` read observes `enabled: true`. +/// A live race (Codex, this PR) could leave that read stale — a concurrent +/// `flows_set_enabled(id, true)` landing between the read and the guarded +/// write would previously compute `should_disarm = false` from the stale +/// snapshot and let the automatic graph persist enabled. This test pins the +/// non-racy half of that contract directly at the `flows_update` level: even +/// starting from an *observed* `enabled: false`, a manual→automatic +/// transition still writes the override (a no-op here since the flow was +/// already disabled) rather than skipping it — see +/// `store::update_flow_graph_override_wins_over_concurrently_enabled_row` +/// (store_tests.rs) for the deterministic proof that this override also wins +/// a genuine concurrent-enable race. +#[tokio::test] +async fn flows_update_disarms_manual_to_automatic_transition_even_when_already_disabled() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let created = flows_create( + &config, + "manual-then-scheduled".to_string(), + manual_trigger_graph(), + false, + ) + .await + .unwrap(); + flows_set_enabled(&config, &created.value.id, false) + .await + .unwrap(); + + let updated = flows_update( + &config, + &created.value.id, + None, + Some(schedule_trigger_graph("0 8 * * *")), + None, + None, + ) + .await + .unwrap(); + + assert!( + !updated.value.enabled, + "a manual→automatic transition must never leave the flow enabled, regardless of \ + whether it looked enabled going in" + ); + let reloaded = flows_get(&config, &created.value.id).await.unwrap(); + assert!(!reloaded.value.enabled); +} + #[tokio::test] async fn flows_update_preserves_enabled_when_already_automatic() { let tmp = TempDir::new().unwrap(); diff --git a/src/openhuman/flows/store_tests.rs b/src/openhuman/flows/store_tests.rs index 778712a0e9..cd679fae42 100644 --- a/src/openhuman/flows/store_tests.rs +++ b/src/openhuman/flows/store_tests.rs @@ -107,6 +107,110 @@ fn update_flow_graph_bumps_updated_at_and_preserves_created_at() { assert_eq!(updated.graph.name, "renamed-graph"); } +/// `enabled_override: None` must leave the persisted `enabled` column +/// exactly as it was — `update_flow_graph` re-reads the current row and +/// falls back to `current.enabled`, not to whatever the caller might have +/// observed earlier. +#[test] +fn update_flow_graph_with_none_override_preserves_current_enabled_column() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow(&config, "demo".to_string(), trigger_graph(), false, true).unwrap(); + assert!(flow.enabled, "flow created enabled"); + + let updated = update_flow_graph( + &config, + &flow.id, + flow.name.clone(), + trigger_graph(), + false, + None, // enabled_override + None, + ) + .unwrap(); + + assert!( + updated.enabled, + "a None override must preserve the row's current enabled state" + ); + let reloaded = get_flow(&config, &flow.id).unwrap().unwrap(); + assert!(reloaded.enabled); +} + +/// `enabled_override: Some(false)` must force-persist `enabled=false` +/// regardless of what the row's `enabled` column currently holds — this is +/// the mechanism `flows_update`'s B29 Rule 1 analogue relies on to disarm a +/// manual→automatic trigger transition in the same guarded write. +#[test] +fn update_flow_graph_with_some_false_override_forces_disabled() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow(&config, "demo".to_string(), trigger_graph(), false, true).unwrap(); + assert!(flow.enabled, "flow created enabled"); + + let updated = update_flow_graph( + &config, + &flow.id, + flow.name.clone(), + trigger_graph(), + false, + Some(false), // enabled_override + None, + ) + .unwrap(); + + assert!( + !updated.enabled, + "a Some(false) override must force enabled=false even though the row was enabled" + ); + let reloaded = get_flow(&config, &flow.id).unwrap().unwrap(); + assert!(!reloaded.enabled); +} + +/// Regression for the silent live-arming race Codex flagged on this PR: +/// `flows_update` (ops.rs) makes its manual→automatic disarm decision from +/// an *outer* `existing` read taken before `update_flow_graph`'s own guarded +/// UPDATE re-reads the row. If a concurrent `flows_set_enabled(id, true)` +/// landed in that gap — which bumps `updated_at`, so it would NOT trip the +/// optimistic-concurrency conflict — the outer read would be stale while the +/// row is actually enabled by write time. This proves the mechanism the fix +/// relies on to close that race: an `enabled_override` of `Some(false)` +/// (what `flows_update` now passes unconditionally on a manual→automatic +/// transition, never gated on the stale outer read) always wins over +/// whatever the row's `enabled` column was concurrently flipped to, +/// simulated here by flipping it with `set_enabled` between the two calls. +#[test] +fn update_flow_graph_override_wins_over_concurrently_enabled_row() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow(&config, "demo".to_string(), trigger_graph(), false, false).unwrap(); + assert!(!flow.enabled, "flow created disabled"); + + // Simulates a concurrent `flows_set_enabled(id, true)` racing in after + // `flows_update`'s outer `existing` read observed `enabled: false`, but + // before its guarded `update_flow_graph` write below. + let raced = set_enabled(&config, &flow.id, true).unwrap(); + assert!(raced.enabled); + + let updated = update_flow_graph( + &config, + &flow.id, + flow.name.clone(), + trigger_graph(), + false, + Some(false), // the unconditional disarm override + None, + ) + .unwrap(); + + assert!( + !updated.enabled, + "the disarm override must win over a concurrently-enabled row, not the reverse" + ); + let reloaded = get_flow(&config, &flow.id).unwrap().unwrap(); + assert!(!reloaded.enabled); +} + #[test] fn record_run_sets_last_run_fields() { let tmp = TempDir::new().unwrap(); From 095e3c34463fc4b2ae1ff601fbeb5d4c1e22a4f3 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:10:05 +0530 Subject: [PATCH 09/18] fix(chat): don't wipe fresh streaming/tool-timeline on a completed hydrate snapshot (#4903) --- app/src/store/chatRuntimeSlice.test.ts | 97 ++++++++++++++++++++++++++ app/src/store/chatRuntimeSlice.ts | 30 ++++++-- 2 files changed, 120 insertions(+), 7 deletions(-) diff --git a/app/src/store/chatRuntimeSlice.test.ts b/app/src/store/chatRuntimeSlice.test.ts index 05c97de39a..769a8baaae 100644 --- a/app/src/store/chatRuntimeSlice.test.ts +++ b/app/src/store/chatRuntimeSlice.test.ts @@ -17,6 +17,7 @@ import chatRuntimeReducer, { resetSessionTokenUsage, setPendingApprovalForThread, setQueueStatusForThread, + setStreamingAssistantForThread, setToolTimelineForThread, setWorkflowProposalForThread, } from './chatRuntimeSlice'; @@ -759,6 +760,102 @@ describe('hydrateRuntimeFromSnapshot — workflow proposal race guard', () => { }); }); +// Regression: a `completed` snapshot rehydration must not clobber streaming +// narration / a tool timeline that is fresher than the snapshot itself. This +// happens when the socket-disconnect reconciliation path (ChatRuntimeProvider) +// deliberately preserves `streamingAssistantByThread` across `endInferenceTurn` +// so a partial reply stays visible while the socket reconnects, and a +// `fetchAndHydrateTurnState` rehydration then lands with a `completed` +// snapshot for that same (now-settled) turn. Only `interrupted` (a genuine +// crash — nothing fresher can exist) should unconditionally clobber; a +// `completed` snapshot should only fill in when there is no live state to +// lose (cold boot / new window). +describe('hydrateRuntimeFromSnapshot — streaming/timeline race guard', () => { + function makeTimelineSnapshot( + threadId: string, + lifecycle: PersistedTurnState['lifecycle'] + ): PersistedTurnState { + return { + threadId, + requestId: 'req-snapshot', + lifecycle, + iteration: 3, + maxIterations: 10, + streamingText: '', + thinking: '', + toolTimeline: [{ id: 'c-snapshot', name: 'web_search', round: 1, status: 'success' }], + startedAt: '2026-06-23T00:00:00Z', + updatedAt: '2026-06-23T00:00:00Z', + }; + } + + it('does not wipe a fresher streaming/tool-timeline lane on a completed snapshot', () => { + const store = makeStore(); + // The live driver already has state for this thread — e.g. the + // disconnect-reconciliation path kept the streaming bubble around while + // the socket reconnects. + store.dispatch( + setStreamingAssistantForThread({ + threadId: 't-settled', + streaming: { requestId: 'req-live', content: 'partial reply so far', thinking: '' }, + }) + ); + store.dispatch( + setToolTimelineForThread({ + threadId: 't-settled', + entries: [{ id: 'c-live', name: 'read_file', round: 1, status: 'success' }], + }) + ); + + store.dispatch( + hydrateRuntimeFromSnapshot({ snapshot: makeTimelineSnapshot('t-settled', 'completed') }) + ); + + const state = store.getState().chatRuntime; + // The fresher live streaming bubble survives untouched… + expect(state.streamingAssistantByThread['t-settled']?.content).toBe('partial reply so far'); + // …and so does the live tool timeline, rather than being replaced by the + // (behind) snapshot's single row. + expect(state.toolTimelineByThread['t-settled'].map(e => e.id)).toEqual(['c-live']); + }); + + it('clears the streaming/tool-timeline lanes on an interrupted snapshot (crash cleanup)', () => { + const store = makeStore(); + store.dispatch( + setStreamingAssistantForThread({ + threadId: 't-crashed', + streaming: { requestId: 'req-stale', content: 'stale partial reply', thinking: '' }, + }) + ); + store.dispatch( + setToolTimelineForThread({ + threadId: 't-crashed', + entries: [{ id: 'c-stale', name: 'read_file', round: 1, status: 'running' }], + }) + ); + + store.dispatch( + hydrateRuntimeFromSnapshot({ snapshot: makeTimelineSnapshot('t-crashed', 'interrupted') }) + ); + + const state = store.getState().chatRuntime; + expect(state.streamingAssistantByThread['t-crashed']).toBeUndefined(); + expect(state.toolTimelineByThread['t-crashed'].map(e => e.id)).toEqual(['c-snapshot']); + }); + + it('still hydrates the timeline from a completed snapshot on cold boot (no prior live state)', () => { + const store = makeStore(); + + store.dispatch( + hydrateRuntimeFromSnapshot({ snapshot: makeTimelineSnapshot('t-cold-boot', 'completed') }) + ); + + const state = store.getState().chatRuntime; + expect(state.streamingAssistantByThread['t-cold-boot']).toBeUndefined(); + expect(state.toolTimelineByThread['t-cold-boot'].map(e => e.id)).toEqual(['c-snapshot']); + }); +}); + describe('hydrateRuntimeFromSnapshot — persisted tool result output', () => { it('maps the persisted output onto parent and sub-agent rows as result', () => { const store = makeStore(); diff --git a/app/src/store/chatRuntimeSlice.ts b/app/src/store/chatRuntimeSlice.ts index 5ca4e06890..d0cad6d808 100644 --- a/app/src/store/chatRuntimeSlice.ts +++ b/app/src/store/chatRuntimeSlice.ts @@ -1989,13 +1989,29 @@ const chatRuntimeSlice = createSlice({ // is still carried so "View processing" replays the full reasoning. if (snapshot.lifecycle === 'interrupted' || snapshot.lifecycle === 'completed') { delete state.inferenceStatusByThread[threadId]; - delete state.streamingAssistantByThread[threadId]; - // Settle any in-flight rows so their agent names stop pulsing - // (no-op for an already-completed snapshot whose rows are terminal). - state.toolTimelineByThread[threadId] = preserveLiveSubagentProse( - state.toolTimelineByThread[threadId], - snapshot.toolTimeline.map(toolTimelineFromPersisted).map(settleOrphanedTimelineEntry) - ); + + // A `completed` snapshot can still lag behind live state this session + // already has for the thread: the socket-disconnect reconciliation + // path (`ChatRuntimeProvider`) deliberately *keeps* + // `streamingAssistantByThread` set across `endInferenceTurn` so a + // partial reply stays visible while the socket reconnects, and a + // `fetchAndHydrateTurnState` rehydration can land moments later. The + // same applies to `toolTimelineByThread`, which the live event + // stream keeps richer/fresher than a flush-boundary persisted copy. + // Only let the snapshot clobber those lanes when it is unambiguously + // the authority: an `interrupted` snapshot (the process that was + // streaming is gone — nothing fresher can exist) or there is no live + // streaming state for this thread to lose (cold boot / new window). + const hasFresherLiveStream = Boolean(state.streamingAssistantByThread[threadId]); + if (snapshot.lifecycle === 'interrupted' || !hasFresherLiveStream) { + delete state.streamingAssistantByThread[threadId]; + // Settle any in-flight rows so their agent names stop pulsing + // (no-op for an already-completed snapshot whose rows are terminal). + state.toolTimelineByThread[threadId] = preserveLiveSubagentProse( + state.toolTimelineByThread[threadId], + snapshot.toolTimeline.map(toolTimelineFromPersisted).map(settleOrphanedTimelineEntry) + ); + } state.processingByThread[threadId] = snapshot.transcript ?? []; return; } From 5ba512b201b74fe0f7994cd9a2b9164d9e8c4d08 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:36:13 +0530 Subject: [PATCH 10/18] fix(flows): correct save_workflow + workflow_builder contract text (disarm/create accuracy) (#4904) --- .../flows/agents/workflow_builder/agent.toml | 15 ++-- .../agents/workflow_builder/builder_prompt.rs | 23 ++++++ .../flows/agents/workflow_builder/prompt.md | 41 +++++++---- src/openhuman/flows/builder_tools.rs | 36 +++++++--- src/openhuman/flows/builder_tools_tests.rs | 70 +++++++++++++++++++ 5 files changed, 158 insertions(+), 27 deletions(-) diff --git a/src/openhuman/flows/agents/workflow_builder/agent.toml b/src/openhuman/flows/agents/workflow_builder/agent.toml index 159839aee5..c004043381 100644 --- a/src/openhuman/flows/agents/workflow_builder/agent.toml +++ b/src/openhuman/flows/agents/workflow_builder/agent.toml @@ -1,13 +1,14 @@ id = "workflow_builder" display_name = "Workflow Builder" delegate_name = "build_workflow" -when_to_use = "Workflow authoring specialist — owns building tinyflows automation graphs from a natural-language description. Route any request to 'set up a workflow that…', 'automate…', 'when X happens do Y', 'build/create/edit an automation or flow', or to iterate on a proposed workflow here. It grounds nodes in real tool slugs + connections, dry-runs a draft in a sandbox to self-check, and returns a workflow PROPOSAL for review. Persistence stays with the user's Accept + Save; when the user explicitly asks the agent to save (or test-run) onto an existing flow id it may `save_workflow` / `run_flow` — but never on its own, and it can never create a new flow or enable/disable one. Not for running an already-saved flow (that is a direct flows_run) and not for the legacy skill 'workflows'." +when_to_use = "Workflow authoring specialist — owns building tinyflows automation graphs from a natural-language description. Route any request to 'set up a workflow that…', 'automate…', 'when X happens do Y', 'build/create/edit an automation or flow', or to iterate on a proposed workflow here. It grounds nodes in real tool slugs + connections, dry-runs a draft in a sandbox to self-check, and returns a workflow PROPOSAL for review. Persistence stays with the user's Accept + Save; when the user explicitly asks the agent to save (or test-run) onto an existing flow id it may `save_workflow` / `run_flow` — but never on its own. It CAN create a new flow (`create_workflow`) or clone one (`duplicate_flow`) when the user explicitly asks, but every flow it creates is always born DISABLED — it can never enable a flow, or run one without the user's confirmation first. Not for running an already-saved flow (that is a direct flows_run) and not for the legacy skill 'workflows'." temperature = 0.2 max_iterations = 12 iteration_policy = "extended" max_result_chars = 12000 -# No sandbox filter needed: the belt is propose/read/dry-run plus two +# No sandbox filter needed: the belt is propose/read/dry-run plus a handful of # explicitly-bounded writes (save_workflow onto an existing flow; +# create_workflow/duplicate_flow, always force-disabled at creation; # run_flow of a saved flow behind a confirm-first prompt rule), and # dry_run_workflow runs against tinyflows MOCK capabilities (no real effects). sandbox_mode = "none" @@ -28,10 +29,12 @@ hint = "reasoning" # DELIBERATELY NARROW: propose/revise (validate-only) + read (flows, runs, # connections, tool catalog) + sandbox dry-run + Composio discovery/connect + # a confirmed real test-run of a SAVED flow + save_workflow (persist a graph -# onto an EXISTING flow only). NO shell, NO file writes, NO channel sends, NO -# composio_execute, and NO flows_create/set_enabled — the agent can never -# create or enable a flow, or perform an arbitrary real integration action -# directly. Composio access is limited to LISTING toolkits/connections, +# onto an EXISTING flow only) + create_workflow/duplicate_flow (persist a +# BRAND NEW flow — always force-disabled at creation, see +# `builder_tools::CreateWorkflowTool`). NO shell, NO file writes, NO channel +# sends, NO composio_execute, and NO flows_set_enabled — the agent can create +# a flow but can never enable one, or perform an arbitrary real integration +# action directly. Composio access is limited to LISTING toolkits/connections, # raising the inline CONNECT card (an approval-gated OAuth hand-off), and one # narrow carve-out below. # `run_flow` executes a flow the user has ALREADY saved to test it — a real diff --git a/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs b/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs index 97f7a8f05f..80a6c1f008 100644 --- a/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs +++ b/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs @@ -335,6 +335,29 @@ mod tests { STANDING_PROMPT.contains("Read-only — you can't change their memory"), "standing prompt must state the memory read-only guarantee, not just mention memory_recall" ); + + // Negative (contract accuracy, issue #6): `create_workflow` and + // `duplicate_flow` are on this agent's belt (see agent.toml's `named` + // tool list), so the prompt must never claim the agent can't create a + // flow at all — only that it can't enable/run one unattended. + for banned in [ + "create a new flow, or enable/disable one", + "It cannot create flows,", + ] { + assert!( + !STANDING_PROMPT.contains(banned), + "standing prompt must not carry the stale \"can never create a flow\" claim \ + `{banned}` — create_workflow/duplicate_flow are on the belt (issue #6)" + ); + } + + // Positive: the accurate contract — the agent CAN create a flow, but + // every flow it creates is always born disabled. + assert!( + STANDING_PROMPT.contains("create_workflow") && STANDING_PROMPT.contains("born"), + "standing prompt must accurately teach that create_workflow exists and that \ + created flows are always born disabled (issue #6)" + ); } #[test] diff --git a/src/openhuman/flows/agents/workflow_builder/prompt.md b/src/openhuman/flows/agents/workflow_builder/prompt.md index 3a643e3496..0ab8b7e5cf 100644 --- a/src/openhuman/flows/agents/workflow_builder/prompt.md +++ b/src/openhuman/flows/agents/workflow_builder/prompt.md @@ -8,8 +8,11 @@ user to review and save. ## The invariants you must never break -You **cannot and must not** create a new flow, or enable/disable one. You have -no tool that does — by design. Your authoring outputs are: +You **can** create a new flow (`create_workflow`) or clone one +(`duplicate_flow`), but only when the user explicitly asks — and every flow +you create is always born **DISABLED**. Enabling a flow is not a tool you +have, by design: you **cannot and must not** enable or disable one, ever. +Your authoring outputs are: - **`propose_workflow`** / **`revise_workflow`** — these *validate* a candidate graph and hand back a proposal summary. They **never** save anything. @@ -28,7 +31,7 @@ exception is `save_workflow` on an **existing** flow id, and only when the user **explicitly asks** (see below). If a user says "just turn it on for me", explain that enabling stays in their hands — you cannot enable a flow. -## Saving your work: `save_workflow` (only on the user's explicit ask) +## Saving your work: `save_workflow` / `create_workflow` (only on the user's explicit ask) Every authoring turn — build, revise, or repair — is **propose-only** by default. Your arc is: @@ -41,20 +44,32 @@ default. Your arc is: card") — never recite every persist path, and never repeat it across turns. -**When the user says "save it":** if you have a `save_workflow` action -available — an **existing** `flow_id` plus their explicit ask ("save this", -"yes save it onto flow_X") — just call `save_workflow { flow_id, draft_id, -name? }` (pass the `draft_id` you've been iterating on; an inline `graph` -also works) and confirm in one plain line what you saved (trigger, steps, and — -if the flow is enabled with a schedule/app_event trigger — that it's now -live and will fire on its own). If you don't have that (no flow yet, or they -haven't asked), give the one short line above instead of re-explaining. +**When the user says "save it":** which tool depends on whether the flow +already exists: + +- **Existing flow** — you have a `flow_id` plus their explicit ask ("save + this", "yes save it onto flow_X") — just call `save_workflow { flow_id, + draft_id, name? }` (pass the `draft_id` you've been iterating on; an inline + `graph` also works) and confirm in one plain line what you saved (trigger, + steps, and — if the flow is enabled with a schedule/app_event trigger — + that it's now live and will fire on its own). +- **Brand-new flow** — no `flow_id` yet, but the user explicitly asked you to + create/save it as a new automation ("create this and save it", "make this a + new flow") — call `create_workflow` (or `duplicate_flow` to clone an + existing one) instead; it persists a NEW flow, always born **DISABLED**, + and confirm what you created plus that it's off until they enable it. +- **Neither** (no flow yet and no explicit save/create ask, or they haven't + asked at all) — give the one short line from step 2 above instead of + re-explaining. **Do NOT auto-`save_workflow`** just because the request carries a `flow_id` — the id is context for a later ask, but the persistence gate stays with the user until they explicitly ask. Never `save_workflow` onto a -flow the user did NOT ask you to build/update. It cannot create flows, and -it never changes `enabled` or the approval gate. +flow the user did NOT ask you to build/update. It only writes onto a flow +that already exists (creating one is `create_workflow`'s job, not +`save_workflow`'s) and it never touches the approval gate — but it CAN +auto-disable the flow if the graph's trigger just transitioned from manual +to automatic on an already-enabled flow; say so if it happens. ## Testing a saved flow: `run_flow` (ask first!) diff --git a/src/openhuman/flows/builder_tools.rs b/src/openhuman/flows/builder_tools.rs index 21a49b19a6..60495aca0b 100644 --- a/src/openhuman/flows/builder_tools.rs +++ b/src/openhuman/flows/builder_tools.rs @@ -3232,10 +3232,13 @@ impl Tool for SaveWorkflowTool { the usual case after editing with edit_workflow; draft_id wins if both are given) or \ an inline `graph`; `flow_id` is always required as the persistence TARGET. It \ validates and writes the graph (and optional new `name`) to that flow. It can NOT \ - create a new flow, and it never changes the flow's enabled state or its approval \ - gate. NOTE: if the flow is enabled and the graph has a schedule/app_event trigger, \ - saving arms it — it will start firing on its own. Always tell the user what you \ - saved. Params: { flow_id, draft_id? | graph?, name? }." + create a new flow, and it never touches the approval gate — but it CAN \ + auto-disable the flow when the trigger transitions from manual to automatic \ + (schedule/webhook/app_event), so a save never silently arms a trigger that wasn't \ + already live; the returned `warnings` will explain it when that happens. NOTE: if \ + the flow was ALREADY enabled with an automatic trigger and stays automatic, saving \ + re-arms it live — it will start firing on its own. Always tell the user what you \ + saved (including any auto-disable). Params: { flow_id, draft_id? | graph?, name? }." } fn parameters_schema(&self) -> Value { @@ -3385,12 +3388,29 @@ impl Tool for SaveWorkflowTool { enabled = flow.enabled, "[flows] save_workflow: persisted" ); + // Surface any explanatory logs `flows_update` produced — most + // notably the manual→automatic auto-disarm message (#4889) — + // to the agent. Skip the boilerplate "flow updated: " line, + // which just duplicates the `persisted`/`flow_id` fields this + // response already carries. + let flow_updated_boilerplate = format!("flow updated: {flow_id}"); + warnings.extend( + outcome + .logs + .into_iter() + .filter(|log| *log != flow_updated_boilerplate), + ); // Issue B29 (save/enable safety), Rule 3: `flows_create` only // gates the FIRST creation of a flow — an agent `save_workflow` - // targets an EXISTING flow via `flows_update`, which preserves - // whatever `enabled` state the flow already had. If the user - // already armed this flow (enabled it) and it has an automatic - // trigger, saving a new graph onto it re-arms it live with no + // targets an EXISTING flow via `flows_update`, which (since + // #4889) force-disables the flow whenever the trigger + // transitions from manual to automatic (schedule/webhook/ + // app_event) — so a save can never silently arm a trigger that + // wasn't already live (see the `warnings.extend` above for the + // explanatory log). Short of that transition, `flows_update` + // preserves whatever `enabled` state the flow already had: if + // it was ALREADY enabled with an automatic trigger and stays + // automatic, saving a new graph onto it re-arms it live with no // further confirmation. Surface that loudly so the copilot // relays it to the user instead of staying silent. if flow.enabled && ops::trigger_is_automatic(&flow.graph) { diff --git a/src/openhuman/flows/builder_tools_tests.rs b/src/openhuman/flows/builder_tools_tests.rs index e6a4d59dd1..da11ca8972 100644 --- a/src/openhuman/flows/builder_tools_tests.rs +++ b/src/openhuman/flows/builder_tools_tests.rs @@ -1759,6 +1759,76 @@ async fn save_workflow_rejects_invalid_graph_and_leaves_flow_intact() { ); } +/// A single-node graph with an automatic (schedule) trigger — enough to +/// exercise the manual→automatic transition without tripping any of +/// `run_builder_gates`' binding/connection/contract checks (no other nodes, +/// nothing to bind). +fn schedule_trigger_graph() -> Value { + json!({ + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Trigger", + "config": { "trigger_kind": "schedule", "schedule": "0 8 * * *" } } + ], + "edges": [] + }) +} + +#[tokio::test] +async fn save_workflow_surfaces_auto_disarm_warning_on_manual_to_automatic_transition() { + // Regression for #4889 + the stale-docs issue that motivated this test: + // `flows_update` auto-disables a flow whenever its trigger transitions + // from manual to automatic on an already-enabled flow, but `save_workflow` + // used to drop `flows_update`'s explanatory `RpcOutcome.logs` entirely — + // the agent had no way to relay the disarm to the user. Assert both the + // disarm itself and that its log now surfaces in `save_workflow`'s + // `warnings`. + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow_id = seed_flow(&config, "Manual flow").await; + let seeded = ops::flows_get(&config, &flow_id).await.unwrap().value; + assert!( + seeded.enabled, + "precondition: a manual-trigger flow persists enabled from create" + ); + + let tool = SaveWorkflowTool::new(config.clone()); + let result = tool + .execute(json!({ + "flow_id": flow_id, + "graph": schedule_trigger_graph(), + })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!( + parsed["enabled"], false, + "manual→automatic transition on an enabled flow must auto-disable it: {parsed}" + ); + let warnings = parsed["warnings"] + .as_array() + .expect("warnings must be an array"); + assert!( + warnings + .iter() + .any(|w| w.as_str().unwrap_or("").contains("auto-disabled")), + "save_workflow must surface flows_update's disarm log as a warning, got: {parsed}" + ); + let flow_updated_boilerplate = format!("flow updated: {flow_id}"); + assert!( + warnings + .iter() + .all(|w| w.as_str().unwrap_or("") != flow_updated_boilerplate), + "save_workflow must exclude the redundant \"flow updated: \" boilerplate \ + from warnings, got: {parsed}" + ); + + // Persisted, not just returned in-memory. + let reloaded = ops::flows_get(&config, &flow_id).await.unwrap().value; + assert!(!reloaded.enabled); +} + // ── save_workflow: enforcing binding-resolvability gate ───────────────────── /// The proven live-failure shape (same as From 2dcaaba2936910f46276af06985ebbe05f751673 Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:48:21 +0530 Subject: [PATCH 11/18] feat(core): compile-time media feature gate (media_generation + image) (#4804) (#4840) Co-authored-by: M3gA-Mind --- .github/workflows/ci-lite.yml | 8 +++-- AGENTS.md | 3 ++ Cargo.toml | 14 +++++++- app/src-tauri/Cargo.toml | 12 ++++++- src/openhuman/mod.rs | 2 ++ src/openhuman/tools/ops.rs | 3 ++ src/openhuman/tools/ops_tests.rs | 34 +++++++++++++++++++ .../agent_session_round24_raw_coverage_e2e.rs | 27 ++++++--------- 8 files changed, 82 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index 870276ae6d..f807ef536c 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -354,8 +354,10 @@ jobs: # Feature-gate smoke: proves the core still compiles with a domain gate turned # OFF. The disabled build is the ONLY thing that catches stub-facade signature # drift (see AGENTS.md "Compile-time domain gates"), so it must run in CI, not - # just locally. Pathfinder lane for #4803 (voice); extend the --features list - # as sibling gates (#4797–#4802, #4804) land. + # just locally. Pathfinder lane for #4803 (voice); the explicit --features + # list (only tokenjuice-treesitter) turns every default gate OFF, so it also + # covers #4804 (media) and each sibling gate as it lands — no edit needed + # unless a new gate must stay ON here. rust-feature-gate-smoke: name: Rust Feature-Gate Smoke (gates off) needs: [changes] @@ -382,7 +384,7 @@ jobs: cache-on-failure: true shared-key: pr-rust-feature-gate-smoke - - name: Check core builds with the voice gate disabled + - name: Check core builds with the voice + media gates disabled run: bash scripts/ci-cancel-aware.sh cargo check --manifest-path Cargo.toml --no-default-features --features tokenjuice-treesitter rust-core-coverage: diff --git a/AGENTS.md b/AGENTS.md index 03ecc7e77c..b279ed219f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -233,11 +233,14 @@ GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml \ | Feature | Default | Gates | Drops deps | | ------- | ------- | ----- | ---------- | | `voice` | ON | `openhuman::voice` + `openhuman::audio_toolkit` domains — STT/TTS providers, dictation server, always-on listening, podcast audio + email | `hound`, `lettre` | +| `media` | ON | `openhuman::media_generation` (the `media_generate_*` agent tools) + `openhuman::image` scaffold | none (surface-only) | **Facade pattern (pathfinder for the other gates).** `pub mod voice;` is **always compiled** as a facade: the real submodules are `#[cfg(feature = "voice")]`, and a `#[cfg(not(feature = "voice"))] mod stub;` (`src/openhuman/voice/stub.rs`) re-exposes the same public surface that always-on / other-gated callers use (`server`, `dictation_listener`, `streaming`, `reply_speech`, `cloud_transcribe`, `cli`, `create_stt_provider`, `effective_stt_provider`, `publish_ptt_transcript_committed`) with no-op / `None` / disabled-error bodies. Callers therefore do **not** need per-call `#[cfg]`. When voice is off: the voice/audio controllers are unregistered (unknown-method over `/rpc`, absent from `/schema`), the `audio_generate_podcast` agent tools are absent, and `openhuman voice` returns a "voice disabled" error. Stub signatures must match the real ones exactly — the disabled build (`--no-default-features --features tokenjuice-treesitter`) is the **only** thing that catches drift, so run it before pushing any change to the voice surface. **Scope note:** the `voice` gate does **not** drop `whisper-rs` / `llama` / `cpal`. Those live in the inference domain (`src/openhuman/inference/local/service/whisper_engine.rs`; `cpal` is shared with accessibility) and await a separate future `inference` gate. The issue-level DoD line claiming whisper is dropped is superseded by this scope correction. +**Leaf-gate variant (`media`, #4804).** Unlike `voice`, the `media` gate needs **no** stub facade: `media_generation` has a single caller (the `build_media_tools` call in `src/openhuman/tools/ops.rs`, itself `#[cfg(feature = "media")]`) and `openhuman::image` is unwired scaffold (#2997), so both modules are simply `#[cfg(feature = "media")] pub mod …`. It is a **surface-only** gate: media generation is backend-proxied (`reqwest`, shared) and the `image` crate is shared with channel upload, so no exclusive deps are shed — the issue's "sheds media processing dependencies" / "controllers unregistered" DoD lines are superseded (Media is agent-tools-only; no controller/store/subscriber is tagged `Media`). When a gated domain is a true leaf, prefer this over the facade+stub. + ### Event bus (`src/core/event_bus/`) Typed pub/sub + native request/response. Both singletons — use module-level functions. diff --git a/Cargo.toml b/Cargo.toml index 2b6bff7f03..cb12f559c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -334,7 +334,7 @@ tokio = { version = "1", features = ["test-util"] } proptest = "1" [features] -default = ["tokenjuice-treesitter", "voice"] +default = ["tokenjuice-treesitter", "voice", "media"] # AST-aware code compression (tree-sitter Rust/TS/Python grammars; C build). # On by default; disable to fall back to the brace-depth heuristic. tokenjuice-treesitter = [ @@ -351,6 +351,18 @@ tokenjuice-treesitter = [ # inference domain (shared with accessibility for cpal) and await a separate # `inference` gate. voice = ["dep:hound", "dep:lettre"] +# Media-generation + image domains: the `media_generate_*` agent tools +# (image/video via GMI through the backend) and the `openhuman::image` tool +# contracts scaffold. Default-ON. Slim builds opt out via +# `--no-default-features --features ""`. +# Composes with the runtime `DomainSet::media` flag (#4796). +# NOTE: this gate sheds NO exclusive dependencies — media generation is +# backend-proxied (reqwest, shared) and the `image` crate is shared with +# channel media upload. It is a surface-only gate (drops the tool code + +# module from the compile), not a dependency-shedding one. There are no +# controllers / stores / subscribers tagged `Media` (agent tools only), and +# `openhuman::image` is currently unwired scaffold (added #2997). +media = [] sandbox-landlock = ["dep:landlock"] sandbox-bubblewrap = [] peripheral-rpi = ["dep:rppal"] diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 177210c7e1..4c6bb5c6ef 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -132,7 +132,17 @@ cef = { version = "=146.4.1", default-features = false } # by tying the core's lifetime to the GUI process. The existing port-7788 # probe in `core_process::ensure_running` still attaches to a running # `openhuman-core run` harness when one is already listening. -openhuman_core = { path = "../..", package = "openhuman", default-features = false } +# +# `default-features = false` (set in #1061, before the compile-time domain +# gates existed) means the embedded core does NOT inherit the root crate's +# default gate set, so each default-ON gate must be forwarded explicitly to +# keep the shipped desktop build byte-identical (AGENTS.md "Compile-time +# domain gates"). `media` re-registers the `media_generate_*` agent tools +# that #4804 moved behind `#[cfg(feature = "media")]`; it sheds no deps, so +# this only restores the pre-gate desktop tool surface. +openhuman_core = { path = "../..", package = "openhuman", default-features = false, features = [ + "media", +] } tinyjuice = { version = "0.2.1", default-features = false } [target.'cfg(unix)'.dependencies] diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 74f8442046..601a569cfc 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -57,6 +57,7 @@ pub mod harness_init; pub mod health; pub mod heartbeat; pub mod http_host; +#[cfg(feature = "media")] pub mod image; pub mod inference; pub mod integrations; @@ -68,6 +69,7 @@ pub mod mcp_audit; pub mod mcp_client; pub mod mcp_registry; pub mod mcp_server; +#[cfg(feature = "media")] pub mod media_generation; pub mod meet; pub mod meet_agent; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 95fb2736bb..d9203d79b5 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -875,6 +875,9 @@ pub fn all_tools_with_runtime( // Media generation (image/video via GMI through the backend). Skipped when // no integration client is configured; artifacts land under `action_dir`. + // Gated by the `media` compile-time feature (#4804); absent from slim + // builds. Runtime `DomainSet::media` (#4796) still gates it when compiled. + #[cfg(feature = "media")] tools.extend(crate::openhuman::media_generation::build_media_tools( root_config, action_dir, diff --git a/src/openhuman/tools/ops_tests.rs b/src/openhuman/tools/ops_tests.rs index 3e1e0d5b7e..9ddb2cfe28 100644 --- a/src/openhuman/tools/ops_tests.rs +++ b/src/openhuman/tools/ops_tests.rs @@ -260,6 +260,40 @@ fn all_tools_always_registers_curl() { ); } +// Compile-time `media` feature gate (#4804). The media-generation agent tools +// (`media_generate_*`) are present only when the `media` feature is compiled +// in AND an integration client is configured. The disabled build proves the +// module + its single call site drop out entirely (leaf gate, no stub facade). +#[cfg(feature = "media")] +#[test] +fn media_tools_registered_when_feature_on() { + let tmp = TempDir::new().unwrap(); + let cfg = integration_test_config(&tmp, "http://127.0.0.1:1"); + store_test_session_token(&cfg); + let tools = integration_tools_for_config(&tmp, &cfg); + let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); + assert!( + names.contains(&"media_generate_image"), + "media tools must register with the `media` feature on + an integration \ + client; got: {names:?}" + ); +} + +#[cfg(not(feature = "media"))] +#[test] +fn media_tools_absent_when_feature_off() { + let tmp = TempDir::new().unwrap(); + let cfg = integration_test_config(&tmp, "http://127.0.0.1:1"); + store_test_session_token(&cfg); + let tools = integration_tools_for_config(&tmp, &cfg); + let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); + assert!( + !names.iter().any(|n| n.starts_with("media_")), + "no `media_*` tools may be registered when the `media` feature is off; \ + got: {names:?}" + ); +} + #[test] fn all_tools_registers_gitbooks_when_enabled() { let tmp = TempDir::new().unwrap(); diff --git a/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs b/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs index 6865e8da0c..0e0d455b69 100644 --- a/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs +++ b/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs @@ -411,17 +411,13 @@ async fn max_iteration_checkpoint_uses_deterministic_fallback_and_hooks() { let calls = Arc::new(AtomicUsize::new(0)); let hook_calls = Arc::new(AtomicUsize::new(0)); let hook_contexts = Arc::new(Mutex::new(Vec::new())); + // The wrap-up model call yields nothing (empty text + no streamed deltas), so + // `summarize_turn_wrapup` returns an empty summary and the cap path falls back + // to `build_deterministic_checkpoint` — the exact path this test covers. (A + // non-empty wrap-up would instead be surfaced verbatim as the answer.) let provider = ScriptedProvider::with_stream( - vec![ - xml_tool_response("alpha"), - text_response( - "{\"name\":\"round24_echo\",\"arguments\":{\"value\":\"again\"}}", - None, - ), - ], - vec![ProviderDelta::TextDelta { - delta: "checkpoint delta".to_string(), - }], + vec![xml_tool_response("alpha"), text_response("", None)], + vec![], ); let mut agent = Agent::builder() @@ -454,7 +450,7 @@ async fn max_iteration_checkpoint_uses_deterministic_fallback_and_hooks() { let answer = agent.turn("hit the cap").await.unwrap(); assert!(answer.contains("I reached the tool-call limit for this turn (1 steps)")); - // The unified TurnEngine digest uses `- round24_echo [ok]: ...` format (no backticks). + // The deterministic checkpoint lists each executed tool, e.g. ``- `round24_echo` — ok``. assert!(answer.contains("round24_echo")); assert_eq!(calls.load(Ordering::SeqCst), 1); wait_for_hook_calls(&hook_calls, 1).await; @@ -480,12 +476,11 @@ async fn max_iteration_checkpoint_uses_deterministic_fallback_and_hooks() { while let Ok(event) = progress_rx.try_recv() { streamed.push(event); } - assert!(streamed.iter().any(|event| matches!( + // The wrap-up produced no text, so no iteration-2 wrap-up delta is streamed; + // the deterministic fallback (asserted above) becomes the answer instead. + assert!(!streamed.iter().any(|event| matches!( event, - openhuman_core::openhuman::agent::progress::AgentProgress::TextDelta { - delta, - iteration: 2 - } if delta == "checkpoint delta" + openhuman_core::openhuman::agent::progress::AgentProgress::TextDelta { iteration: 2, .. } ))); } From eae6f9dc4e64971959161a7351d2422944b4e004 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:08:16 +0530 Subject: [PATCH 12/18] fix(flows): canvas Accept persists the proposal (accept = review + save) (#4893) --- .../flows/WorkflowCopilotPanel.test.tsx | 93 +++++++- .../components/flows/WorkflowCopilotPanel.tsx | 59 ++++- app/src/lib/i18n/ar.ts | 2 + app/src/lib/i18n/bn.ts | 2 + app/src/lib/i18n/de.ts | 2 + app/src/lib/i18n/en.ts | 2 + app/src/lib/i18n/es.ts | 2 + app/src/lib/i18n/fr.ts | 2 + app/src/lib/i18n/hi.ts | 2 + app/src/lib/i18n/id.ts | 2 + app/src/lib/i18n/it.ts | 2 + app/src/lib/i18n/ko.ts | 2 + app/src/lib/i18n/pl.ts | 2 + app/src/lib/i18n/pt.ts | 2 + app/src/lib/i18n/ru.ts | 4 +- app/src/lib/i18n/zh-CN.ts | 2 + app/src/pages/FlowCanvasPage.tsx | 198 ++++++++++------ .../pages/__tests__/FlowCanvasPage.test.tsx | 221 ++++++++++++------ 18 files changed, 445 insertions(+), 156 deletions(-) diff --git a/app/src/components/flows/WorkflowCopilotPanel.test.tsx b/app/src/components/flows/WorkflowCopilotPanel.test.tsx index 0fe3fdea73..d46ae467ef 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.test.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.test.tsx @@ -301,8 +301,8 @@ describe('WorkflowCopilotPanel', () => { expect(screen.getByTestId('workflow-copilot-removed')).toBeInTheDocument(); }); - it('Accept applies to the draft and clears the proposal (never persists)', () => { - const onAccept = vi.fn(); + it('Accept calls onAccept (host applies + saves) and clears the proposal once it resolves', async () => { + const onAccept = vi.fn().mockResolvedValue(undefined); hookState.proposal = proposalWith(['a', 'c']); render( { ); fireEvent.click(screen.getByTestId('workflow-copilot-accept')); expect(onAccept).toHaveBeenCalledWith(hookState.proposal); - expect(hookState.clearProposal).toHaveBeenCalledTimes(1); + await waitFor(() => expect(hookState.clearProposal).toHaveBeenCalledTimes(1)); + }); + + it('shows the saving label and disables Accept while the host save is in flight', async () => { + // Deferred promise so the test controls exactly when the host's save + // (`onAccept`) resolves, to observe the in-between "saving" state. + let resolveSave!: () => void; + const savePromise = new Promise(resolve => { + resolveSave = resolve; + }); + const onAccept = vi.fn().mockReturnValue(savePromise); + hookState.proposal = proposalWith(['a', 'c']); + render( + + ); + + fireEvent.click(screen.getByTestId('workflow-copilot-accept')); + await waitFor(() => + expect(screen.getByTestId('workflow-copilot-accept')).toHaveTextContent( + 'flows.copilot.saving' + ) + ); + expect(screen.getByTestId('workflow-copilot-accept')).toBeDisabled(); + expect(hookState.clearProposal).not.toHaveBeenCalled(); + + resolveSave(); + await waitFor(() => expect(hookState.clearProposal).toHaveBeenCalledTimes(1)); + }); + + it('leaves the proposal visible for retry when the host save rejects', async () => { + const onAccept = vi.fn().mockRejectedValue(new Error('save failed')); + hookState.proposal = proposalWith(['a', 'c']); + render( + + ); + + fireEvent.click(screen.getByTestId('workflow-copilot-accept')); + await waitFor(() => expect(onAccept).toHaveBeenCalledTimes(1)); + // The button re-enables once the rejected save settles, and the proposal + // was never cleared — the card stays up so the user can retry. + await waitFor(() => expect(screen.getByTestId('workflow-copilot-accept')).not.toBeDisabled()); + expect(hookState.clearProposal).not.toHaveBeenCalled(); + }); + + it('disables Reject while an Accept save is in flight, so it cannot race the persisted save', async () => { + // Regression for the CodeRabbit finding: Reject must not stay clickable + // while `onAccept`'s save is still pending, otherwise the user's cancel + // can be silently overridden by the earlier Accept's save landing after. + let resolveSave!: () => void; + const savePromise = new Promise(resolve => { + resolveSave = resolve; + }); + const onAccept = vi.fn().mockReturnValue(savePromise); + const onReject = vi.fn(); + hookState.proposal = proposalWith(['a', 'c']); + render( + + ); + + fireEvent.click(screen.getByTestId('workflow-copilot-accept')); + await waitFor(() => expect(screen.getByTestId('workflow-copilot-reject')).toBeDisabled()); + + // A click while disabled is a no-op in jsdom/RTL — Reject must not fire. + fireEvent.click(screen.getByTestId('workflow-copilot-reject')); + expect(onReject).not.toHaveBeenCalled(); + expect(hookState.clearProposal).not.toHaveBeenCalled(); + + resolveSave(); + await waitFor(() => expect(hookState.clearProposal).toHaveBeenCalledTimes(1)); + expect(screen.getByTestId('workflow-copilot-reject')).not.toBeDisabled(); }); it('Reject discards the proposal without applying it', () => { diff --git a/app/src/components/flows/WorkflowCopilotPanel.tsx b/app/src/components/flows/WorkflowCopilotPanel.tsx index d897fcedcb..582f3ea7cd 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.tsx @@ -16,8 +16,13 @@ * `streamingAssistantByThread`, streamed here by Phase B). So the copilot reads * like a real chat rather than a one-shot form. * - * Invariant: the copilot only PROPOSES. Accept applies to the UNSAVED local - * draft (no `flows_update`); persistence stays behind the canvas's own Save. + * Invariant: the copilot only PROPOSES — the agent turn itself never + * persists. Accept applies the proposal to the local draft AND immediately + * saves it (review + save in one click) via the host's `onAccept`, which + * awaits the host's own persistence call; the panel shows a saving state + * meanwhile and, if the save fails, leaves the proposal visible for retry + * rather than silently discarding it. Reject remains local-only (revert the + * overlay, no persistence call). */ import createDebug from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -65,8 +70,13 @@ interface Props { * reflects it. */ onProposal: (proposal: WorkflowProposal) => void; - /** Accept the pending proposal into the local draft (host commits it). */ - onAccept: (proposal: WorkflowProposal) => void; + /** + * Accept the pending proposal (host applies it to the local draft AND + * persists it — "accept" is now review + save in one step). May return a + * promise the panel awaits to show a saving state; a rejected promise + * leaves the proposal visible so the user can retry. + */ + onAccept: (proposal: WorkflowProposal) => void | Promise; /** Reject the pending proposal (host reverts the overlay). */ onReject: () => void; /** Close the panel. */ @@ -376,12 +386,35 @@ export default function WorkflowCopilotPanel({ const noopAttach = useCallback(async () => {}, []); const noop = useCallback(() => {}, []); - const accept = useCallback(() => { - if (!proposal) return; - onAccept(proposal); - clearProposal(); - lastSurfacedRef.current = null; - }, [proposal, onAccept, clearProposal]); + // Accept now review-and-saves: `onAccept` (the host's `handleAcceptProposal`) + // applies the proposal to the draft AND persists it. Track a local + // `acceptSaving` flag so the button can show a saving state and disable + // re-clicks while that's in flight. If the host's save throws, leave the + // proposal card visible (don't `clearProposal()`) so the user can retry — + // otherwise a failed autosave would silently vanish the only affordance to + // try again from the copilot itself (the header Save button is a fallback, + // but this keeps the copilot's own flow self-contained). + const [acceptSaving, setAcceptSaving] = useState(false); + const accept = useCallback(async () => { + // Self-guard against re-entrance: the JSX `disabled={acceptSaving}` on + // the Accept button prevents a normal double-click, but `acceptSaving` + // only flips after the FIRST call's `setAcceptSaving(true)` commits — a + // second invocation racing ahead of that render (e.g. programmatic + // re-fire) must not start a second concurrent save. + if (!proposal || acceptSaving) return; + setAcceptSaving(true); + log('accept: saving proposal via host onAccept'); + try { + await onAccept(proposal); + log('accept: save succeeded, clearing proposal'); + clearProposal(); + lastSurfacedRef.current = null; + } catch (err) { + log('accept: save failed, leaving proposal visible for retry err=%o', err); + } finally { + setAcceptSaving(false); + } + }, [proposal, acceptSaving, onAccept, clearProposal]); const reject = useCallback(() => { onReject(); @@ -533,14 +566,16 @@ export default function WorkflowCopilotPanel({ type="button" variant="primary" size="sm" + disabled={acceptSaving} data-testid="workflow-copilot-accept" - onClick={accept}> - {t('flows.copilot.accept')} + onClick={() => void accept()}> + {acceptSaving ? t('flows.copilot.saving') : t('flows.copilot.acceptAndSave')} + ); +} + /** * The root-shell sidebar, split top-to-bottom into: * @@ -33,18 +81,48 @@ export default function AppSidebar() { const { t } = useT(); const location = useLocation(); const navigate = useNavigate(); + const { snapshot: coreSnapshot, isReady } = useCoreState(); + // Rewards is a cloud-only surface (credits/referrals/coupons live behind the + // backend rewards API); the page itself renders an "unavailable" state for + // local sessions, so there's no point offering the entry there. Mirrors the + // `cloudOnly` intent recorded for rewards in navConfig's AVATAR_MENU_ITEMS. + // + // Show it only once core state has bootstrapped to a real, non-local session. + // The initial snapshot is `{ isReady: false, sessionToken: null }`, and + // `isLocalSessionToken(null)` is `false`, so gating on the token alone would + // briefly flash Rewards for a local session until the first refresh resolves. + const showRewards = + isReady && + Boolean(coreSnapshot.sessionToken) && + !isLocalSessionToken(coreSnapshot.sessionToken); const feedbackActive = location.pathname === '/feedback'; + const rewardsActive = location.pathname === '/rewards'; + + // Log the gate outcome whenever it resolves/flips. Booleans only — never the + // session token or a raw path. + useEffect(() => { + log( + 'rewards footer entry visibility resolved: visible=%s isReady=%s hasSession=%s local=%s', + showRewards, + isReady, + Boolean(coreSnapshot.sessionToken), + isLocalSessionToken(coreSnapshot.sessionToken) + ); + }, [showRewards, isReady, coreSnapshot.sessionToken]); - const handleFeedbackClick = () => { - if (!feedbackActive) { + const handleFooterNav = (tab: string, path: string, active: boolean) => { + log('footer nav click: tab=%s active=%s', tab, active); + if (!active) { trackEvent('tab_bar_change', { from_tab: 'unknown', - to_tab: 'feedback', - from_path: location.pathname, - to_path: '/feedback', + to_tab: tab, + // Normalize to a route template so route-scoped entity IDs (thread, + // flow, team, …) never leave the app via analytics. + from_path: normalizeAnalyticsPagePath(location.pathname), + to_path: path, }); } - navigate('/feedback'); + navigate(path); }; return ( @@ -66,23 +144,24 @@ export default function AppSidebar() { app rail above its thread list) can order them via Tailwind `order-*`. */}
- {/* Slim feedback row — pinned just above the status bar. Kept thin and - low-profile so it reads as a footer affordance, not a primary nav tab - (it used to live in SidebarNav). */} - + {/* Slim account affordances pinned above the status bar — Rewards then + Feedback. Rewards is shown only for a resolved cloud session. */} + {showRewards && ( + handleFooterNav('rewards', '/rewards', rewardsActive)} + /> + )} + handleFooterNav('feedback', '/feedback', feedbackActive)} + /> {/* App-wide footer: connectivity status + build/version, pinned to the bottom of the sidebar. */}
diff --git a/app/src/components/layout/shell/navIcons.tsx b/app/src/components/layout/shell/navIcons.tsx index dc785dae60..fefd6299c9 100644 --- a/app/src/components/layout/shell/navIcons.tsx +++ b/app/src/components/layout/shell/navIcons.tsx @@ -193,6 +193,19 @@ export function NavIcon({ id, className = 'w-5 h-5' }: NavIconProps) { /> ); + case 'rewards': + // Gift box — mirrors the glyph the Rewards page uses for its own header + // and welcome icon, so the sidebar entry reads as the same destination. + return ( + + + + ); default: return null; } From 1feb87317d06709f47a2dc8a25ac86d0fd76abf7 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:24:17 +0530 Subject: [PATCH 15/18] fix(flows): show "awaiting approval" state in runs lists when a run is halted at an approval gate (#4932) --- .../components/flows/FlowRunsDrawer.test.tsx | 39 ++++ app/src/components/flows/FlowRunsDrawer.tsx | 12 +- .../components/flows/FlowRunsSidebar.test.tsx | 33 +++ app/src/components/flows/FlowRunsSidebar.tsx | 54 +++-- .../useRunsPendingApprovalSet.test.ts | 214 ++++++++++++++++++ app/src/hooks/useRunsPendingApprovalSet.ts | 118 ++++++++++ app/src/pages/WorkflowRunsPage.test.tsx | 42 ++++ app/src/pages/WorkflowRunsPage.tsx | 58 +++-- 8 files changed, 519 insertions(+), 51 deletions(-) create mode 100644 app/src/hooks/__tests__/useRunsPendingApprovalSet.test.ts create mode 100644 app/src/hooks/useRunsPendingApprovalSet.ts diff --git a/app/src/components/flows/FlowRunsDrawer.test.tsx b/app/src/components/flows/FlowRunsDrawer.test.tsx index 43cf8096a4..d762ff6806 100644 --- a/app/src/components/flows/FlowRunsDrawer.test.tsx +++ b/app/src/components/flows/FlowRunsDrawer.test.tsx @@ -23,6 +23,9 @@ import { FlowRunsDrawer } from './FlowRunsDrawer'; const listFlowRuns = vi.hoisted(() => vi.fn()); vi.mock('../../services/api/flowsApi', () => ({ listFlowRuns })); +const fetchPendingApprovals = vi.hoisted(() => vi.fn()); +vi.mock('../../services/api/approvalApi', () => ({ fetchPendingApprovals })); + const FlowRunInspectorDrawer = vi.hoisted(() => vi.fn()); vi.mock('./FlowRunInspectorDrawer', () => ({ FLOW_RUN_STATUS_ACCENT: { @@ -87,6 +90,7 @@ function renderDrawer(flowId: string | null, onClose: () => void, flowName?: str describe('FlowRunsDrawer', () => { beforeEach(() => { vi.clearAllMocks(); + fetchPendingApprovals.mockResolvedValue([]); }); it('renders null when flowId is null', () => { @@ -123,6 +127,41 @@ describe('FlowRunsDrawer', () => { expect(row).toHaveTextContent('Completed with warnings'); }); + it('shows "Awaiting approval" for a running run halted at a matching flow approval gate', async () => { + listFlowRuns.mockResolvedValue([makeRun({ id: 'run-1', status: 'running' })]); + fetchPendingApprovals.mockResolvedValue([ + { + request_id: 'req-1', + tool_name: 'SLACK_SEND_MESSAGE', + action_summary: 'Send Slack message', + args_redacted: {}, + session_id: 'session-1', + created_at: '2026-01-01T00:00:00Z', + expires_at: null, + source_context: { kind: 'flow', flow_id: 'flow-1', run_id: 'run-1' }, + }, + ]); + renderDrawer('flow-1', vi.fn()); + + const row = await screen.findByTestId('flow-run-row-run-1'); + await waitFor(() => expect(row).toHaveTextContent('Awaiting approval')); + expect(screen.getByTestId('flow-run-row-dot-run-1').className.includes('dot-pending')).toBe( + true + ); + }); + + it('leaves a running run without a matching flow approval labeled "Running"', async () => { + listFlowRuns.mockResolvedValue([makeRun({ id: 'run-1', status: 'running' })]); + fetchPendingApprovals.mockResolvedValue([]); + renderDrawer('flow-1', vi.fn()); + + const row = await screen.findByTestId('flow-run-row-run-1'); + await waitFor(() => expect(row).toHaveTextContent('Running')); + expect(screen.getByTestId('flow-run-row-dot-run-1').className.includes('dot-running')).toBe( + true + ); + }); + it('falls back to a generic title when no flowName is given', async () => { listFlowRuns.mockResolvedValue([]); renderDrawer('flow-1', vi.fn()); diff --git a/app/src/components/flows/FlowRunsDrawer.tsx b/app/src/components/flows/FlowRunsDrawer.tsx index 8f44848162..368c73b779 100644 --- a/app/src/components/flows/FlowRunsDrawer.tsx +++ b/app/src/components/flows/FlowRunsDrawer.tsx @@ -29,6 +29,10 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useEscapeKey } from '../../hooks/useEscapeKey'; import { useFlowRunsLiveRefresh } from '../../hooks/useFlowRunsLiveRefresh'; +import { + resolveDisplayStatus, + useRunsPendingApprovalSet, +} from '../../hooks/useRunsPendingApprovalSet'; import { useT } from '../../lib/i18n/I18nContext'; import { type FlowRun, listFlowRuns } from '../../services/api/flowsApi'; import { @@ -142,6 +146,7 @@ export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Pr }, [flowId]); useFlowRunsLiveRefresh(runs, refetch); + const pendingRunIds = useRunsPendingApprovalSet(runs); useEscapeKey( () => { @@ -213,6 +218,7 @@ export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Pr
    {runs.map(run => { const startedAt = formatTimestamp(run.started_at); + const displayStatus = resolveDisplayStatus(run, pendingRunIds); return (
  • -
  • - ))} + + + ); + })}
diff --git a/app/src/hooks/__tests__/useRunsPendingApprovalSet.test.ts b/app/src/hooks/__tests__/useRunsPendingApprovalSet.test.ts new file mode 100644 index 0000000000..80abba31e9 --- /dev/null +++ b/app/src/hooks/__tests__/useRunsPendingApprovalSet.test.ts @@ -0,0 +1,214 @@ +/** + * useRunsPendingApprovalSet — unit tests. + * + * Verifies: no poll when every run is terminal (or the list is empty); polls + * `approval_list_pending` every 3s while a run is `running`; collects only + * flow-origin (`source_context.kind === 'flow'`) matches into the returned + * Set; a chat-origin approval (no flow `source_context`) is excluded; a + * failed poll keeps the last-known set instead of clearing it; teardown once + * every run settles to terminal; cleanup on unmount. + */ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { PendingApproval } from '../../services/api/approvalApi'; +import type { FlowRun } from '../../services/api/flowsApi'; +import { resolveDisplayStatus, useRunsPendingApprovalSet } from '../useRunsPendingApprovalSet'; + +const fetchPendingApprovals = vi.hoisted(() => vi.fn()); +vi.mock('../../services/api/approvalApi', () => ({ fetchPendingApprovals })); + +function makeRun(overrides: Partial = {}): FlowRun { + return { + id: 'run-1', + flow_id: 'flow-1', + thread_id: 'run-1', + status: 'running', + started_at: '2026-01-01T00:00:00Z', + steps: [], + pending_approvals: [], + ...overrides, + }; +} + +function makeApproval(overrides: Partial = {}): PendingApproval { + return { + request_id: 'req-1', + tool_name: 'shell', + action_summary: 'Run `shell`', + args_redacted: {}, + session_id: 'session-1', + created_at: '2026-01-01T00:00:00Z', + expires_at: null, + source_context: { kind: 'flow', flow_id: 'flow-1', run_id: 'run-1' }, + ...overrides, + }; +} + +describe('useRunsPendingApprovalSet', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not poll when every run is terminal', async () => { + fetchPendingApprovals.mockResolvedValue([]); + renderHook(() => + useRunsPendingApprovalSet([ + makeRun({ status: 'completed' }), + makeRun({ id: 'run-2', status: 'failed' }), + ]) + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(20_000); + }); + expect(fetchPendingApprovals).not.toHaveBeenCalled(); + }); + + it('does not poll for an empty runs list', async () => { + fetchPendingApprovals.mockResolvedValue([]); + renderHook(() => useRunsPendingApprovalSet([])); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(fetchPendingApprovals).not.toHaveBeenCalled(); + }); + + it('polls every 3s while a run is running and includes it when a matching flow approval exists', async () => { + fetchPendingApprovals.mockResolvedValue([makeApproval({ request_id: 'req-a' })]); + const { result } = renderHook(() => + useRunsPendingApprovalSet([makeRun({ status: 'running' })]) + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(fetchPendingApprovals).toHaveBeenCalledTimes(1); + expect(result.current.has('run-1')).toBe(true); + + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(fetchPendingApprovals).toHaveBeenCalledTimes(2); + }); + + it('excludes a running run with no matching pending approval', async () => { + fetchPendingApprovals.mockResolvedValue([]); + const { result } = renderHook(() => + useRunsPendingApprovalSet([makeRun({ status: 'running' })]) + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.size).toBe(0); + }); + + it('excludes a chat-origin approval (no flow source_context)', async () => { + fetchPendingApprovals.mockResolvedValue([ + makeApproval({ request_id: 'req-chat', source_context: undefined }), + ]); + const { result } = renderHook(() => + useRunsPendingApprovalSet([makeRun({ status: 'running' })]) + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.size).toBe(0); + }); + + it('keeps the last-known set when a poll fails', async () => { + fetchPendingApprovals.mockResolvedValueOnce([makeApproval({ request_id: 'req-a' })]); + const { result, rerender } = renderHook( + ({ runs }: { runs: FlowRun[] }) => useRunsPendingApprovalSet(runs), + { initialProps: { runs: [makeRun({ status: 'running' })] } } + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.has('run-1')).toBe(true); + + fetchPendingApprovals.mockRejectedValueOnce(new Error('network down')); + rerender({ runs: [makeRun({ status: 'running' })] }); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(fetchPendingApprovals).toHaveBeenCalledTimes(2); + expect(result.current.has('run-1')).toBe(true); + + // A subsequent successful tick keeps polling on the same cadence. + fetchPendingApprovals.mockResolvedValueOnce([makeApproval({ request_id: 'req-a' })]); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(fetchPendingApprovals).toHaveBeenCalledTimes(3); + }); + + it('tears down polling once every run settles to terminal', async () => { + fetchPendingApprovals.mockResolvedValue([makeApproval({ request_id: 'req-a' })]); + const { rerender } = renderHook( + ({ runs }: { runs: FlowRun[] }) => useRunsPendingApprovalSet(runs), + { initialProps: { runs: [makeRun({ status: 'running' })] } } + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(fetchPendingApprovals).toHaveBeenCalledTimes(1); + + rerender({ runs: [makeRun({ status: 'completed' })] }); + + fetchPendingApprovals.mockClear(); + await act(async () => { + await vi.advanceTimersByTimeAsync(20_000); + }); + expect(fetchPendingApprovals).not.toHaveBeenCalled(); + }); + + it('cleans up pending timers on unmount', async () => { + fetchPendingApprovals.mockResolvedValue([]); + const { unmount } = renderHook(() => + useRunsPendingApprovalSet([makeRun({ status: 'running' })]) + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(fetchPendingApprovals).toHaveBeenCalledTimes(1); + + unmount(); + + fetchPendingApprovals.mockClear(); + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(fetchPendingApprovals).not.toHaveBeenCalled(); + }); +}); + +describe('resolveDisplayStatus', () => { + it('overrides to pending_approval when running and the run id is in the pending set', () => { + const run = makeRun({ status: 'running' }); + const pendingRunIds = new Set(['run-1']); + expect(resolveDisplayStatus(run, pendingRunIds)).toBe('pending_approval'); + }); + + it('leaves the status untouched when running but not in the pending set', () => { + const run = makeRun({ status: 'running' }); + expect(resolveDisplayStatus(run, new Set())).toBe('running'); + }); + + it('leaves non-running statuses untouched even if the id is (stale) in the pending set', () => { + const run = makeRun({ status: 'completed' }); + const pendingRunIds = new Set(['run-1']); + expect(resolveDisplayStatus(run, pendingRunIds)).toBe('completed'); + }); +}); diff --git a/app/src/hooks/useRunsPendingApprovalSet.ts b/app/src/hooks/useRunsPendingApprovalSet.ts new file mode 100644 index 0000000000..0d185a3ae7 --- /dev/null +++ b/app/src/hooks/useRunsPendingApprovalSet.ts @@ -0,0 +1,118 @@ +/** + * useRunsPendingApprovalSet — cross-references the shared approval queue + * against a runs LIST so surfaces that show many runs at once (sidebar, + * drawer, all-runs page) can distinguish a run merely `running` from one + * that's actually halted at an interactive `ApprovalGate` mid-run. + * + * The core has no separate DB status for "parked at an approval gate" — a + * run stays `status: "running"` in `FlowRun` while it waits; only the shared + * `openhuman.approval_list_pending` queue (see `useFlowPendingApprovals`, + * the single-run analogue used by `FlowRunInspectorDrawer`) knows it's + * parked, via `PendingApproval.source_context = { kind: "flow", run_id }`. + * + * While at least one run in the list is `running`, this hook polls that + * shared queue every {@link POLL_INTERVAL_MS} and returns the Set of + * `run_id`s with a matching flow-origin pending approval. Callers combine + * this with `run.status` via {@link resolveDisplayStatus} to override the + * DISPLAY status only — `run.status` itself, and `useFlowRunsLiveRefresh` + * (which reads the ORIGINAL runs array), are untouched, so `running` stays + * non-terminal there and the list keeps refetching until the run truly + * finishes. + * + * Mirrors the poll-loop + cleanup shape of `useFlowPendingApprovals` + * (cancelled flag + `setTimeout` reschedule + teardown) and the + * gate-on-activity contract of `useFlowRunsLiveRefresh`. Unlike + * `useFlowPendingApprovals`, a failed poll here does NOT surface an error or + * stop polling — this is a best-effort display hint, not a source of truth, + * so a transient failure just keeps showing the last-known set and retries + * on the next tick. + */ +import debug from 'debug'; +import { useEffect, useState } from 'react'; + +import { fetchPendingApprovals } from '../services/api/approvalApi'; +import type { FlowRun, FlowRunStatus } from '../services/api/flowsApi'; + +const log = debug('flows:runs-pending-approval-set'); + +/** How often to re-poll the shared approval queue while any run is `running`. */ +const POLL_INTERVAL_MS = 3000; + +/** + * Poll `openhuman.approval_list_pending` every {@link POLL_INTERVAL_MS}ms + * while `runs` contains at least one `status === 'running'` entry, and + * return the Set of `run_id`s with a flow-origin pending approval + * (`source_context.kind === 'flow'`). Stops polling — but does not clear the + * last-known set — once every run has left `running` (completed, failed, + * cancelled, or already reflected as `pending_approval`). Best-effort: a + * failed poll is logged and simply retried next tick, keeping the + * last-known set rather than surfacing an error to the caller. + */ +export function useRunsPendingApprovalSet(runs: FlowRun[]): Set { + const [pendingRunIds, setPendingRunIds] = useState>(() => new Set()); + + const hasRunning = runs.some(run => run.status === 'running'); + + useEffect(() => { + if (!hasRunning) { + log('approval polling skipped: no-running-runs'); + return; + } + log('approval polling started'); + + let cancelled = false; + let pollHandle: number | undefined; + + const tick = async () => { + if (cancelled) return; + try { + log('approval poll: calling fetchPendingApprovals'); + const all = await fetchPendingApprovals(); + if (cancelled) return; + const next = new Set(); + for (const approval of all) { + if (approval.source_context?.kind === 'flow') { + next.add(approval.source_context.run_id); + } + } + log('approval poll: succeeded total=%d flow-scoped=%d', all.length, next.size); + setPendingRunIds(next); + } catch (err) { + const errorType = err instanceof Error ? err.name : typeof err; + log('approval poll: failed error_type=%s; preserving-last-known-set', errorType); + // Best-effort — leave `pendingRunIds` as-is and retry next tick. + } finally { + if (!cancelled) { + log('approval poll: scheduling retry delay_ms=%d', POLL_INTERVAL_MS); + pollHandle = window.setTimeout(() => void tick(), POLL_INTERVAL_MS); + } + } + }; + + void tick(); + return () => { + cancelled = true; + if (pollHandle !== undefined) window.clearTimeout(pollHandle); + log('approval polling stopped'); + }; + }, [hasRunning]); + + return pendingRunIds; +} + +/** + * Overrides a run's DISPLAY status to `pending_approval` when it's currently + * `running` AND the shared approval queue (via {@link useRunsPendingApprovalSet}) + * has a flow-origin pending approval for it. Does not mutate `run` or touch + * any other status — callers substitute the result at render sites only + * (status dot / accent / label), leaving `run.status` itself (and anything + * keyed on it, like `useFlowRunsLiveRefresh`'s terminal-status check) intact. + */ +export function resolveDisplayStatus(run: FlowRun, pendingRunIds: Set): FlowRunStatus { + if (run.status === 'running' && pendingRunIds.has(run.id)) { + return 'pending_approval'; + } + return run.status; +} + +export default useRunsPendingApprovalSet; diff --git a/app/src/pages/WorkflowRunsPage.test.tsx b/app/src/pages/WorkflowRunsPage.test.tsx index d03d4e66bf..99ffd273a4 100644 --- a/app/src/pages/WorkflowRunsPage.test.tsx +++ b/app/src/pages/WorkflowRunsPage.test.tsx @@ -17,6 +17,9 @@ vi.mock('../services/api/flowsApi', () => ({ listFlows: (...a: unknown[]) => listFlows(...a), })); +const fetchPendingApprovals = vi.hoisted(() => vi.fn()); +vi.mock('../services/api/approvalApi', () => ({ fetchPendingApprovals })); + // PanelPage + LoadingState pull i18n/redux we don't need — stub to bare markup. vi.mock('../components/layout/PanelPage', () => ({ default: ({ children }: { children: React.ReactNode }) =>
{children}
, @@ -31,6 +34,8 @@ describe('WorkflowRunsPage', () => { navigateMock.mockReset(); listAllFlowRuns.mockReset(); listFlows.mockReset(); + fetchPendingApprovals.mockReset(); + fetchPendingApprovals.mockResolvedValue([]); }); it('renders aggregate runs with their workflow name and status', async () => { @@ -76,6 +81,43 @@ describe('WorkflowRunsPage', () => { await waitFor(() => expect(screen.getByText('rpc down')).toBeInTheDocument()); }); + it('shows "pending approval" for a running run halted at a matching flow approval gate', async () => { + listAllFlowRuns.mockResolvedValue([ + { id: 'r1', flow_id: 'f1', status: 'running', started_at: '2026-01-01T00:00:00Z' }, + ]); + listFlows.mockResolvedValue([{ id: 'f1', name: 'Daily digest' }]); + fetchPendingApprovals.mockResolvedValue([ + { + request_id: 'req-1', + tool_name: 'SLACK_SEND_MESSAGE', + action_summary: 'Send Slack message', + args_redacted: {}, + session_id: 'session-1', + created_at: '2026-01-01T00:00:00Z', + expires_at: null, + source_context: { kind: 'flow', flow_id: 'f1', run_id: 'r1' }, + }, + ]); + + render(); + + const row = await screen.findByTestId('workflow-run-r1'); + await waitFor(() => expect(row).toHaveTextContent('pending approval')); + }); + + it('leaves a running run without a matching flow approval labeled "running"', async () => { + listAllFlowRuns.mockResolvedValue([ + { id: 'r1', flow_id: 'f1', status: 'running', started_at: '2026-01-01T00:00:00Z' }, + ]); + listFlows.mockResolvedValue([{ id: 'f1', name: 'Daily digest' }]); + fetchPendingApprovals.mockResolvedValue([]); + + render(); + + const row = await screen.findByTestId('workflow-run-r1'); + await waitFor(() => expect(row).toHaveTextContent('running')); + }); + describe('live refresh (useFlowRunsLiveRefresh integration)', () => { afterEach(() => { vi.useRealTimers(); diff --git a/app/src/pages/WorkflowRunsPage.tsx b/app/src/pages/WorkflowRunsPage.tsx index 874dd0de37..756985400d 100644 --- a/app/src/pages/WorkflowRunsPage.tsx +++ b/app/src/pages/WorkflowRunsPage.tsx @@ -13,6 +13,10 @@ import { useNavigate } from 'react-router-dom'; import PanelPage from '../components/layout/PanelPage'; import { CenteredLoadingState, ErrorBanner } from '../components/ui/LoadingState'; import { useFlowRunsLiveRefresh } from '../hooks/useFlowRunsLiveRefresh'; +import { + resolveDisplayStatus, + useRunsPendingApprovalSet, +} from '../hooks/useRunsPendingApprovalSet'; import { useT } from '../lib/i18n/I18nContext'; import { type Flow, @@ -77,6 +81,7 @@ export default function WorkflowRunsPage() { }, []); useFlowRunsLiveRefresh(runs, refetchRuns); + const pendingRunIds = useRunsPendingApprovalSet(runs); const statusLabel = (status: FlowRunStatus) => t(`flows.allRuns.status.${status}`, status.replace(/_/g, ' ')); @@ -101,31 +106,34 @@ export default function WorkflowRunsPage() {
    - {runs.map(run => ( -
  • - - {run.error && ( -

    - {run.error} -

    - )} -
  • - ))} + {runs.map(run => { + const displayStatus = resolveDisplayStatus(run, pendingRunIds); + return ( +
  • + + {run.error && ( +

    + {run.error} +

    + )} +
  • + ); + })}
)}
From d320c4382373213429e183f6102fdebed323fa84 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:04:07 +0300 Subject: [PATCH 16/18] chore(rust): enforce warning-free clippy (#4872) --- .github/workflows/ci-lite.yml | 19 +- .husky/pre-push | 12 +- Cargo.toml | 13 + app/package.json | 2 +- app/src-tauri/Cargo.toml | 2 +- app/src-tauri/src/cdp/input.rs | 236 ------------- app/src-tauri/src/cdp/mod.rs | 20 +- app/src-tauri/src/cdp/session.rs | 4 +- app/src-tauri/src/cdp/snapshot.rs | 181 +++++----- app/src-tauri/src/cdp/target.rs | 6 - app/src-tauri/src/claude_code.rs | 2 +- app/src-tauri/src/deep_link_ipc.rs | 6 +- .../src/discord_scanner/mod_tests.rs | 2 - app/src-tauri/src/imessage_scanner/mod.rs | 7 - app/src-tauri/src/lib.rs | 10 +- .../src/meet_audio/caption_listener.rs | 6 +- .../src/meet_audio/listen_capture.rs | 327 ------------------ app/src-tauri/src/meet_audio/mod.rs | 32 +- app/src-tauri/src/meet_audio/speak_pump.rs | 9 +- app/src-tauri/src/meet_call/mod.rs | 4 +- app/src-tauri/src/meet_scanner/mod.rs | 2 +- app/src-tauri/src/meet_video/frame_bus.rs | 13 +- app/src-tauri/src/ptt_hotkeys.rs | 9 - app/src-tauri/src/ptt_overlay.rs | 10 +- app/src-tauri/src/screen_capture/mod.rs | 2 + app/src-tauri/src/slack_scanner/extract.rs | 59 ++-- app/src-tauri/src/slack_scanner/idb.rs | 2 +- app/src-tauri/src/telegram_scanner/extract.rs | 15 +- app/src-tauri/src/telegram_scanner/idb.rs | 2 +- .../src/wechat_scanner/dom_snapshot.rs | 2 - app/src-tauri/src/whatsapp_scanner/idb.rs | 2 +- app/src-tauri/src/whatsapp_scanner/mod.rs | 4 +- package.json | 1 + src/bin/memory_tree_init_smoke.rs | 6 +- src/bin/slack_backfill.rs | 43 ++- src/core/cli.rs | 12 - src/openhuman/accessibility/automate.rs | 2 +- src/openhuman/accessibility/mod.rs | 2 - .../accessibility/permissions_tests.rs | 3 +- src/openhuman/agent/harness/archivist/mod.rs | 2 - .../agent/harness/archivist/recap.rs | 1 + .../agent/harness/harness_gap_tests.rs | 3 +- .../agent/harness/session/builder/factory.rs | 2 +- .../agent/harness/session/transcript_tests.rs | 4 +- .../agent/harness/session/turn/session_io.rs | 60 +++- .../agent/harness/session/turn_tests.rs | 49 ++- .../agent/harness/subagent_runner/ops/mod.rs | 4 +- src/openhuman/agent/harness/tests.rs | 5 +- src/openhuman/agent/harness/tool_filter.rs | 2 +- src/openhuman/agent/progress_tracing.rs | 2 +- src/openhuman/agent/schemas.rs | 1 - src/openhuman/agent_meetings/summary.rs | 2 +- src/openhuman/agent_memory/tools.rs | 2 +- .../background_delivery.rs | 37 +- .../agent_orchestration/running_subagents.rs | 2 +- .../spawn_parallel_graph.rs | 2 +- src/openhuman/agent_registry/agents/loader.rs | 30 +- src/openhuman/agentbox/store_tests.rs | 2 +- src/openhuman/artifacts/store.rs | 2 +- src/openhuman/audio_toolkit/ops.rs | 2 +- src/openhuman/channels/proactive.rs | 7 +- src/openhuman/composio/tools/direct.rs | 8 +- src/openhuman/config/ops/mod.rs | 4 +- src/openhuman/config/schema/load/mod.rs | 2 - src/openhuman/config/schema/load_tests.rs | 4 +- src/openhuman/config/schemas/mod.rs | 13 +- src/openhuman/credentials/profiles.rs | 23 +- src/openhuman/cron/tools/add.rs | 2 +- src/openhuman/cwd_jail/linux.rs | 8 +- .../desktop_companion/pipeline_tests.rs | 1 - .../desktop_companion/session_tests.rs | 1 - src/openhuman/devices/rpc.rs | 2 +- src/openhuman/devices/store.rs | 2 +- src/openhuman/embeddings/ollama_adapter.rs | 9 +- src/openhuman/file_state/ops.rs | 10 +- src/openhuman/flows/ops.rs | 6 +- src/openhuman/flows/ops_tests.rs | 2 +- src/openhuman/inference/local/install.rs | 1 - src/openhuman/inference/local/mod.rs | 1 + src/openhuman/inference/local/ollama.rs | 1 + .../inference/provider/claude_code/driver.rs | 1 + src/openhuman/inference/provider/factory.rs | 21 +- .../inference/provider/factory_tests.rs | 16 +- src/openhuman/learning/extract/heuristics.rs | 2 - src/openhuman/learning/reflection.rs | 2 +- src/openhuman/learning/stability_detector.rs | 6 +- src/openhuman/mcp_registry/connections.rs | 1 + .../mcp_registry/registries/mcp_official.rs | 7 +- src/openhuman/mcp_server/write_dispatch.rs | 8 +- src/openhuman/meet_agent/brain/llm.rs | 2 +- src/openhuman/meet_agent/brain/speech.rs | 1 - src/openhuman/meet_agent/session.rs | 1 + src/openhuman/meet_agent/store.rs | 2 +- src/openhuman/memory/ops/learn.rs | 1 - src/openhuman/memory/read_rpc/types.rs | 3 +- src/openhuman/memory_queue/worker.rs | 9 +- .../memory_store/chunks/connection.rs | 3 - .../memory_store/chunks/embeddings.rs | 3 - src/openhuman/memory_tools/capture.rs | 6 +- src/openhuman/memory_tree/tree/factory.rs | 4 +- src/openhuman/memory_tree/tree/registry.rs | 8 +- src/openhuman/memory_tree/tree/rpc.rs | 3 +- src/openhuman/people/store.rs | 10 +- src/openhuman/runtime_python/bootstrap.rs | 1 + src/openhuman/security/pii/rules.rs | 2 +- src/openhuman/skills/e2e_plumbing_tests.rs | 1 - src/openhuman/skills/e2e_run_tests.rs | 1 - src/openhuman/skills/ops_tests.rs | 1 - src/openhuman/skills/registry.rs | 5 +- src/openhuman/socket/medulla/mod.rs | 7 +- .../subconscious_triggers/runtime.rs | 3 +- src/openhuman/test_support/introspect.rs | 4 +- src/openhuman/tinyagents/mod.rs | 113 +++--- src/openhuman/tinyagents/model.rs | 4 + src/openhuman/tinyagents/observability.rs | 1 + .../tinyagents/payload_summarizer.rs | 10 +- src/openhuman/tinyagents/replay/mod.rs | 4 +- src/openhuman/tinyagents/retriever.rs | 2 +- src/openhuman/tinyagents/tests.rs | 1 - src/openhuman/tinycortex/embeddings.rs | 1 + src/openhuman/tinycortex/sync.rs | 2 +- src/openhuman/tinyflows/caps.rs | 1 + src/openhuman/tinyplace/manifest.rs | 1 - src/openhuman/todos/graph_shadow.rs | 5 +- .../tools/impl/filesystem/glob_search.rs | 2 +- .../tools/impl/network/polymarket.rs | 8 +- src/openhuman/tools/impl/system/schedule.rs | 8 +- src/openhuman/voice/factory/tests.rs | 6 +- src/openhuman/voice/schemas/mod.rs | 5 +- src/openhuman/voice/server.rs | 4 +- src/openhuman/wallet/chains/btc.rs | 2 +- src/openhuman/webview_accounts/ops.rs | 2 +- src/openhuman/webview_apis/client.rs | 4 +- src/openhuman/x402/x402_tests.rs | 4 +- tests/agent_harness_e2e.rs | 21 +- tests/json_rpc_e2e.rs | 161 ++++----- tests/keyring_secretstore_e2e.rs | 12 +- .../agent_session_round24_raw_coverage_e2e.rs | 29 +- tests/subconscious_conversation_e2e.rs | 16 +- tests/transcript_search_e2e.rs | 24 +- 140 files changed, 732 insertions(+), 1280 deletions(-) delete mode 100644 app/src-tauri/src/cdp/input.rs delete mode 100644 app/src-tauri/src/meet_audio/listen_capture.rs diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index f807ef536c..a00dc98a56 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -338,6 +338,7 @@ jobs: with: workspaces: | . -> target + app/src-tauri -> target cache-on-failure: true # shared-key (not key) so the cache name is stable and not suffixed # with the job id. Swatinem caches the full target/, which is why we @@ -349,7 +350,23 @@ jobs: run: cargo fmt --all -- --check - name: Run clippy (core crate) - run: bash scripts/ci-cancel-aware.sh cargo clippy -p openhuman + if: needs.changes.outputs['rust-core'] == 'true' + run: bash scripts/ci-cancel-aware.sh cargo clippy -p openhuman -- -D warnings + + - name: Cache CEF binary distribution + if: needs.changes.outputs['rust-tauri'] == 'true' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: | + ~/Library/Caches/tauri-cef + ~/.cache/tauri-cef + key: cef-x86_64-unknown-linux-gnu-v2-${{ hashFiles('app/src-tauri/Cargo.toml') }} + restore-keys: | + cef-x86_64-unknown-linux-gnu-v2- + + - name: Run clippy (Tauri shell) + if: needs.changes.outputs['rust-tauri'] == 'true' + run: bash scripts/ci-cancel-aware.sh cargo clippy --manifest-path app/src-tauri/Cargo.toml -- -D warnings # Feature-gate smoke: proves the core still compiles with a domain gate turned # OFF. The disabled build is the ONLY thing that catches stub-facade signature diff --git a/.husky/pre-push b/.husky/pre-push index 521d8b9c14..d39768b34a 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -93,10 +93,10 @@ pnpm compile COMPILE_EXIT=$? set -e -# Run Rust compile checks for both the core and Tauri codebases +# Run Clippy for both Rust codebases; Clippy also performs compile checks. set +e -pnpm rust:check -RUST_CHECK_EXIT=$? +pnpm rust:clippy +RUST_CLIPPY_EXIT=$? set -e # Enforce scoped cmd-* tokens in components/commands/ @@ -106,7 +106,7 @@ CMD_TOKENS_EXIT=$? set -e # Exit with error if any command still fails after fixes -if [ $FORMAT_EXIT -ne 0 ] || [ $LINT_EXIT -ne 0 ] || [ $COMPILE_EXIT -ne 0 ] || [ $RUST_CHECK_EXIT -ne 0 ] || [ $CMD_TOKENS_EXIT -ne 0 ]; then +if [ $FORMAT_EXIT -ne 0 ] || [ $LINT_EXIT -ne 0 ] || [ $COMPILE_EXIT -ne 0 ] || [ $RUST_CLIPPY_EXIT -ne 0 ] || [ $CMD_TOKENS_EXIT -ne 0 ]; then echo echo "============================================================" echo "Pre-push checks failed." @@ -116,10 +116,10 @@ if [ $FORMAT_EXIT -ne 0 ] || [ $LINT_EXIT -ne 0 ] || [ $COMPILE_EXIT -ne 0 ] || echo " git add -A && git commit -m 'chore: apply auto-fixes'" echo " git push" fi - if [ $COMPILE_EXIT -ne 0 ] || [ $RUST_CHECK_EXIT -ne 0 ] || [ $CMD_TOKENS_EXIT -ne 0 ]; then + if [ $COMPILE_EXIT -ne 0 ] || [ $RUST_CLIPPY_EXIT -ne 0 ] || [ $CMD_TOKENS_EXIT -ne 0 ]; then echo "Fix the remaining errors above (TypeScript / Rust / cmd-tokens)" echo "before re-pushing — these have no auto-fix path." fi echo "============================================================" exit 1 -fi \ No newline at end of file +fi diff --git a/Cargo.toml b/Cargo.toml index c24b224696..e85b7ca481 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -379,6 +379,19 @@ e2e-test-support = [] [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage)'] } +# These project-wide shape/documentation lints describe intentional public APIs +# and long-standing module docs. Keep them explicitly baselined so `-D warnings` +# can make every other Clippy and rustc diagnostic a hard failure. +[lints.clippy] +borrowed_box = "allow" +doc_overindented_list_items = "allow" +field_reassign_with_default = "allow" +large_enum_variant = "allow" +result_large_err = "allow" +should_implement_trait = "allow" +too_many_arguments = "allow" +while_let_loop = "allow" + # Fix whisper-rs-sys CRT mismatch on Windows MSVC (LNK2038). # Upstream cmake build defaults to /MD but Rust uses /MT. # This fork adds config.static_crt(true) to the build script. diff --git a/app/package.json b/app/package.json index 39ebbe545c..7c42fe9d6a 100644 --- a/app/package.json +++ b/app/package.json @@ -62,7 +62,7 @@ "rust:check": "cargo check --manifest-path src-tauri/Cargo.toml", "rust:format": "cargo fmt --manifest-path ../Cargo.toml --all && cargo fmt --manifest-path src-tauri/Cargo.toml --all", "rust:format:check": "cargo fmt --manifest-path ../Cargo.toml --all --check && cargo fmt --manifest-path src-tauri/Cargo.toml --all --check", - "rust:clippy": "cargo clippy -p openhuman -- -D warnings", + "rust:clippy": "cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings", "format": "prettier --write . && pnpm rust:format", "format:check": "prettier --check . && pnpm rust:format:check", "lint": "eslint . --ext .ts,.tsx --cache", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 7cd56f76ae..2af2f6a3e3 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -226,7 +226,7 @@ tinyagents = { path = "../../vendor/tinyagents" } # TinyAgents so integration work can test crate changes against OpenHuman before # publishing. tinyflows = { path = "../../vendor/tinyflows" } -tinycortex = { path = "../../vendor/tinycortex", features = ["git-diff"] } +tinycortex = { path = "../../vendor/tinycortex" } tinyjuice = { path = "../../vendor/tinyjuice" } tinychannels = { path = "../../vendor/tinychannels" } tinyplace = { path = "../../vendor/tinyplace/sdk/rust" } diff --git a/app/src-tauri/src/cdp/input.rs b/app/src-tauri/src/cdp/input.rs deleted file mode 100644 index cd97330286..0000000000 --- a/app/src-tauri/src/cdp/input.rs +++ /dev/null @@ -1,236 +0,0 @@ -//! Thin helpers around `Input.dispatchMouseEvent` and -//! `Input.dispatchKeyEvent` so providers can drive web UIs without -//! touching the page's JavaScript. -//! -//! All coordinates are CSS pixels relative to the viewport — the same -//! frame `DOMSnapshot.captureSnapshot(includeDOMRects=true)` returns -//! bounding rects in. Callers typically pair these with -//! [`crate::cdp::Snapshot::rect`] to find the click target. -//! -//! Everything here is CEF-only — CDP requires a remote-debugging port, -//! which wry doesn't expose. -//! -//! # Cookbook -//! -//! ```ignore -//! let snap = Snapshot::capture_with_rects(&mut cdp, &session).await?; -//! let idx = snap.find_descendant(0, |s, i| s.attr(i, "aria-label") == Some("Search mail")) -//! .ok_or("search box not found")?; -//! let rect = snap.rect(idx).ok_or("search box has no layout rect")?; -//! let (cx, cy) = rect.center(); -//! input::click(&mut cdp, &session, cx, cy).await?; -//! input::type_text(&mut cdp, &session, "from:linkedin.com").await?; -//! input::press_key(&mut cdp, &session, Key::Enter).await?; -//! ``` - -use serde_json::{json, Value}; - -use super::CdpConn; - -#[allow(dead_code)] // helper is used by currently gated input paths. -#[cfg(target_os = "macos")] -const SELECT_ALL_MODIFIER: u32 = 4; -#[allow(dead_code)] // helper is used by currently gated input paths. -#[cfg(not(target_os = "macos"))] -const SELECT_ALL_MODIFIER: u32 = 2; - -/// Names recognised by `Input.dispatchKeyEvent`'s `key` field. We -/// hand-pick the ones Gmail's keyboard handlers care about so callers -/// can use a typed value rather than stringly-typed literals scattered -/// across providers. -#[allow(dead_code)] // variants reserved for upcoming providers / write ops. -#[derive(Debug, Clone, Copy)] -pub enum Key { - Enter, - Escape, - Tab, - Backspace, - ArrowDown, - ArrowUp, -} - -impl Key { - /// `(key, code, windowsVirtualKeyCode)` triple. Gmail's listeners - /// branch on different fields depending on browser; we set all three - /// to maximise compatibility. - fn cdp_fields(self) -> (&'static str, &'static str, u32) { - match self { - Key::Enter => ("Enter", "Enter", 13), - Key::Escape => ("Escape", "Escape", 27), - Key::Tab => ("Tab", "Tab", 9), - Key::Backspace => ("Backspace", "Backspace", 8), - Key::ArrowDown => ("ArrowDown", "ArrowDown", 40), - Key::ArrowUp => ("ArrowUp", "ArrowUp", 38), - } - } -} - -/// Click at `(x, y)` — left button, no modifiers, single click. -/// Issues mouseMoved → mousePressed → mouseReleased so hover handlers -/// (Gmail's search-box has one) fire correctly before the click. -pub async fn click(cdp: &mut CdpConn, session: &str, x: f64, y: f64) -> Result<(), String> { - log::debug!("[cdp::input] click session={session} x={x:.1} y={y:.1}"); - let _ = mouse_event(cdp, session, "mouseMoved", x, y, 0).await?; - let _ = mouse_event(cdp, session, "mousePressed", x, y, 1).await?; - let _ = mouse_event(cdp, session, "mouseReleased", x, y, 1).await?; - log::debug!("[cdp::input] click complete session={session}"); - Ok(()) -} - -async fn mouse_event( - cdp: &mut CdpConn, - session: &str, - kind: &str, - x: f64, - y: f64, - click_count: u32, -) -> Result { - log::debug!( - "[cdp::input] mouse_event session={session} kind={kind} x={x:.1} y={y:.1} clicks={click_count}" - ); - cdp.call( - "Input.dispatchMouseEvent", - json!({ - "type": kind, - "x": x, - "y": y, - "button": "left", - "buttons": if kind == "mousePressed" { 1 } else { 0 }, - "clickCount": click_count, - }), - Some(session), - ) - .await - .map_err(|e| format!("Input.dispatchMouseEvent {kind}: {e}")) -} - -/// Type a literal string by dispatching one `keyDown`/`char`/`keyUp` -/// triple per character. CDP's `dispatchKeyEvent type=char` is what -/// actually inserts text into focused editable fields — `keyDown` -/// alone leaves the input empty for most letters. The `keyDown` -/// + `keyUp` pair is still needed so listeners (autocomplete, -/// keystroke counters) see a normal keystroke. -pub async fn type_text(cdp: &mut CdpConn, session: &str, text: &str) -> Result<(), String> { - log::debug!( - "[cdp::input] type_text session={session} chars={}", - text.chars().count() - ); - for ch in text.chars() { - let s = ch.to_string(); - // keyDown — Gmail's command/keyboard router observes these. - cdp.call( - "Input.dispatchKeyEvent", - json!({ - "type": "keyDown", - "text": s, - "unmodifiedText": s, - "key": s, - }), - Some(session), - ) - .await - .map_err(|e| format!("Input.dispatchKeyEvent keyDown {ch:?}: {e}"))?; - // char — actual text insertion into the focused editable. - cdp.call( - "Input.dispatchKeyEvent", - json!({ - "type": "char", - "text": s, - "unmodifiedText": s, - "key": s, - }), - Some(session), - ) - .await - .map_err(|e| format!("Input.dispatchKeyEvent char {ch:?}: {e}"))?; - cdp.call( - "Input.dispatchKeyEvent", - json!({ - "type": "keyUp", - "text": s, - "unmodifiedText": s, - "key": s, - }), - Some(session), - ) - .await - .map_err(|e| format!("Input.dispatchKeyEvent keyUp {ch:?}: {e}"))?; - } - log::debug!("[cdp::input] type_text complete session={session}"); - Ok(()) -} - -/// Press a non-character key (Enter, Esc, …). Sends `rawKeyDown` → -/// `keyUp`; no `char` because non-printables don't insert text. -pub async fn press_key(cdp: &mut CdpConn, session: &str, key: Key) -> Result<(), String> { - let (key_name, code, vk) = key.cdp_fields(); - log::debug!("[cdp::input] press_key session={session} key={key_name}"); - cdp.call( - "Input.dispatchKeyEvent", - json!({ - "type": "rawKeyDown", - "key": key_name, - "code": code, - "windowsVirtualKeyCode": vk, - "nativeVirtualKeyCode": vk, - }), - Some(session), - ) - .await - .map_err(|e| format!("Input.dispatchKeyEvent rawKeyDown {key_name}: {e}"))?; - cdp.call( - "Input.dispatchKeyEvent", - json!({ - "type": "keyUp", - "key": key_name, - "code": code, - "windowsVirtualKeyCode": vk, - "nativeVirtualKeyCode": vk, - }), - Some(session), - ) - .await - .map_err(|e| format!("Input.dispatchKeyEvent keyUp {key_name}: {e}"))?; - log::debug!("[cdp::input] press_key complete session={session} key={key_name}"); - Ok(()) -} - -/// Dispatch Cmd/Ctrl+A to select-all in the focused contenteditable / input. -/// Useful when the search box already has a previous query in it that -/// we need to overwrite — Gmail keeps the last query rendered in the -/// search input so a fresh visit sees stale text. -pub async fn select_all_in_focused(cdp: &mut CdpConn, session: &str) -> Result<(), String> { - log::debug!( - "[cdp::input] select_all_in_focused session={session} modifier={SELECT_ALL_MODIFIER}" - ); - cdp.call( - "Input.dispatchKeyEvent", - json!({ - "type": "rawKeyDown", - "key": "a", - "code": "KeyA", - "windowsVirtualKeyCode": 65, - "nativeVirtualKeyCode": 65, - "modifiers": SELECT_ALL_MODIFIER, - }), - Some(session), - ) - .await - .map_err(|e| format!("Input.dispatchKeyEvent select-all keyDown: {e}"))?; - cdp.call( - "Input.dispatchKeyEvent", - json!({ - "type": "keyUp", - "key": "a", - "code": "KeyA", - "windowsVirtualKeyCode": 65, - "nativeVirtualKeyCode": 65, - "modifiers": SELECT_ALL_MODIFIER, - }), - Some(session), - ) - .await - .map_err(|e| format!("Input.dispatchKeyEvent select-all keyUp: {e}"))?; - log::debug!("[cdp::input] select_all_in_focused complete session={session}"); - Ok(()) -} diff --git a/app/src-tauri/src/cdp/mod.rs b/app/src-tauri/src/cdp/mod.rs index 3c360e5a68..17cede9959 100644 --- a/app/src-tauri/src/cdp/mod.rs +++ b/app/src-tauri/src/cdp/mod.rs @@ -6,29 +6,21 @@ //! and `Webview::on_dev_tools_protocol`. There is no listener and no //! network surface; any same-UID process is shut out by construction. //! -//! Scanners pick up a [`CdpConn`] either via [`conn_for_account`] (for -//! `acct_`-labelled webviews) or [`conn_for_label`] / -//! [`connect_and_attach_matching_in_process_by_label`] (for other +//! Scanners pick up a [`CdpConn`] either via [`target::conn_for_account`] (for +//! `acct_`-labelled webviews) or [`target::conn_for_label`] / +//! [`target::connect_and_attach_matching_in_process_by_label`] (for other //! surfaces such as the Meet call window). pub mod conn; pub mod in_process; -pub mod input; pub mod session; pub mod snapshot; pub mod target; pub use conn::CdpConn; -pub use in_process::{ - install_for_account, install_for_label, install_for_webview, set_cef_app_handle, CdpRegistry, - EventFrame, WebviewCdpTransport, CALL_TIMEOUT, -}; +pub use in_process::{install_for_account, install_for_label, set_cef_app_handle, CdpRegistry}; pub use session::{ placeholder_marker, placeholder_url, spawn_session, target_url_fragment, SpawnedSession, }; -#[allow(unused_imports)] // `Rect` re-export consumed once turn 2 lands; keep stable. -pub use snapshot::{Rect, Snapshot}; -pub use target::{ - conn_for_account, conn_for_label, connect_and_attach_matching_in_process, - connect_and_attach_matching_in_process_by_label, detach_session, find_page_target_where, -}; +pub use snapshot::Snapshot; +pub use target::{detach_session, find_page_target_where}; diff --git a/app/src-tauri/src/cdp/session.rs b/app/src-tauri/src/cdp/session.rs index 92a0626ba9..77a8d429b9 100644 --- a/app/src-tauri/src/cdp/session.rs +++ b/app/src-tauri/src/cdp/session.rs @@ -24,8 +24,8 @@ use tokio::task::JoinHandle; // elapsed check honours `tokio::time::pause()` / `advance()` in unit tests. use tokio::time::{sleep, Instant}; +use super::find_page_target_where; use super::target::conn_for_account; -use super::{find_page_target_where, CdpConn}; use crate::webview_accounts::{emit_load_finished, redact_url_for_log, RevealTrigger}; /// Backoff between failed attach attempts / reconnects. Intentionally @@ -517,7 +517,7 @@ async fn run_session_cycle( // page reaches the CEF helper's notify-IPC, which posts back to // `forward_native_notification` in `webview_accounts`. Without it, // the constructor silently no-ops and no toast ever fires (#1016). - if let Some(origin) = origin_of(&real_url) { + if let Some(origin) = origin_of(real_url) { // Default permission set every embedded provider needs. Origin-scoped // so we don't leak grants across providers running in the same CEF // browser process. diff --git a/app/src-tauri/src/cdp/snapshot.rs b/app/src-tauri/src/cdp/snapshot.rs index 9228e3675e..dc33a1b3ce 100644 --- a/app/src-tauri/src/cdp/snapshot.rs +++ b/app/src-tauri/src/cdp/snapshot.rs @@ -33,35 +33,6 @@ struct CaptureSnapshot { struct DocumentSnap { #[serde(default)] nodes: NodeTreeSnap, - #[serde(default)] - layout: LayoutSnap, -} - -/// Subset of `documents[i].layout` we care about. Each layout node -/// references a DOM node by index and carries its `[x, y, w, h]` bounds -/// in CSS pixels. Only populated when `includeDOMRects: true` is passed -/// to `DOMSnapshot.captureSnapshot`. -#[derive(Deserialize, Debug, Default)] -struct LayoutSnap { - #[serde(rename = "nodeIndex", default)] - node_index: Vec, - #[serde(default)] - bounds: Vec>, -} - -/// Element bounding rect in CSS pixels relative to the viewport. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Rect { - pub x: f64, - pub y: f64, - pub width: f64, - pub height: f64, -} - -impl Rect { - pub fn center(self) -> (f64, f64) { - (self.x + self.width / 2.0, self.y + self.height / 2.0) - } } #[derive(Deserialize, Debug, Default)] @@ -82,46 +53,38 @@ pub struct Snapshot { strings: Vec, nodes: NodeTreeSnap, children: Vec>, - /// `rects[node_idx]` is `Some(Rect)` when layout info was requested - /// AND the node has a layout box (text + element nodes do; pure - /// metadata like `` doesn't). `None` otherwise. - rects: Vec>, } impl Snapshot { /// Run `DOMSnapshot.captureSnapshot` on an attached session and return - /// the parsed main-document tree. Iframes are ignored — none of the - /// migrated providers render chat lists inside iframes. + /// one parsed tree containing the main document and any iframe documents. pub async fn capture(cdp: &mut CdpConn, session: &str) -> Result { - Self::capture_inner(cdp, session, false).await - } - - /// Same as [`capture`] but also requests element bounding rects. - /// Use when the caller needs to drive `Input.dispatchMouseEvent` — - /// pulling rects on every snapshot adds protocol overhead, so the - /// cheap path stays cheap. - pub async fn capture_with_rects(cdp: &mut CdpConn, session: &str) -> Result { - Self::capture_inner(cdp, session, true).await - } - - async fn capture_inner( - cdp: &mut CdpConn, - session: &str, - with_rects: bool, - ) -> Result { + log::debug!("[cdp::snapshot] capture start session={session}"); let raw = cdp .call( "DOMSnapshot.captureSnapshot", - json!({ - "computedStyles": [], - "includePaintOrder": false, - "includeDOMRects": with_rects, - }), + capture_request(), Some(session), ) - .await?; - let snap: CaptureSnapshot = - serde_json::from_value(raw).map_err(|e| format!("decode DOMSnapshot: {e}"))?; + .await + .map_err(|error| { + log::warn!("[cdp::snapshot] capture call failed session={session} error={error}"); + error + })?; + log::debug!("[cdp::snapshot] capture call complete session={session}"); + let snap: CaptureSnapshot = serde_json::from_value(raw).map_err(|error| { + log::warn!("[cdp::snapshot] decode failed session={session} error={error}"); + format!("decode DOMSnapshot: {error}") + })?; + let snapshot = Self::from_capture(snap); + log::debug!( + "[cdp::snapshot] decode complete session={session} nodes={}", + snapshot.len() + ); + Ok(snapshot) + } + + fn from_capture(snap: CaptureSnapshot) -> Self { let strings = snap.strings; // Merge every document (main frame + all iframes) into a single // flat node array. CDP returns each frame as its own document @@ -138,12 +101,9 @@ impl Snapshot { let mut merged_node_name: Vec = Vec::new(); let mut merged_node_value: Vec = Vec::new(); let mut merged_attributes: Vec> = Vec::new(); - let mut merged_layout_node_index: Vec = Vec::new(); - let mut merged_layout_bounds: Vec> = Vec::new(); for document in snap.documents { let doc_offset = merged_node_type.len() as i32; let doc_nodes = document.nodes; - let doc_count = doc_nodes.node_type.len(); for &p in &doc_nodes.parent_index { merged_parent_index.push(if p < 0 { -1 } else { p + doc_offset }); } @@ -162,12 +122,6 @@ impl Snapshot { while merged_attributes.len() < merged_node_type.len() { merged_attributes.push(Vec::new()); } - // Per-document layout indices need the same offset. - for &li in &document.layout.node_index { - merged_layout_node_index.push(if li < 0 { -1 } else { li + doc_offset }); - } - merged_layout_bounds.extend(document.layout.bounds); - let _ = doc_count; } let nodes = NodeTreeSnap { parent_index: merged_parent_index, @@ -176,10 +130,6 @@ impl Snapshot { node_value: merged_node_value, attributes: merged_attributes, }; - let layout = LayoutSnap { - node_index: merged_layout_node_index, - bounds: merged_layout_bounds, - }; let count = nodes.node_type.len(); let mut children: Vec> = vec![Vec::new(); count]; for (i, &p) in nodes.parent_index.iter().enumerate() { @@ -187,34 +137,11 @@ impl Snapshot { children[p as usize].push(i); } } - let mut rects: Vec> = vec![None; count]; - if with_rects { - for (layout_i, &node_i) in layout.node_index.iter().enumerate() { - if node_i < 0 { - continue; - } - let node_i = node_i as usize; - if node_i >= count { - continue; - } - let bounds = match layout.bounds.get(layout_i) { - Some(b) if b.len() >= 4 => b, - _ => continue, - }; - rects[node_i] = Some(Rect { - x: bounds[0], - y: bounds[1], - width: bounds[2], - height: bounds[3], - }); - } - } - Ok(Self { + Self { strings, nodes, children, - rects, - }) + } } pub fn len(&self) -> usize { @@ -268,13 +195,6 @@ impl Snapshot { self.children.get(idx).map(|v| v.as_slice()).unwrap_or(&[]) } - /// Layout rect for `idx`. `None` when the snapshot was captured - /// without `includeDOMRects` OR the node has no layout box (e.g. - /// ``, comment nodes, `display:none` elements). - pub fn rect(&self, idx: usize) -> Option { - self.rects.get(idx).copied().flatten() - } - /// Depth-first pre-order walk of every descendant of `root` (including /// `root` itself). Cheap enough for chat-list scrapes that run every /// 2 seconds — DOM has thousands of nodes, not millions. @@ -336,6 +256,14 @@ impl Snapshot { } } +fn capture_request() -> serde_json::Value { + json!({ + "computedStyles": [], + "includePaintOrder": false, + "includeDOMRects": false, + }) +} + fn collapse_ws(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut last_space = true; @@ -357,6 +285,51 @@ fn collapse_ws(s: &str) -> String { mod tests { use super::*; + #[test] + fn capture_request_disables_dom_rects() { + let request = capture_request(); + assert_eq!(request["includeDOMRects"], false); + assert_eq!(request["includePaintOrder"], false); + assert_eq!(request["computedStyles"], json!([])); + } + + #[test] + fn from_capture_offsets_documents_and_builds_child_adjacency_without_layout() { + let capture: CaptureSnapshot = serde_json::from_value(json!({ + "strings": ["DIV", "first", "SPAN", "second"], + "documents": [ + { + "nodes": { + "parentIndex": [-1, 0], + "nodeType": [1, 3], + "nodeName": [0, -1], + "nodeValue": [-1, 1] + } + }, + { + "nodes": { + "parentIndex": [-1, 0], + "nodeType": [1, 3], + "nodeName": [2, -1], + "nodeValue": [-1, 3] + } + } + ] + })) + .expect("snapshot fixture should decode without layout data"); + + let snapshot = Snapshot::from_capture(capture); + + assert_eq!(snapshot.len(), 4); + assert_eq!(snapshot.children(0), &[1]); + assert_eq!(snapshot.children(2), &[3]); + assert!(snapshot.children(1).is_empty()); + assert_eq!(snapshot.tag(0), "DIV"); + assert_eq!(snapshot.tag(2), "SPAN"); + assert_eq!(snapshot.text_content(0), "first"); + assert_eq!(snapshot.text_content(2), "second"); + } + #[test] fn collapse_ws_collapses_and_trims() { assert_eq!(collapse_ws(" hello world "), "hello world"); diff --git a/app/src-tauri/src/cdp/target.rs b/app/src-tauri/src/cdp/target.rs index 93e8736213..7a57c8502e 100644 --- a/app/src-tauri/src/cdp/target.rs +++ b/app/src-tauri/src/cdp/target.rs @@ -18,7 +18,6 @@ pub struct CdpTarget { pub id: String, pub kind: String, pub url: String, - pub title: String, } /// Parse the response of a `Target.getTargets` CDP call into a list of @@ -38,11 +37,6 @@ pub fn parse_targets(v: &Value) -> Vec { .and_then(|u| u.as_str()) .unwrap_or("") .to_string(), - title: t - .get("title") - .and_then(|u| u.as_str()) - .unwrap_or("") - .to_string(), }) }) .collect() diff --git a/app/src-tauri/src/claude_code.rs b/app/src-tauri/src/claude_code.rs index f3b3a3deec..c70f960cc0 100644 --- a/app/src-tauri/src/claude_code.rs +++ b/app/src-tauri/src/claude_code.rs @@ -61,7 +61,7 @@ end tell"#; Err(_) => continue, } } - return Err("no terminal emulator found (tried x-terminal-emulator, gnome-terminal, konsole, xfce4-terminal, xterm). Run `claude login` manually.".into()); + Err("no terminal emulator found (tried x-terminal-emulator, gnome-terminal, konsole, xfce4-terminal, xterm). Run `claude login` manually.".into()) } #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] diff --git a/app/src-tauri/src/deep_link_ipc.rs b/app/src-tauri/src/deep_link_ipc.rs index 42fe98b869..857ee4a855 100644 --- a/app/src-tauri/src/deep_link_ipc.rs +++ b/app/src-tauri/src/deep_link_ipc.rs @@ -108,13 +108,15 @@ pub(crate) fn try_forward_deep_links() -> ForwardResult { // Pending URLs collected before setup() has an app handle. static PENDING_URLS: OnceLock>>> = OnceLock::new(); // Live handler installed by drain_pending_urls — dispatches directly to app. -static LIVE_HANDLER: OnceLock>>> = OnceLock::new(); +type LiveHandler = Box; +type LiveHandlerSlot = Mutex>; +static LIVE_HANDLER: OnceLock = OnceLock::new(); fn pending_queue() -> &'static Arc>> { PENDING_URLS.get_or_init(|| Arc::new(Mutex::new(Vec::new()))) } -fn live_handler() -> &'static Mutex>> { +fn live_handler() -> &'static LiveHandlerSlot { LIVE_HANDLER.get_or_init(|| Mutex::new(None)) } diff --git a/app/src-tauri/src/discord_scanner/mod_tests.rs b/app/src-tauri/src/discord_scanner/mod_tests.rs index ad7345d2d8..7278419079 100644 --- a/app/src-tauri/src/discord_scanner/mod_tests.rs +++ b/app/src-tauri/src/discord_scanner/mod_tests.rs @@ -339,7 +339,6 @@ fn page(id: &str, url: &str) -> crate::cdp::target::CdpTarget { id: id.to_string(), kind: "page".to_string(), url: url.to_string(), - title: String::new(), } } @@ -409,7 +408,6 @@ fn resolve_none_when_no_prefix_target() { id: "iframe".to_string(), kind: "iframe".to_string(), url: "https://discord.com/channels/@me".to_string(), - title: String::new(), }, ]; assert!( diff --git a/app/src-tauri/src/imessage_scanner/mod.rs b/app/src-tauri/src/imessage_scanner/mod.rs index 2593190a8f..289fd7cac0 100644 --- a/app/src-tauri/src/imessage_scanner/mod.rs +++ b/app/src-tauri/src/imessage_scanner/mod.rs @@ -465,13 +465,6 @@ impl ScannerRegistry { pub fn new() -> Self { Self } - pub fn ensure_scanner( - self: std::sync::Arc, - _app: tauri::AppHandle, - _account_id: String, - ) { - } - pub fn shutdown(&self) {} } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 9cac2be8d8..5ca2b6375b 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -399,9 +399,7 @@ async fn restart_app(app: tauri::AppHandle) -> Result<(), String> { perform_early_teardown_async(&app).await; log::info!("[app] restart_app — early teardown complete, restarting"); - app.restart(); - // restart() does not return, but we must satisfy the signature - Ok(()) + app.restart() } /// Read the authoritative active user id from `active_user.toml` so the @@ -1214,7 +1212,7 @@ fn mascot_native_window_is_open() -> bool { mascot_native_window::is_open() } -#[cfg(not(target_os = "macos"))] +#[cfg(all(not(target_os = "macos"), not(target_os = "linux")))] fn mascot_native_window_is_open() -> bool { false } @@ -1848,7 +1846,9 @@ async fn perform_early_teardown_async(app_handle: &AppHandle) { log::info!("[app] perform_early_teardown_async — early teardown complete"); } -/// Explicitly winds down CEF and Tauri before an app.exit(0) +/// Explicitly wind down CEF and the core before exiting from desktop-owned +/// quit actions. Linux does not build the tray or macOS application menu. +#[cfg(not(target_os = "linux"))] fn shutdown_app_sync(app_handle: &AppHandle, exit_code: i32) { log::info!("[app] shutdown_app_sync — starting early teardown"); perform_early_teardown_sync_once(app_handle, "shutdown_app_sync"); diff --git a/app/src-tauri/src/meet_audio/caption_listener.rs b/app/src-tauri/src/meet_audio/caption_listener.rs index 637c4ee7bc..e7e07878ae 100644 --- a/app/src-tauri/src/meet_audio/caption_listener.rs +++ b/app/src-tauri/src/meet_audio/caption_listener.rs @@ -2,8 +2,8 @@ //! `captions_bridge.js` we install at session start, and forwards each //! new line to core's `meet_agent_push_caption` RPC. //! -//! Replaces the old [`super::listen_capture`] (CEF audio handler → -//! Whisper STT) which proved unreliable: CEF's `cef_audio_handler_t` +//! Replaces the old CEF audio-handler and Whisper STT path, which proved +//! unreliable: CEF's `cef_audio_handler_t` //! is queried lazily on first audio output, so a solo agent in a //! lobby never engaged the pipeline. Captions handle that case for //! free — Meet's STT is already running, speaker-attributed, and @@ -34,7 +34,6 @@ const MAX_CONSECUTIVE_ERRORS: u32 = 30; /// RAII handle. Drop to stop the listener task. pub struct CaptionListener { - pub request_id: String, pub(crate) _shutdown_tx: Option>, } @@ -88,7 +87,6 @@ pub fn start(request_id: String, cdp: CdpConn, session_id: String) -> CaptionLis }); CaptionListener { - request_id, _shutdown_tx: Some(shutdown_tx), } } diff --git a/app/src-tauri/src/meet_audio/listen_capture.rs b/app/src-tauri/src/meet_audio/listen_capture.rs deleted file mode 100644 index 493a52e378..0000000000 --- a/app/src-tauri/src/meet_audio/listen_capture.rs +++ /dev/null @@ -1,327 +0,0 @@ -//! Capture the embedded Meet webview's audio output and forward it to -//! the core meet_agent loop. -//! -//! ## Pipeline -//! -//! 1. `tauri_runtime_cef::audio::register_audio_handler` taps the -//! per-browser `cef_audio_handler_t`. CEF delivers planar -//! float32 PCM at the renderer's native rate (typically 48 kHz, -//! 1–2 channels) directly from the audio output device path — -//! *before* it hits the OS speaker. No system permission needed. -//! -//! 2. Downsample-to-mono runs inline on the CEF audio thread: -//! - average across channels → mono float32 -//! - linear-interpolate down to 16 kHz (the rate `voice::streaming` -//! and the smoke test in `meet_agent::session` expect) -//! - clamp + scale to PCM16LE -//! -//! 3. Accumulate ~100 ms per chunk (1 600 samples @ 16 kHz). We push -//! via the core RPC on every flush boundary; smaller pushes would -//! overload the JSON envelope, larger ones would slow VAD. -//! -//! 4. RPC pushes are spawned on the tokio runtime so the audio -//! callback never blocks on network IO. A bounded channel -//! backpressures: if core is wedged, we drop the oldest queued -//! chunk rather than holding CEF's audio thread. - -use std::sync::{Arc, Mutex}; - -use tauri_runtime_cef::audio::{ - register_audio_handler, AudioHandlerRegistration, AudioStreamEvent, -}; -use tokio::sync::mpsc; - -const TARGET_SAMPLE_RATE: u32 = 16_000; -/// 100 ms @ 16 kHz mono. `meet_agent::ops::Vad` pushes hangover counts -/// based on per-frame cadence, so changing this changes the VAD wall -/// time too. 100 ms feels responsive without burning RPC. -const FLUSH_SAMPLES: usize = (TARGET_SAMPLE_RATE as usize) / 10; -/// Bounded channel between the CEF callback (producer) and the -/// async-runtime forwarder (consumer). 32 chunks ≈ 3.2 s at the flush -/// cadence — generous slack for transient core latency, but bounded -/// so a wedged core can't OOM us. -const FORWARD_CHANNEL_CAPACITY: usize = 32; - -/// RAII handle. Drop to release the CEF audio registration and shut -/// down the forwarder task. Both happen synchronously — the channel -/// closes first, the task exits its recv loop, and the registration -/// drop unhooks CEF in the same tick. -pub struct ListenSession { - pub request_id: String, - _registration: AudioHandlerRegistration, - /// Held so `Drop` closes the channel even if there are no in-flight - /// chunks. The forwarder task observes the close and exits. - _shutdown_tx: mpsc::Sender>, -} - -/// Opens the audio capture for `meet_url`. The same exact URL must -/// have been used to build the CEF window — `register_audio_handler` -/// matches by prefix. -pub fn start(meet_url: &str, request_id: String) -> Result { - let (tx, rx) = mpsc::channel::>(FORWARD_CHANNEL_CAPACITY); - let resampler = Arc::new(Mutex::new(Resampler::new())); - - let resampler_for_handler = resampler.clone(); - let tx_for_handler = tx.clone(); - let request_id_for_log = request_id.clone(); - let registration = register_audio_handler(meet_url.to_string(), move |event| { - on_audio_event( - &request_id_for_log, - event, - &resampler_for_handler, - &tx_for_handler, - ); - }); - - spawn_forwarder(request_id.clone(), rx); - - log::info!( - "[meet-audio] listen registered request_id={} url_chars={}", - request_id, - meet_url.chars().count() - ); - - Ok(ListenSession { - request_id, - _registration: registration, - _shutdown_tx: tx, - }) -} - -/// Process one CEF audio event. Speech/Stopped/Error all flow through -/// here; only `Packet` produces RPC traffic, but the others are logged -/// at info so an aborted call leaves a breadcrumb in the file logs. -fn on_audio_event( - request_id: &str, - event: AudioStreamEvent, - resampler: &Arc>, - tx: &mpsc::Sender>, -) { - match event { - AudioStreamEvent::Started { - sample_rate_hz, - channels, - frames_per_buffer, - } => { - log::info!( - "[meet-audio] cef stream start request_id={request_id} hz={sample_rate_hz} channels={channels} frames_per_buffer={frames_per_buffer}" - ); - if let Ok(mut r) = resampler.lock() { - r.reset(sample_rate_hz as u32); - } - } - AudioStreamEvent::Packet { - channels: planes, - pts_ms: _, - } => { - let pcm_bytes = match resampler.lock() { - Ok(mut r) => r.feed_and_drain(&planes), - Err(_) => return, - }; - for chunk in pcm_bytes.chunks(FLUSH_SAMPLES * 2) { - // `try_send` drops the chunk on a full channel rather - // than blocking the CEF audio thread. Better to lose - // a frame than to stall the renderer. - if tx.try_send(chunk.to_vec()).is_err() { - log::warn!( - "[meet-audio] forward channel full; dropping {} bytes request_id={request_id}", - chunk.len() - ); - } - } - } - AudioStreamEvent::Stopped => { - log::info!("[meet-audio] cef stream stopped request_id={request_id}"); - if let Ok(mut r) = resampler.lock() { - r.reset(0); - } - } - AudioStreamEvent::Error(msg) => { - log::warn!("[meet-audio] cef stream error request_id={request_id} msg={msg}"); - } - } -} - -/// Pull chunks off the bounded channel and POST each to core. Lives in -/// its own task so the CEF callback never blocks on HTTP. -fn spawn_forwarder(request_id: String, mut rx: mpsc::Receiver>) { - tauri::async_runtime::spawn(async move { - use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; - while let Some(chunk) = rx.recv().await { - let pcm_b64 = B64.encode(&chunk); - let res = super::rpc_call( - "openhuman.meet_agent_push_listen_pcm", - serde_json::json!({ - "request_id": request_id, - "pcm_base64": pcm_b64, - }), - ) - .await; - if let Err(err) = res { - log::debug!( - "[meet-audio] push_listen_pcm err request_id={request_id} bytes={} err={err}", - chunk.len() - ); - } - } - log::info!("[meet-audio] forwarder exiting request_id={request_id}"); - }); -} - -/// Stateful float32-planar → PCM16LE mono @ 16 kHz resampler. -/// -/// Uses linear interpolation, which is good enough for speech (the -/// downstream STT does not care about ultrasonics or pristine high -/// frequencies). Carry the previous sample across `feed_and_drain` -/// calls so we don't introduce a tick at every CEF buffer boundary. -/// Pick a source sample by signed index. Negative indices return the -/// carry sample from the previous call (so phase < 0 keeps the -/// interpolation continuous across buffer boundaries); past-the-end -/// indices clamp to the last sample (which is what the next call will -/// install as its own carry, so the output stays smooth even if a -/// caller stops feeding mid-stream). -fn sample_at(mono: &[f32], carry: f32, idx: i64) -> f32 { - if idx < 0 { - carry - } else if (idx as usize) < mono.len() { - mono[idx as usize] - } else { - *mono.last().unwrap_or(&0.0) - } -} - -struct Resampler { - source_rate_hz: u32, - /// Fractional position into the source buffer between calls. - /// 0.0 means "start cleanly with the next sample". Negative is - /// not used — the source rate is always known before we feed. - phase: f64, - /// Last source sample of the previous call, used as the "left" - /// neighbour when we interpolate the first sample of the next call. - last_sample: f32, -} - -impl Resampler { - fn new() -> Self { - Self { - source_rate_hz: 0, - phase: 0.0, - last_sample: 0.0, - } - } - - fn reset(&mut self, source_rate_hz: u32) { - self.source_rate_hz = source_rate_hz; - self.phase = 0.0; - self.last_sample = 0.0; - } - - fn feed_and_drain(&mut self, planes: &[Vec]) -> Vec { - if planes.is_empty() || self.source_rate_hz == 0 { - return Vec::new(); - } - let frames = planes[0].len(); - if frames == 0 { - return Vec::new(); - } - // Mono mix. - let mono: Vec = (0..frames) - .map(|i| { - let mut sum = 0.0_f32; - for plane in planes { - if let Some(v) = plane.get(i) { - sum += *v; - } - } - sum / planes.len() as f32 - }) - .collect(); - - let ratio = self.source_rate_hz as f64 / TARGET_SAMPLE_RATE as f64; - let mut out = Vec::with_capacity((mono.len() as f64 / ratio).ceil() as usize * 2); - // `pos` floats through `mono` indices. `pos < 0` means "still - // sampling the carry sample from the previous call"; `pos = 0` - // means "right at mono[0]". - let mut pos = self.phase; - while pos < mono.len() as f64 { - let idx_f = pos.floor(); - let frac = pos - idx_f; - let idx = idx_f as i64; - let s_left = sample_at(mono.as_slice(), self.last_sample, idx); - let s_right = sample_at(mono.as_slice(), self.last_sample, idx + 1); - let sample = s_left as f64 + (s_right as f64 - s_left as f64) * frac; - // Float32 [-1.0, 1.0] → i16. Clamp because Chromium can - // overshoot a touch on heavy compression. - let s_i16 = (sample.clamp(-1.0, 1.0) * i16::MAX as f64) as i16; - out.extend_from_slice(&s_i16.to_le_bytes()); - pos += ratio; - } - // Carry the trailing fractional position into the next call. - // It will be negative when we overshot (next call resumes - // mid-source-sample), so the next call interpolates between - // `last_sample` and the new mono[0]. - self.phase = pos - mono.len() as f64; - self.last_sample = *mono.last().unwrap_or(&0.0); - out - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resampler_with_no_source_rate_yields_nothing() { - let mut r = Resampler::new(); - let out = r.feed_and_drain(&[vec![0.5; 100]]); - assert!(out.is_empty(), "no source rate set, must produce nothing"); - } - - #[test] - fn resampler_48k_to_16k_mono_drops_samples_3to1() { - let mut r = Resampler::new(); - r.reset(48_000); - let plane = vec![0.5_f32; 4_800]; // 100ms @ 48k - let bytes = r.feed_and_drain(&[plane]); - // 100ms @ 16k = 1600 samples * 2 bytes. Allow ±2 samples slop - // from the fractional phase carry. - let samples = bytes.len() / 2; - assert!( - (1598..=1602).contains(&samples), - "expected ~1600 samples, got {samples}" - ); - } - - #[test] - fn resampler_stereo_to_mono_averages_channels() { - let mut r = Resampler::new(); - r.reset(16_000); - let left = vec![0.8_f32; 1600]; - let right = vec![-0.2_f32; 1600]; - let bytes = r.feed_and_drain(&[left, right]); - // Avg = 0.3 → ~9830 in i16. First two bytes are LE i16. - let first = i16::from_le_bytes([bytes[0], bytes[1]]); - assert!( - (9000..11000).contains(&first), - "expected mid-amplitude i16, got {first}" - ); - } - - #[test] - fn resampler_clamps_out_of_range_floats() { - let mut r = Resampler::new(); - r.reset(16_000); - let bytes = r.feed_and_drain(&[vec![5.0_f32; 100]]); - let first = i16::from_le_bytes([bytes[0], bytes[1]]); - assert_eq!(first, i16::MAX); - } - - #[test] - fn resampler_passthrough_when_rates_match() { - let mut r = Resampler::new(); - r.reset(16_000); - let plane = vec![0.5_f32; 1600]; - let bytes = r.feed_and_drain(&[plane]); - assert_eq!(bytes.len(), 1600 * 2); - } -} diff --git a/app/src-tauri/src/meet_audio/mod.rs b/app/src-tauri/src/meet_audio/mod.rs index a60f582b9b..5af6674b49 100644 --- a/app/src-tauri/src/meet_audio/mod.rs +++ b/app/src-tauri/src/meet_audio/mod.rs @@ -2,12 +2,8 @@ //! //! ## Pieces //! -//! - [`listen_capture`] — taps the embedded Meet webview's audio output -//! via the per-browser `CefAudioHandler` exposed by our vendored -//! `tauri-runtime-cef::audio` extension, downsamples to 16 kHz mono -//! PCM16LE, batches into ~100 ms chunks, and posts them to core via -//! `openhuman.meet_agent_push_listen_pcm`. Zero OS-level audio -//! permission needed: we read frames straight out of the renderer. +//! - [`caption_listener`] — drains Meet's built-in captions through CDP +//! and forwards speaker-attributed lines to core. //! //! - [`speak_pump`] — drains synthesized PCM the brain enqueued (via //! `openhuman.meet_agent_poll_speech`) and writes it into the @@ -19,15 +15,12 @@ //! //! [`start`] is invoked once the meet-call window has been built (in //! `meet_call::meet_call_open_window`). It opens the core session, -//! registers the audio handler keyed by the call's URL, and spawns the -//! poll-speech loop. [`stop`] runs from the window-destroyed handler: -//! it drops the audio handler registration (which silences capture -//! immediately), stops the speak pump, and tells core to close the -//! session and report counters. +//! starts the caption listener and poll-speech loop. [`stop`] runs from +//! the window-destroyed handler: it stops both tasks and tells core to +//! close the session and report counters. pub mod caption_listener; pub mod inject; -pub mod listen_capture; pub mod speak_pump; use std::collections::HashMap; @@ -54,14 +47,8 @@ impl MeetAudioState { /// teardown synchronously — no async drop needed because the caption /// listener and speak pump both shut down on signal/drop. /// -/// The legacy CEF-audio `listen_capture::ListenSession` is kept as an -/// optional field so the pre-register flow still has somewhere to -/// hand the registration off if a future build re-enables it. In the -/// caption-driven path it stays `None`. pub struct MeetAudioSession { - pub request_id: String, _captions: caption_listener::CaptionListener, - _legacy_listen: Option, _speak: speak_pump::SpeakPump, } @@ -229,9 +216,7 @@ pub async fn start( state.inner.lock().unwrap().insert( request_id.clone(), MeetAudioSession { - request_id: request_id.clone(), _captions: captions, - _legacy_listen: None, _speak: speak, }, ); @@ -353,11 +338,8 @@ pub(crate) async fn rpc_call( /// No-op caption listener used when CDP attach failed at session /// start. Lets the rest of the lifecycle hold a uniform value. -fn caption_listener_disabled(request_id: String) -> caption_listener::CaptionListener { - caption_listener::CaptionListener { - request_id, - _shutdown_tx: None, - } +fn caption_listener_disabled(_request_id: String) -> caption_listener::CaptionListener { + caption_listener::CaptionListener { _shutdown_tx: None } } /// Trim a string for logging without panicking on multi-byte chars. diff --git a/app/src-tauri/src/meet_audio/speak_pump.rs b/app/src-tauri/src/meet_audio/speak_pump.rs index 2cda72a79d..a8ab64a743 100644 --- a/app/src-tauri/src/meet_audio/speak_pump.rs +++ b/app/src-tauri/src/meet_audio/speak_pump.rs @@ -48,7 +48,6 @@ const SPEAKING_STATE_EVENT: &str = "meet-video:speaking-state"; /// RAII handle. Drop to stop the pump task. The shutdown channel /// causes the spawned loop to exit on the next select tick. pub struct SpeakPump { - pub request_id: String, _shutdown_tx: Option>, } @@ -130,7 +129,6 @@ pub fn start( }); SpeakPump { - request_id, _shutdown_tx: Some(shutdown_tx), } } @@ -277,11 +275,8 @@ fn next_speaking_state( /// No-op pump used when bridge install failed at session start. Keeps /// the rest of the session lifecycle uniform — `MeetAudioSession` can /// still hold a `SpeakPump` regardless of speak-path readiness. -pub fn start_disabled(request_id: String) -> SpeakPump { - SpeakPump { - request_id, - _shutdown_tx: None, - } +pub fn start_disabled(_request_id: String) -> SpeakPump { + SpeakPump { _shutdown_tx: None } } /// Run a single pump tick. Returns `true` when the tick actually diff --git a/app/src-tauri/src/meet_call/mod.rs b/app/src-tauri/src/meet_call/mod.rs index 592aa2789b..809b7355ec 100644 --- a/app/src-tauri/src/meet_call/mod.rs +++ b/app/src-tauri/src/meet_call/mod.rs @@ -134,8 +134,8 @@ pub async fn meet_call_open_window( } // Only one meet-call window can be live at a time — concurrent bot - // sessions race the CEF audio handler registration (`listen_capture`) - // and confuse the user with multiple "Meet — OpenHuman" windows in + // sessions race the shared audio bridge and confuse the user with + // multiple "Meet — OpenHuman" windows in // their Dock. Close any stragglers from a prior Join before opening // a fresh one. The CloseRequested handler will tear down their // scanner + audio session via the per-window event listeners below. diff --git a/app/src-tauri/src/meet_scanner/mod.rs b/app/src-tauri/src/meet_scanner/mod.rs index 9e8525fd9d..1e96ee81bc 100644 --- a/app/src-tauri/src/meet_scanner/mod.rs +++ b/app/src-tauri/src/meet_scanner/mod.rs @@ -35,7 +35,7 @@ use std::time::Duration; use serde_json::{json, Value}; -use tauri::{AppHandle, Manager, Runtime}; +use tauri::{AppHandle, Runtime}; use crate::cdp::{self, CdpConn}; diff --git a/app/src-tauri/src/meet_video/frame_bus.rs b/app/src-tauri/src/meet_video/frame_bus.rs index c8454ba542..537d0bd349 100644 --- a/app/src-tauri/src/meet_video/frame_bus.rs +++ b/app/src-tauri/src/meet_video/frame_bus.rs @@ -191,14 +191,16 @@ async fn handle_connection( // while waiting for the producer's next tick. let writer = tokio::spawn(async move { let initial = latest_rx.borrow().clone(); - if !initial.is_empty() { - if sink + if !initial.is_empty() + && sink .send(Message::Binary((*initial).clone())) .await .is_err() - { - return; - } + { + log::debug!( + "[meet-video] initial WebSocket frame send failed; closing writer peer_transport=disconnected" + ); + return; } while latest_rx.changed().await.is_ok() { let frame = latest_rx.borrow().clone(); @@ -237,7 +239,6 @@ async fn handle_connection( #[cfg(test)] mod tests { use super::*; - use futures_util::{SinkExt as _, StreamExt as _}; use tokio_tungstenite::connect_async; #[tokio::test] diff --git a/app/src-tauri/src/ptt_hotkeys.rs b/app/src-tauri/src/ptt_hotkeys.rs index 743ccadb6c..04956e0401 100644 --- a/app/src-tauri/src/ptt_hotkeys.rs +++ b/app/src-tauri/src/ptt_hotkeys.rs @@ -14,8 +14,6 @@ pub(crate) enum PttError { EmptyShortcut, ModifierOnlyShortcut, ConflictsWithDictation(String), - UnsupportedOnWayland, - RegistrationFailed(String), } impl std::fmt::Display for PttError { @@ -29,13 +27,6 @@ impl std::fmt::Display for PttError { PttError::ConflictsWithDictation(s) => { write!(f, "ptt shortcut '{s}' conflicts with the dictation hotkey") } - PttError::UnsupportedOnWayland => write!( - f, - "global shortcuts are not supported in this Wayland session — switch to X11 or use in-app dictation" - ), - PttError::RegistrationFailed(s) => { - write!(f, "failed to register ptt shortcut: {s}") - } } } } diff --git a/app/src-tauri/src/ptt_overlay.rs b/app/src-tauri/src/ptt_overlay.rs index 90bdfeb50d..6cfb824e68 100644 --- a/app/src-tauri/src/ptt_overlay.rs +++ b/app/src-tauri/src/ptt_overlay.rs @@ -23,7 +23,7 @@ pub(crate) fn ensure_window(app: &AppHandle) -> Result<(), String return Ok(()); } let url = WebviewUrl::App("index.html#/ptt-overlay".into()); - let mut builder = WebviewWindowBuilder::new(app, OVERLAY_LABEL, url) + let builder = WebviewWindowBuilder::new(app, OVERLAY_LABEL, url) .title("OpenHuman Push-to-Talk") .inner_size(160.0, 56.0) .decorations(false) @@ -39,11 +39,9 @@ pub(crate) fn ensure_window(app: &AppHandle) -> Result<(), String .visible(false); #[cfg(target_os = "macos")] - { - builder = builder - .visible_on_all_workspaces(true) - .accept_first_mouse(false); - } + let builder = builder + .visible_on_all_workspaces(true) + .accept_first_mouse(false); let _window = builder .build() diff --git a/app/src-tauri/src/screen_capture/mod.rs b/app/src-tauri/src/screen_capture/mod.rs index a18ac07438..3614fd294d 100644 --- a/app/src-tauri/src/screen_capture/mod.rs +++ b/app/src-tauri/src/screen_capture/mod.rs @@ -82,6 +82,7 @@ pub struct ScreenSource { /// What kind of source a parsed DesktopMediaID-format string describes. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg(any(target_os = "macos", test))] pub(crate) enum SourceKind { Screen, Window, @@ -93,6 +94,7 @@ pub(crate) enum SourceKind { /// match what the enumerator emits. Pure logic so it can be unit-tested /// without touching platform APIs; macOS callers use it before dispatching /// to the capture backend. +#[cfg(any(target_os = "macos", test))] pub(crate) fn parse_source_id(id: &str) -> Option<(SourceKind, u32)> { let mut parts = id.splitn(3, ':'); let kind = match parts.next()? { diff --git a/app/src-tauri/src/slack_scanner/extract.rs b/app/src-tauri/src/slack_scanner/extract.rs index c2a9e40aa9..9b8600b1d7 100644 --- a/app/src-tauri/src/slack_scanner/extract.rs +++ b/app/src-tauri/src/slack_scanner/extract.rs @@ -35,16 +35,16 @@ pub struct ExtractedMessage { pub ts: String, } -/// Main entry: walks every record in the dump and returns -/// `(messages, user_id → display_name, channel_id → name, workspace_name)`. -pub fn harvest( - dump: &IdbDump, -) -> ( +type HarvestResult = ( Vec, HashMap, HashMap, Option, -) { +); + +/// Main entry: walks every record in the dump and returns +/// `(messages, user_id → display_name, channel_id → name, workspace_name)`. +pub fn harvest(dump: &IdbDump) -> HarvestResult { let mut messages: Vec = Vec::new(); let mut users: HashMap = HashMap::new(); let mut channels: HashMap = HashMap::new(); @@ -173,15 +173,13 @@ fn walk( .or_insert_with(|| n.to_string()); } } - 'T' => { - if workspace.is_none() { - if let Some(n) = map - .get("name") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - { - *workspace = Some(n.to_string()); - } + 'T' if workspace.is_none() => { + if let Some(n) = map + .get("name") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + *workspace = Some(n.to_string()); } } _ => {} @@ -222,24 +220,23 @@ fn walk( ); } } - Value::String(s) => { - // Redux-persist default: values are JSON-encoded strings. If - // this string is plausibly JSON, parse and recurse. + Value::String(s) if s.len() > 20 && (s.starts_with('{') || s.starts_with('[')) - && (s.ends_with('}') || s.ends_with(']')) - { - if let Ok(inner) = serde_json::from_str::(s) { - walk( - &inner, - channel_hint, - messages, - users, - channels, - workspace, - depth + 1, - ); - } + && (s.ends_with('}') || s.ends_with(']')) => + { + // Redux-persist default: values are JSON-encoded strings. If + // this string is plausibly JSON, parse and recurse. + if let Ok(inner) = serde_json::from_str::(s) { + walk( + &inner, + channel_hint, + messages, + users, + channels, + workspace, + depth + 1, + ); } } _ => {} diff --git a/app/src-tauri/src/slack_scanner/idb.rs b/app/src-tauri/src/slack_scanner/idb.rs index 2631284ce5..d4f5a9031f 100644 --- a/app/src-tauri/src/slack_scanner/idb.rs +++ b/app/src-tauri/src/slack_scanner/idb.rs @@ -283,7 +283,7 @@ async fn serialize_values( chunk.len() )); } - for ((idx, _), val) in chunk.iter().zip(serialised.into_iter()) { + for ((idx, _), val) in chunk.iter().zip(serialised) { result[*idx] = val; } } diff --git a/app/src-tauri/src/telegram_scanner/extract.rs b/app/src-tauri/src/telegram_scanner/extract.rs index 40e3b41c09..6f9d02e4b7 100644 --- a/app/src-tauri/src/telegram_scanner/extract.rs +++ b/app/src-tauri/src/telegram_scanner/extract.rs @@ -166,16 +166,15 @@ fn walk(v: &Value, peer_hint: Option<&str>, out: &mut Harvest, depth: u32) { walk(vv, peer_hint, out, depth + 1); } } - Value::String(s) => { - // Some tweb stores persist state as JSON-encoded strings. - // Recurse when the shape looks plausibly JSON. + Value::String(s) if s.len() > 20 && (s.starts_with('{') || s.starts_with('[')) - && (s.ends_with('}') || s.ends_with(']')) - { - if let Ok(inner) = serde_json::from_str::(s) { - walk(&inner, peer_hint, out, depth + 1); - } + && (s.ends_with('}') || s.ends_with(']')) => + { + // Some tweb stores persist state as JSON-encoded strings. + // Recurse when the shape looks plausibly JSON. + if let Ok(inner) = serde_json::from_str::(s) { + walk(&inner, peer_hint, out, depth + 1); } } _ => {} diff --git a/app/src-tauri/src/telegram_scanner/idb.rs b/app/src-tauri/src/telegram_scanner/idb.rs index 84d88dd765..5fd1fe173b 100644 --- a/app/src-tauri/src/telegram_scanner/idb.rs +++ b/app/src-tauri/src/telegram_scanner/idb.rs @@ -283,7 +283,7 @@ async fn serialize_values( chunk.len() )); } - for ((idx, _), val) in chunk.iter().zip(serialised.into_iter()) { + for ((idx, _), val) in chunk.iter().zip(serialised) { result[*idx] = val; } } diff --git a/app/src-tauri/src/wechat_scanner/dom_snapshot.rs b/app/src-tauri/src/wechat_scanner/dom_snapshot.rs index b750d9a1b4..5afe84739a 100644 --- a/app/src-tauri/src/wechat_scanner/dom_snapshot.rs +++ b/app/src-tauri/src/wechat_scanner/dom_snapshot.rs @@ -23,7 +23,6 @@ pub struct MessageRow { pub struct DomScan { pub chat_rows: Vec, pub messages: Vec, - pub active_chat_name: Option, pub unread: u32, pub hash: u64, } @@ -71,7 +70,6 @@ pub async fn scan(cdp: &mut CdpConn, session: &str) -> Result { Ok(DomScan { chat_rows, messages, - active_chat_name, unread, hash, }) diff --git a/app/src-tauri/src/whatsapp_scanner/idb.rs b/app/src-tauri/src/whatsapp_scanner/idb.rs index 9a61b6e251..9b8388ab7e 100644 --- a/app/src-tauri/src/whatsapp_scanner/idb.rs +++ b/app/src-tauri/src/whatsapp_scanner/idb.rs @@ -237,7 +237,7 @@ async fn serialize_values( chunk.len() )); } - for ((idx, _), val) in chunk.iter().zip(serialised.into_iter()) { + for ((idx, _), val) in chunk.iter().zip(serialised) { result[*idx] = val; } } diff --git a/app/src-tauri/src/whatsapp_scanner/mod.rs b/app/src-tauri/src/whatsapp_scanner/mod.rs index c43ecdbdf6..bb71050528 100644 --- a/app/src-tauri/src/whatsapp_scanner/mod.rs +++ b/app/src-tauri/src/whatsapp_scanner/mod.rs @@ -29,8 +29,6 @@ use tauri::{AppHandle, Emitter, Runtime}; use tokio::task::AbortHandle; use tokio::time::sleep; -use crate::cdp::CdpConn; - mod dom_snapshot; #[cfg(test)] mod dom_snapshot_tests; @@ -1209,7 +1207,7 @@ fn merge_dom_into_snapshot( continue; } if let Some(mid) = mid_opt { - let bare_mid = mid.rsplitn(2, '_').next().map(str::to_string); + let bare_mid = mid.rsplit('_').next().map(str::to_string); let lookup = by_msg_id .get(&mid) .cloned() diff --git a/package.json b/package.json index 9a7a0fd980..e87ca4f8cc 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "debug": "bash scripts/debug/cli.sh", "test:install-ps1": "pwsh -NoProfile -File scripts/tests/OpenHumanWindowsInstall.Tests.ps1", "rust:check": "pnpm --filter openhuman-app rust:check", + "rust:clippy": "cargo clippy -p openhuman -- -D warnings && pnpm --filter openhuman-app rust:clippy", "typecheck": "pnpm --filter openhuman-app compile", "tauri:ios:init": "bash scripts/ios-init.sh", "tauri:ios:dev": "pnpm --filter openhuman-app tauri:ios:dev", diff --git a/src/bin/memory_tree_init_smoke.rs b/src/bin/memory_tree_init_smoke.rs index bddd11e9c2..f2c7f05f9d 100644 --- a/src/bin/memory_tree_init_smoke.rs +++ b/src/bin/memory_tree_init_smoke.rs @@ -57,8 +57,10 @@ fn main() -> ExitCode { } }; - let mut cfg = Config::default(); - cfg.workspace_dir = workspace.clone(); + let cfg = Config { + workspace_dir: workspace.clone(), + ..Config::default() + }; let db_path = workspace.join("memory_tree").join("chunks.db"); let cold = !db_path.exists(); diff --git a/src/bin/slack_backfill.rs b/src/bin/slack_backfill.rs index 732ac3c2e7..e5b77d41fc 100644 --- a/src/bin/slack_backfill.rs +++ b/src/bin/slack_backfill.rs @@ -323,8 +323,8 @@ async fn main() -> Result<()> { enum Outcome { Ok, Ratelimit, - OtherFail(String), - Transport(String), + OtherFail, + Transport, } let mut outcomes: Vec<(u32, std::time::Duration, Outcome)> = Vec::with_capacity(n as usize); let probe_started = Instant::now(); @@ -342,7 +342,10 @@ async fn main() -> Result<()> { .await; let dt = t0.elapsed(); let outcome = match resp { - Err(e) => Outcome::Transport(format!("{e:#}")), + Err(e) => { + log::warn!("[probe-ratelimit] transport error on call {i}: {e:#}"); + Outcome::Transport + } Ok(r) if r.successful => Outcome::Ok, Ok(r) => { let err = r.error.as_deref().unwrap_or("provider failure"); @@ -356,7 +359,11 @@ async fn main() -> Result<()> { ); Outcome::Ratelimit } else { - Outcome::OtherFail(err.to_string()) + log::warn!( + "[probe-ratelimit] provider failure on call {i}: {}", + sanitize_probe_error(err) + ); + Outcome::OtherFail } } }; @@ -379,11 +386,11 @@ async fn main() -> Result<()> { .count(); let other = outcomes .iter() - .filter(|(_, _, o)| matches!(o, Outcome::OtherFail(_))) + .filter(|(_, _, o)| matches!(o, Outcome::OtherFail)) .count(); let transport = outcomes .iter() - .filter(|(_, _, o)| matches!(o, Outcome::Transport(_))) + .filter(|(_, _, o)| matches!(o, Outcome::Transport)) .count(); let avg_ms = if !outcomes.is_empty() { outcomes.iter().map(|(_, d, _)| d.as_millis()).sum::() / outcomes.len() as u128 @@ -568,3 +575,27 @@ fn component_status(endpoint: &Option, model: &Option) -> String _ => "off".to_string(), } } + +fn sanitize_probe_error(error: &str) -> String { + let single_line = error.split_whitespace().collect::>().join(" "); + openhuman_core::openhuman::inference::provider::ops::sanitize_api_error(&single_line) +} + +#[cfg(test)] +mod tests { + use super::sanitize_probe_error; + + #[test] + fn probe_error_summary_is_single_line_bounded_and_secret_safe() { + let raw = format!( + "provider failed\nwith xoxb-secret-token {}", + "x".repeat(300) + ); + let summary = sanitize_probe_error(&raw); + + assert!(!summary.contains('\n')); + assert!(!summary.contains("xoxb-secret-token")); + assert!(summary.contains("[REDACTED]")); + assert!(summary.chars().count() <= 203); + } +} diff --git a/src/core/cli.rs b/src/core/cli.rs index 014b75c08a..2d6b55be97 100644 --- a/src/core/cli.rs +++ b/src/core/cli.rs @@ -14,10 +14,6 @@ use crate::core::jsonrpc::{default_state, invoke_method, parse_json_params}; use crate::core::logging::CliLogDefault; use crate::core::{ControllerSchema, TypeSchema}; -/// Debug/e2e agent paths can build deep async poll stacks while assembling -/// prompts, provider requests, and sub-agent tool loops. -const CLI_RUNTIME_THREAD_STACK_SIZE: usize = 8 * 1024 * 1024; - /// The ASCII banner displayed when the CLI starts. const CLI_BANNER: &str = r#" @@ -450,14 +446,6 @@ fn run_namespace_command( Ok(()) } -fn build_cli_runtime() -> Result { - tokio::runtime::Builder::new_multi_thread() - .thread_stack_size(CLI_RUNTIME_THREAD_STACK_SIZE) - .enable_all() - .build() - .map_err(Into::into) -} - /// Parses command-line arguments into a JSON map based on a function's schema. /// /// # Arguments diff --git a/src/openhuman/accessibility/automate.rs b/src/openhuman/accessibility/automate.rs index 51e197d9f7..960b91f648 100644 --- a/src/openhuman/accessibility/automate.rs +++ b/src/openhuman/accessibility/automate.rs @@ -664,7 +664,7 @@ impl AutomateBackend for RealBackend { // `summarization` keeps M1 free of Config-schema churn while still keeping // the chat model out of the loop. use tinyagents::harness::message::Message; - use tinyagents::harness::model::{ChatModel, ModelRequest}; + use tinyagents::harness::model::ModelRequest; let model = crate::openhuman::inference::provider::create_chat_model( "summarization", &self.config, diff --git a/src/openhuman/accessibility/mod.rs b/src/openhuman/accessibility/mod.rs index a7b1de0ab6..a88ba08607 100644 --- a/src/openhuman/accessibility/mod.rs +++ b/src/openhuman/accessibility/mod.rs @@ -28,8 +28,6 @@ mod vision_click; #[cfg(target_os = "windows")] mod uia_interact; -#[cfg(test)] -pub(crate) use automation_state::test_lock as automation_state_test_lock; pub use automation_state::{ clear as clear_automation_denial, mark_system_events_denied, system_events_denied, }; diff --git a/src/openhuman/accessibility/permissions_tests.rs b/src/openhuman/accessibility/permissions_tests.rs index 2009636aac..671cedab3f 100644 --- a/src/openhuman/accessibility/permissions_tests.rs +++ b/src/openhuman/accessibility/permissions_tests.rs @@ -158,8 +158,7 @@ fn permission_state_serde_round_trip() { mod automation_state_stale_cache { use crate::openhuman::accessibility::automation_state; use crate::openhuman::accessibility::{ - automation_state_test_lock, clear_automation_denial, mark_system_events_denied, - system_events_denied, + clear_automation_denial, mark_system_events_denied, system_events_denied, }; #[test] diff --git a/src/openhuman/agent/harness/archivist/mod.rs b/src/openhuman/agent/harness/archivist/mod.rs index 7f9887c7b2..d2943f7ba1 100644 --- a/src/openhuman/agent/harness/archivist/mod.rs +++ b/src/openhuman/agent/harness/archivist/mod.rs @@ -30,8 +30,6 @@ pub use types::ArchivistHook; #[cfg(test)] pub(crate) use crate::openhuman::agent::hooks::PostTurnHook; #[cfg(test)] -pub(crate) use crate::openhuman::config::Config; -#[cfg(test)] pub(crate) use crate::openhuman::memory_store::profile; #[cfg(test)] pub(crate) use helpers::extract_profile_key; diff --git a/src/openhuman/agent/harness/archivist/recap.rs b/src/openhuman/agent/harness/archivist/recap.rs index 2ad95b3ec0..ab7cf2d5bc 100644 --- a/src/openhuman/agent/harness/archivist/recap.rs +++ b/src/openhuman/agent/harness/archivist/recap.rs @@ -64,6 +64,7 @@ impl ArchivistHook { /// - NEVER mutates DB state (no `segment_set_summary`, no embedding). /// - NEVER closes a segment. /// - Safe to call on both open and closed segments. + /// /// Summarize a set of episodic entries into a recap string. /// /// Returns `(text, produced_by_llm)`. `produced_by_llm == false` means the diff --git a/src/openhuman/agent/harness/harness_gap_tests.rs b/src/openhuman/agent/harness/harness_gap_tests.rs index 668f3dbf61..7d624553cb 100644 --- a/src/openhuman/agent/harness/harness_gap_tests.rs +++ b/src/openhuman/agent/harness/harness_gap_tests.rs @@ -23,10 +23,9 @@ //! - `` XML attribute form — the parser does not parse attributes; //! only the tag body (JSON) is used. -use crate::openhuman::agent::error::AgentError; use crate::openhuman::inference::provider::traits::ProviderCapabilities; use crate::openhuman::inference::provider::Provider; -use crate::openhuman::inference::provider::{ChatMessage, ChatRequest, ChatResponse, UsageInfo}; +use crate::openhuman::inference::provider::{ChatRequest, ChatResponse}; use crate::openhuman::tool_timeout::parse_tool_timeout_secs; use crate::openhuman::tools::{Tool, ToolResult}; use async_trait::async_trait; diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index e454a80b20..2c02861a2a 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -491,7 +491,7 @@ impl Agent { )?; let supports_native = resolved_chat_model .profile() - .map_or(true, |profile| profile.tool_calling); + .is_none_or(|profile| profile.tool_calling); log::info!( "[session-builder] agent_id={} provider_role={} resolved_model={} supports_native_tools={}", agent_id, diff --git a/src/openhuman/agent/harness/session/transcript_tests.rs b/src/openhuman/agent/harness/session/transcript_tests.rs index cb35c7f89a..333d1b9777 100644 --- a/src/openhuman/agent/harness/session/transcript_tests.rs +++ b/src/openhuman/agent/harness/session/transcript_tests.rs @@ -871,7 +871,7 @@ fn read_thread_usage_summary_sums_multiple_transcripts() { let raw = raw_session_dir(ws.path()); std::fs::create_dir_all(&raw).unwrap(); - let mut mk = |stem: &str, input: u64, cost: f64| { + let mk = |stem: &str, input: u64, cost: f64| { let mut meta = sample_meta(); meta.thread_id = Some("thr-multi".into()); meta.input_tokens = input; @@ -928,7 +928,7 @@ fn read_thread_usage_summary_groups_subagents_by_archetype() { .unwrap(); // Sub-agent transcripts (stems contain `__`): coder x2 + researcher x1. - let mut sub = |stem: &str, agent: &str, input: u64, output: u64| { + let sub = |stem: &str, agent: &str, input: u64, output: u64| { let mut m = sample_meta(); m.thread_id = Some("thr-sub".into()); m.agent_name = agent.into(); diff --git a/src/openhuman/agent/harness/session/turn/session_io.rs b/src/openhuman/agent/harness/session/turn/session_io.rs index cce7b01509..e98911a9a2 100644 --- a/src/openhuman/agent/harness/session/turn/session_io.rs +++ b/src/openhuman/agent/harness/session/turn/session_io.rs @@ -5,7 +5,9 @@ use super::super::types::Agent; use crate::openhuman::agent::harness; use crate::openhuman::agent::progress::AgentProgress; use crate::openhuman::context::ARCHIVIST_EXTRACTION_PROMPT; -use crate::openhuman::inference::provider::{ChatMessage, UsageInfo, AGENT_TURN_MAX_OUTPUT_TOKENS}; +use crate::openhuman::inference::provider::{ + ChatMessage, ChatResponse, UsageInfo, AGENT_TURN_MAX_OUTPUT_TOKENS, +}; use futures::StreamExt; use tinyagents::harness::model::{ModelRequest, ModelStreamItem}; @@ -69,8 +71,9 @@ impl Agent { /// Ask the provider for a short wrap-up message with native tools /// **disabled** so the model returns prose rather than another tool call. - /// Streams text deltas to the progress sink (when attached) so the summary - /// appears in the UI like any other reply. + /// Buffers text deltas and forwards them to the progress sink (when + /// attached) only after the completed response is validated as prose, so + /// prompt-formatted tool calls cannot flash in the UI before fallback. /// /// `instruction` is the synthetic user turn that steers the wrap-up — the /// tool-call-cap checkpoint (`MAX_ITER_CHECKPOINT_INSTRUCTION`) or the @@ -128,19 +131,13 @@ impl Agent { }; let mut streamed_text = String::new(); + let mut streamed_deltas = Vec::new(); let mut completed = None; while let Some(item) = stream.next().await { match item { ModelStreamItem::MessageDelta(delta) if !delta.text.is_empty() => { streamed_text.push_str(&delta.text); - if let Some(sink) = &self.on_progress { - let _ = sink - .send(AgentProgress::TextDelta { - delta: delta.text, - iteration: iteration_for_stream, - }) - .await; - } + streamed_deltas.push(delta.text); } ModelStreamItem::Completed(response) => completed = Some(response), ModelStreamItem::Failed(error) => { @@ -163,10 +160,49 @@ impl Agent { let checkpoint = if !text.trim().is_empty() { text } else if response.tool_calls().is_empty() { - streamed_text + streamed_text.clone() } else { String::new() }; + if !checkpoint.trim().is_empty() { + let mut provider_response = ChatResponse { + text: Some(checkpoint.clone()), + tool_calls: Vec::new(), + usage: None, + reasoning_content: None, + }; + let (_, mut prompt_tool_calls) = + self.tool_dispatcher.parse_response(&provider_response); + if streamed_text != checkpoint { + provider_response.text = Some(streamed_text); + let (_, streamed_tool_calls) = + self.tool_dispatcher.parse_response(&provider_response); + prompt_tool_calls.extend(streamed_tool_calls); + } + if !prompt_tool_calls.is_empty() { + tracing::warn!( + parsed_tool_calls = prompt_tool_calls.len(), + "[agent::session] wrap-up model returned a prompt-formatted tool call; using deterministic fallback" + ); + return (String::new(), usage); + } + tracing::debug!( + checkpoint_chars = checkpoint.chars().count(), + buffered_deltas = streamed_deltas.len(), + iteration = iteration_for_stream, + "[agent::session] wrap-up checkpoint validation passed" + ); + if let Some(sink) = &self.on_progress { + for delta in streamed_deltas { + let _ = sink + .send(AgentProgress::TextDelta { + delta, + iteration: iteration_for_stream, + }) + .await; + } + } + } (checkpoint, usage) } diff --git a/src/openhuman/agent/harness/session/turn_tests.rs b/src/openhuman/agent/harness/session/turn_tests.rs index ae4afa6870..c837f21c98 100644 --- a/src/openhuman/agent/harness/session/turn_tests.rs +++ b/src/openhuman/agent/harness/session/turn_tests.rs @@ -1,5 +1,4 @@ use super::*; -use crate::core::event_bus::{global, init_global, DomainEvent}; use crate::openhuman::agent::dispatcher::XmlToolDispatcher; use crate::openhuman::agent::hooks::{PostTurnHook, TurnContext}; use crate::openhuman::agent::tool_policy::{ @@ -8,8 +7,7 @@ use crate::openhuman::agent::tool_policy::{ }; use crate::openhuman::agent_memory::memory_loader::MemoryLoader; use crate::openhuman::inference::provider::{ - ChatMessage, ChatRequest, ChatResponse, ConversationMessage, Provider, ToolResultMessage, - UsageInfo, + ChatMessage, ChatRequest, ChatResponse, ConversationMessage, Provider, UsageInfo, }; use crate::openhuman::memory::Memory; use crate::openhuman::tools::ToolResult; @@ -20,7 +18,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use tokio::sync::Mutex as AsyncMutex; use tokio::sync::Notify; -use tokio::time::{sleep, timeout, Duration}; +use tokio::time::{timeout, Duration}; struct DummyProvider; @@ -1197,6 +1195,49 @@ async fn turn_final_answer_falls_back_to_deterministic_summary_when_reprompt_emp ); } +#[tokio::test] +async fn summarize_turn_wrapup_rejects_prompt_tool_call_and_preserves_usage() { + let provider: Arc = Arc::new(SequenceProvider { + responses: AsyncMutex::new(vec![Ok(ChatResponse { + text: Some("{\"name\":\"echo\",\"arguments\":{}}".into()), + tool_calls: vec![], + usage: Some(UsageInfo { + input_tokens: 13, + output_tokens: 5, + cached_input_tokens: 3, + charged_amount_usd: 0.07, + ..UsageInfo::default() + }), + reasoning_content: None, + })]), + requests: AsyncMutex::new(Vec::new()), + }); + let agent = make_agent_with_builder( + provider, + vec![], + Box::new(FixedMemoryLoader { + context: String::new(), + }), + vec![], + crate::openhuman::config::AgentConfig::default(), + crate::openhuman::config::ContextConfig::default(), + ); + + let (summary, usage) = agent + .summarize_turn_wrapup(&[], "test-model", 1, "write a wrap-up") + .await; + + assert!( + summary.is_empty(), + "prompt-formatted tool calls must trigger the deterministic fallback" + ); + let usage = usage.expect("rejected wrap-up must preserve provider usage"); + assert_eq!(usage.input_tokens, 13); + assert_eq!(usage.output_tokens, 5); + assert_eq!(usage.cached_input_tokens, 3); + assert_eq!(usage.charged_amount_usd, 0.07); +} + #[tokio::test] async fn turn_checkpoint_usage_is_folded_into_transcript_accounting() { // The extra checkpoint provider call costs tokens; those must land in diff --git a/src/openhuman/agent/harness/subagent_runner/ops/mod.rs b/src/openhuman/agent/harness/subagent_runner/ops/mod.rs index c8ba6c102c..9e0ea2692c 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops/mod.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops/mod.rs @@ -61,9 +61,7 @@ pub(super) use provider::LazyToolkitResolver; pub(super) use super::tool_prep::filter_tool_indices; // Types used by tests that were previously in scope via the flat ops.rs imports. #[cfg(test)] -pub(super) use super::types::{ - SubagentMode, SubagentRunError, SubagentRunOptions, SubagentRunOutcome, -}; +pub(super) use super::types::{SubagentMode, SubagentRunError, SubagentRunOptions}; #[cfg(test)] pub(super) use crate::openhuman::agent::harness::definition::{AgentDefinition, PromptSource}; #[cfg(test)] diff --git a/src/openhuman/agent/harness/tests.rs b/src/openhuman/agent/harness/tests.rs index 472e5463d3..aa73dc0e3b 100644 --- a/src/openhuman/agent/harness/tests.rs +++ b/src/openhuman/agent/harness/tests.rs @@ -5,10 +5,9 @@ use super::parse::{ parse_tool_calls, parse_tool_calls_from_json_value, tools_to_openai_format, }; use crate::openhuman::inference::provider::traits::ProviderCapabilities; -use crate::openhuman::inference::provider::{ChatMessage, ChatRequest, ChatResponse, Provider}; -use crate::openhuman::tools::{self, Tool}; +use crate::openhuman::inference::provider::{ChatRequest, ChatResponse, Provider}; +use crate::openhuman::tools; use async_trait::async_trait; -use base64::{engine::general_purpose::STANDARD, Engine as _}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; diff --git a/src/openhuman/agent/harness/tool_filter.rs b/src/openhuman/agent/harness/tool_filter.rs index 7b988c651d..5b375d7f17 100644 --- a/src/openhuman/agent/harness/tool_filter.rs +++ b/src/openhuman/agent/harness/tool_filter.rs @@ -81,7 +81,7 @@ pub fn filter_actions_by_prompt( }) .collect(); - scored.sort_by(|a, b| b.0.cmp(&a.0)); + scored.sort_by_key(|item| std::cmp::Reverse(item.0)); // Only keep positively-scored results. Zero-overlap tools would add noise. scored diff --git a/src/openhuman/agent/progress_tracing.rs b/src/openhuman/agent/progress_tracing.rs index b3d7074b51..87577062fd 100644 --- a/src/openhuman/agent/progress_tracing.rs +++ b/src/openhuman/agent/progress_tracing.rs @@ -774,7 +774,7 @@ impl SpanCollector { ("gen_ai.usage.reasoning_tokens", reasoning_tokens), ("gen_ai.usage.cache_creation_tokens", cache_creation_tokens), ] { - if add == 0 && span.attributes.get(key).is_none() { + if add == 0 && !span.attributes.contains_key(key) { continue; } let prior = span diff --git a/src/openhuman/agent/schemas.rs b/src/openhuman/agent/schemas.rs index bbae99bdec..c095855163 100644 --- a/src/openhuman/agent/schemas.rs +++ b/src/openhuman/agent/schemas.rs @@ -519,7 +519,6 @@ fn local_catalog_models_from_config( fn handle_registry_snapshot(_params: Map) -> ControllerFuture { Box::pin(async { - use crate::openhuman::tools::Tool as _; use tinyagents::registry::{ComponentKind, ComponentMetadata, RegistrySnapshot}; let mut components: Vec = Vec::new(); diff --git a/src/openhuman/agent_meetings/summary.rs b/src/openhuman/agent_meetings/summary.rs index 45dfbf862f..bb8470345d 100644 --- a/src/openhuman/agent_meetings/summary.rs +++ b/src/openhuman/agent_meetings/summary.rs @@ -17,7 +17,7 @@ use crate::core::event_bus::BackendMeetTurn; use crate::openhuman::config::{AutoSummarizePolicy, Config}; use crate::openhuman::inference::provider::create_chat_model; use tinyagents::harness::message::Message; -use tinyagents::harness::model::{ChatModel, ModelRequest}; +use tinyagents::harness::model::ModelRequest; use super::types::{ActionItem, ActionItemKind, MeetingSummary}; diff --git a/src/openhuman/agent_memory/tools.rs b/src/openhuman/agent_memory/tools.rs index 42215af356..468ebf8b7d 100644 --- a/src/openhuman/agent_memory/tools.rs +++ b/src/openhuman/agent_memory/tools.rs @@ -116,7 +116,7 @@ impl Tool for CallMemoryAgentTool { let max_turns = args .get("max_turns") .and_then(|v| v.as_u64()) - .map(|v| v.max(1).min(20) as usize); + .map(|v| v.clamp(1, 20) as usize); let is_async = args.get("async").and_then(|v| v.as_bool()).unwrap_or(false); diff --git a/src/openhuman/agent_orchestration/background_delivery.rs b/src/openhuman/agent_orchestration/background_delivery.rs index b1b0e0df21..bae691c788 100644 --- a/src/openhuman/agent_orchestration/background_delivery.rs +++ b/src/openhuman/agent_orchestration/background_delivery.rs @@ -148,29 +148,30 @@ async fn try_deliver(session: String) { .remove(&session); return; } - match ( + if let (Some(thread_id), Some(notice)) = ( background_completions::batch_thread_id(&batch), background_completions::build_batched_notice(&batch), ) { - (Some(thread_id), Some(notice)) => { - log::info!( - "[background_delivery] delivering {} batched background result(s) \ - session={session} thread_id={thread_id}", - batch.len() + log::info!( + "[background_delivery] delivering {} batched background result(s) \ + session={session} thread_id={thread_id}", + batch.len() + ); + if let Err(e) = crate::openhuman::agent::task_dispatcher::run_system_turn_on_thread( + thread_id, notice, + ) + .await + { + log::warn!( + "[background_delivery] delivery turn failed session={session} error={e}" ); - if let Err(e) = crate::openhuman::agent::task_dispatcher::run_system_turn_on_thread( - thread_id, notice, - ) - .await - { - log::warn!( - "[background_delivery] delivery turn failed session={session} error={e}" - ); - requeue(&session, batch); // don't lose results on a failed turn - } + requeue(&session, batch); // don't lose results on a failed turn } - // Headless (no originating thread to stream into) — drop the batch. - _ => {} + } else { + log::warn!( + "[background_delivery] dropping headless batch session={session} count={}", + batch.len() + ); } } diff --git a/src/openhuman/agent_orchestration/running_subagents.rs b/src/openhuman/agent_orchestration/running_subagents.rs index 4b26363af6..1319585c74 100644 --- a/src/openhuman/agent_orchestration/running_subagents.rs +++ b/src/openhuman/agent_orchestration/running_subagents.rs @@ -847,7 +847,7 @@ pub(crate) fn task_id_for_session_in_workspace( .into_iter() .filter(|record| record_subagent_session_id(record) == Some(subagent_session_id)) .collect(); - matches.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + matches.sort_by_key(|item| std::cmp::Reverse(item.updated_at)); for record in matches { if record_parent_session(&record) != Some(parent_session) { diff --git a/src/openhuman/agent_orchestration/spawn_parallel_graph.rs b/src/openhuman/agent_orchestration/spawn_parallel_graph.rs index 69f66533e5..f449314226 100644 --- a/src/openhuman/agent_orchestration/spawn_parallel_graph.rs +++ b/src/openhuman/agent_orchestration/spawn_parallel_graph.rs @@ -409,7 +409,7 @@ fn snapshot_agent_definitions( .collect() } -pub(crate) fn prepare_spawn_parallel_tasks_from_defs( +pub(super) fn prepare_spawn_parallel_tasks_from_defs( tasks: Vec, definitions: &HashMap, parent: &ParentExecutionContext, diff --git a/src/openhuman/agent_registry/agents/loader.rs b/src/openhuman/agent_registry/agents/loader.rs index bcaa34a0cf..1a9111fa13 100644 --- a/src/openhuman/agent_registry/agents/loader.rs +++ b/src/openhuman/agent_registry/agents/loader.rs @@ -1001,18 +1001,20 @@ mod tests { } #[test] - fn workflow_builder_is_registered_worker_with_narrow_propose_or_read_scope() { + fn workflow_builder_is_registered_worker_with_bounded_authoring_scope() { // Phase 5a/5b: the workflow-builder must be a Worker-tier leaf whose - // tool scope is EXACTLY the propose-or-read + Composio discovery/connect - // + confirmed test-run + save-onto-existing belt — no flow creation - // (flows_create/set_enabled), no shell, no file writes, no channel - // sends, and no composio_execute. It can list toolkits/connections, + // tool scope is EXACTLY the bounded authoring/read + Composio + // discovery/connect belt. Creation is limited to `create_workflow` + // and `duplicate_flow`, which always produce disabled flows; the raw + // flows_create/update/set_enabled tools remain unavailable, as do + // shell, file writes, channel sends, and composio_execute. It can list + // toolkits/connections, // raise the inline connect card, `run_flow` a flow the user already // SAVED to test it (a real run the prompt gates behind user // confirmation), and `save_workflow` a built graph onto a flow the host // ALREADY created (the prompt bar's instant-create path) — but it can - // never create/enable a flow or perform an arbitrary raw integration - // action. One narrow, deliberate carve-out (B12): `get_tool_output_sample` + // never enable a flow or perform an arbitrary raw integration action. + // One narrow, deliberate carve-out (B12): `get_tool_output_sample` // DOES make a real Composio call, but only ever a Read-scope one // (hard-refused otherwise, regardless of the user's scope preference) // against an already-connected toolkit — see `builder_tools.rs`'s @@ -1090,14 +1092,12 @@ mod tests { assert_eq!( names.len(), expected.len(), - "workflow_builder scope must be EXACTLY the propose-or-read belt (got {names:?})" + "workflow_builder scope must be EXACTLY the bounded authoring belt (got {names:?})" ); - // Hard exclusions: nothing that reaches the raw flow - // controller directly, executes raw integration actions, or - // touches the host. - // (Persistence onto an EXISTING flow is the deliberate - // `save_workflow` carve-out above; raw `flows_update` — which - // could also rename/re-gate arbitrary flows — stays out.) + // Hard exclusions: no unrestricted flow mutation, raw + // integration actions, or host access. Creation is exposed + // only through the bounded tools above; raw `flows_update` + // could rename or re-gate arbitrary flows, so it stays out. for forbidden in [ "flows_create", "flows_update", @@ -1113,7 +1113,7 @@ mod tests { ] { assert!( !names.iter().any(|n| n == forbidden), - "workflow_builder must NOT have `{forbidden}` — propose/read only" + "workflow_builder must NOT have unrestricted tool `{forbidden}`" ); } } diff --git a/src/openhuman/agentbox/store_tests.rs b/src/openhuman/agentbox/store_tests.rs index 052ffc8d75..10ddd51101 100644 --- a/src/openhuman/agentbox/store_tests.rs +++ b/src/openhuman/agentbox/store_tests.rs @@ -1,5 +1,5 @@ use super::store::JobStore; -use super::types::{JobRecord, JobStatus, RunResult}; +use super::types::{JobStatus, RunResult}; use std::time::Duration; #[test] diff --git a/src/openhuman/artifacts/store.rs b/src/openhuman/artifacts/store.rs index f895f97077..9d21de44b1 100644 --- a/src/openhuman/artifacts/store.rs +++ b/src/openhuman/artifacts/store.rs @@ -208,7 +208,7 @@ pub(crate) async fn list_artifacts( } // Sort descending by created_at (newest first) - all.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + all.sort_by_key(|item| std::cmp::Reverse(item.created_at)); // Apply thread filter BEFORE pagination so `total` reflects the // per-thread count the UI surfaces, and so a small page doesn't get diff --git a/src/openhuman/audio_toolkit/ops.rs b/src/openhuman/audio_toolkit/ops.rs index 22ea57ec15..fb77b99a5b 100644 --- a/src/openhuman/audio_toolkit/ops.rs +++ b/src/openhuman/audio_toolkit/ops.rs @@ -211,7 +211,7 @@ pub fn resolve_email_capture_dir(config: &Config) -> Option { } #[cfg(feature = "e2e-test-support")] { - return Some(config.workspace_dir.join(DEFAULT_CAPTURE_DIR)); + Some(config.workspace_dir.join(DEFAULT_CAPTURE_DIR)) } #[cfg(not(feature = "e2e-test-support"))] { diff --git a/src/openhuman/channels/proactive.rs b/src/openhuman/channels/proactive.rs index 54af0190d2..9298fb9dea 100644 --- a/src/openhuman/channels/proactive.rs +++ b/src/openhuman/channels/proactive.rs @@ -112,10 +112,13 @@ impl ProactiveMessageSubscriber { /// unit tests — so [`set_runtime_active_channel`] is a no-op there and never /// leaks across the parallel test suite. The choice is also persisted to /// `config.channels_config.active_channel`, which seeds the handle on next start. -static ACTIVE_CHANNEL_HANDLE: std::sync::OnceLock>>>>> = +type ActiveChannelHandle = Arc>>; +type ActiveChannelHandleSlot = RwLock>; + +static ACTIVE_CHANNEL_HANDLE: std::sync::OnceLock = std::sync::OnceLock::new(); -fn active_channel_handle_slot() -> &'static RwLock>>>> { +fn active_channel_handle_slot() -> &'static ActiveChannelHandleSlot { ACTIVE_CHANNEL_HANDLE.get_or_init(|| RwLock::new(None)) } diff --git a/src/openhuman/composio/tools/direct.rs b/src/openhuman/composio/tools/direct.rs index 1e35a0f2ad..baeddfafb5 100644 --- a/src/openhuman/composio/tools/direct.rs +++ b/src/openhuman/composio/tools/direct.rs @@ -783,10 +783,10 @@ impl Tool for ComposioTool { // tool call itself has no outbound side effect to gate. // `action="execute"` (or anything unknown / missing) is the // write path and routes through the approval gate. - match args.get("action").and_then(|v| v.as_str()) { - Some("list") | Some("connect") => false, - _ => true, - } + !matches!( + args.get("action").and_then(|v| v.as_str()), + Some("list") | Some("connect") + ) } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { diff --git a/src/openhuman/config/ops/mod.rs b/src/openhuman/config/ops/mod.rs index 53558d3acd..1a24b834dc 100644 --- a/src/openhuman/config/ops/mod.rs +++ b/src/openhuman/config/ops/mod.rs @@ -33,8 +33,8 @@ pub(crate) use crate::openhuman::config::Config; #[cfg(test)] pub(crate) use loader::{ active_workspace_marker_path, config_openhuman_dir, default_openhuman_dir, env_flag_enabled, - fallback_workspace_dir, reset_local_data_for_paths, reset_local_data_remove_error, - BROWSER_ALLOW_ALL_ENV, BROWSER_ALLOW_ALL_RPC_ENABLE_ENV, + fallback_workspace_dir, reset_local_data_for_paths, BROWSER_ALLOW_ALL_ENV, + BROWSER_ALLOW_ALL_RPC_ENABLE_ENV, }; #[cfg(test)] pub(crate) use std::path::PathBuf; diff --git a/src/openhuman/config/schema/load/mod.rs b/src/openhuman/config/schema/load/mod.rs index 828e5d001c..72e52588d5 100644 --- a/src/openhuman/config/schema/load/mod.rs +++ b/src/openhuman/config/schema/load/mod.rs @@ -32,8 +32,6 @@ pub(crate) use super::Config; #[cfg(test)] pub(crate) use dirs::ACTIVE_USER_STATE_FILE; #[cfg(test)] -pub(crate) use env::ProcessEnvWithoutWorkspace; -#[cfg(test)] pub(crate) use impl_load::parse_config_with_recovery; #[cfg(test)] pub(crate) use migrate::{migrate_cloud_provider_slugs, migrate_legacy_inference_url}; diff --git a/src/openhuman/config/schema/load_tests.rs b/src/openhuman/config/schema/load_tests.rs index 1e16eaa594..9e87902ef2 100644 --- a/src/openhuman/config/schema/load_tests.rs +++ b/src/openhuman/config/schema/load_tests.rs @@ -1360,7 +1360,7 @@ async fn test_save_preserves_backup_file() { let config_path = tmp.path().join("config.toml"); let backup_path = tmp.path().join("config.toml.bak"); - let mut config = Config { + let config = Config { config_path: config_path.clone(), workspace_dir: tmp.path().join("workspace"), action_dir: tmp.path().join("workspace"), @@ -1387,7 +1387,7 @@ async fn test_save_then_corrupt_then_recover() { let tmp = tempfile::tempdir().unwrap(); let config_path = tmp.path().join("config.toml"); - let mut config = Config { + let config = Config { config_path: config_path.clone(), workspace_dir: tmp.path().join("workspace"), action_dir: tmp.path().join("workspace"), diff --git a/src/openhuman/config/schemas/mod.rs b/src/openhuman/config/schemas/mod.rs index 9981018657..aa31c8df4a 100644 --- a/src/openhuman/config/schemas/mod.rs +++ b/src/openhuman/config/schemas/mod.rs @@ -17,18 +17,11 @@ use controllers::{ #[cfg(test)] use helpers::{ deserialize_params, json_output, optional_bool, optional_json, optional_string, - required_string, to_json, ActivityLevelSettingsUpdate, AgentPathsUpdate, AgentSettingsUpdate, - AnalyticsSettingsUpdate, AutonomySettingsUpdate, BrowserSettingsUpdate, - ComposioTriggerSettingsUpdate, DictationSettingsUpdate, LocalAiSettingsUpdate, - MeetSettingsUpdate, MemorySettingsUpdate, MemorySyncSettingsUpdate, ModelSettingsUpdate, - OnboardingCompletedSetParams, RuntimeSettingsUpdate, SandboxSettingsUpdate, - ScreenIntelligenceSettingsUpdate, SearchSettingsUpdate, SetBrowserAllowAllParams, - VoiceServerSettingsUpdate, WorkspaceOnboardingFlagParams, WorkspaceOnboardingFlagSetParams, - DEFAULT_ONBOARDING_FLAG_NAME, + required_string, to_json, AutonomySettingsUpdate, LocalAiSettingsUpdate, MemorySettingsUpdate, + ModelSettingsUpdate, OnboardingCompletedSetParams, SetBrowserAllowAllParams, + WorkspaceOnboardingFlagParams, WorkspaceOnboardingFlagSetParams, DEFAULT_ONBOARDING_FLAG_NAME, }; #[cfg(test)] -use schema_defs::schemas; -#[cfg(test)] use serde_json::{Map, Value}; #[cfg(test)] diff --git a/src/openhuman/credentials/profiles.rs b/src/openhuman/credentials/profiles.rs index 8258f1b9f5..5b6b0e7f77 100644 --- a/src/openhuman/credentials/profiles.rs +++ b/src/openhuman/credentials/profiles.rs @@ -70,6 +70,16 @@ const LOCK_TIMEOUT_MS: u64 = STALE_LOCK_AGE_MS + 5_000; const PERSIST_RETRY_ATTEMPTS: u32 = 6; const PERSIST_RETRY_BASE_MS: u64 = 100; +type EncryptedProfileFields = ( + Option, + Option, + Option, + Option, + Option, + Option, + Option, +); + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum AuthProfileKind { @@ -921,18 +931,7 @@ impl AuthProfilesStore { } /// Encrypt a profile's secret fields for JSON storage (keychain-unavailable path). - fn encrypt_for_json( - &self, - profile: &AuthProfile, - ) -> Result<( - Option, - Option, - Option, - Option, - Option, - Option, - Option, - )> { + fn encrypt_for_json(&self, profile: &AuthProfile) -> Result { let (access_token, refresh_token, id_token, expires_at, token_type, scope) = match (&profile.kind, &profile.token_set) { (AuthProfileKind::OAuth, Some(token_set)) => ( diff --git a/src/openhuman/cron/tools/add.rs b/src/openhuman/cron/tools/add.rs index 5f6e76fda0..efe2e75bb1 100644 --- a/src/openhuman/cron/tools/add.rs +++ b/src/openhuman/cron/tools/add.rs @@ -63,7 +63,7 @@ fn validate_delivery(config: &Config, delivery: &DeliveryConfig) -> Result<(), S } match allowed_users_for_channel(config, channel) { - Some(list) if list.is_empty() => Ok(()), + Some([]) => Ok(()), Some(list) => { if list.iter().any(|u| u == to) { Ok(()) diff --git a/src/openhuman/cwd_jail/linux.rs b/src/openhuman/cwd_jail/linux.rs index 8747ce14d4..c7297ec5fa 100644 --- a/src/openhuman/cwd_jail/linux.rs +++ b/src/openhuman/cwd_jail/linux.rs @@ -15,6 +15,12 @@ use super::jail::{Jail, JailBackend}; pub struct LandlockBackend; +impl Default for LandlockBackend { + fn default() -> Self { + Self::new() + } +} + impl LandlockBackend { pub fn new() -> Self { Self @@ -29,7 +35,7 @@ impl JailBackend for LandlockBackend { fn is_available(&self) -> bool { #[cfg(feature = "sandbox-landlock")] { - use landlock::{AccessFs, Ruleset, RulesetAttr, RulesetCreatedAttr}; + use landlock::{AccessFs, Ruleset, RulesetAttr}; Ruleset::default() .handle_access(AccessFs::ReadFile) .and_then(|r| r.create()) diff --git a/src/openhuman/desktop_companion/pipeline_tests.rs b/src/openhuman/desktop_companion/pipeline_tests.rs index f5b858e184..70cfa13a1a 100644 --- a/src/openhuman/desktop_companion/pipeline_tests.rs +++ b/src/openhuman/desktop_companion/pipeline_tests.rs @@ -8,7 +8,6 @@ use super::*; use crate::openhuman::desktop_companion::pointing::ScreenGeometry; use crate::openhuman::desktop_companion::session; -use crate::openhuman::desktop_companion::types::*; /// Serialize tests that touch the process-global session state. Shared with /// `session_tests` via `session::lock_test_state()` so transitions in one test diff --git a/src/openhuman/desktop_companion/session_tests.rs b/src/openhuman/desktop_companion/session_tests.rs index d4ca0cd2b1..a2e6ea96a5 100644 --- a/src/openhuman/desktop_companion/session_tests.rs +++ b/src/openhuman/desktop_companion/session_tests.rs @@ -1,7 +1,6 @@ //! Tests for companion session lifecycle and state machine. use super::*; -use crate::openhuman::desktop_companion::types::*; /// Serialize tests that mutate the process-global session state. Shared with /// `pipeline_tests` via `lock_test_state()` (defined in `session`) so a diff --git a/src/openhuman/devices/rpc.rs b/src/openhuman/devices/rpc.rs index 11c3d97dc8..427666b26a 100644 --- a/src/openhuman/devices/rpc.rs +++ b/src/openhuman/devices/rpc.rs @@ -334,7 +334,7 @@ mod tests { fn test_config() -> Config { let dir = tempfile::tempdir().expect("tempdir"); let mut config = Config::default(); - config.workspace_dir = dir.into_path(); + config.workspace_dir = dir.keep(); config } diff --git a/src/openhuman/devices/store.rs b/src/openhuman/devices/store.rs index ab4998ec6d..2b89128ff5 100644 --- a/src/openhuman/devices/store.rs +++ b/src/openhuman/devices/store.rs @@ -154,7 +154,7 @@ mod tests { fn test_config() -> Config { let dir = tempfile::tempdir().expect("tempdir"); let mut config = Config::default(); - config.workspace_dir = dir.into_path(); + config.workspace_dir = dir.keep(); config } diff --git a/src/openhuman/embeddings/ollama_adapter.rs b/src/openhuman/embeddings/ollama_adapter.rs index fe48175c60..0b8162fe12 100644 --- a/src/openhuman/embeddings/ollama_adapter.rs +++ b/src/openhuman/embeddings/ollama_adapter.rs @@ -9,6 +9,7 @@ pub use tinyagents::harness::embeddings::{ DEFAULT_OLLAMA_DIMENSIONS, DEFAULT_OLLAMA_MODEL, DEFAULT_OLLAMA_URL, }; +#[derive(Default)] pub struct OllamaEmbedding { inner: OllamaEmbeddingModel, } @@ -33,14 +34,6 @@ impl OllamaEmbedding { } } -impl Default for OllamaEmbedding { - fn default() -> Self { - Self { - inner: OllamaEmbeddingModel::default(), - } - } -} - #[async_trait] impl EmbeddingProvider for OllamaEmbedding { fn name(&self) -> &str { diff --git a/src/openhuman/file_state/ops.rs b/src/openhuman/file_state/ops.rs index bf8be1133f..1e90d1cdbf 100644 --- a/src/openhuman/file_state/ops.rs +++ b/src/openhuman/file_state/ops.rs @@ -1,6 +1,6 @@ //! Operational API for the file state coordinator. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; use std::time::{Instant, SystemTime}; use tokio::sync::{Mutex, OwnedMutexGuard}; @@ -117,10 +117,10 @@ pub fn check_stale_read(agent_id: &str, resolved_path: &PathBuf) -> Option Option { +pub fn check_partial_read(agent_id: &str, resolved_path: &Path) -> Option { let coord = try_global()?; let reads = coord.reads.read(); - let read_key = (agent_id.to_string(), resolved_path.clone()); + let read_key = (agent_id.to_string(), resolved_path.to_path_buf()); let read_stamp = reads.get(&read_key)?; if read_stamp.partial { let display_path = resolved_path.display(); @@ -138,12 +138,12 @@ pub fn check_partial_read(agent_id: &str, resolved_path: &PathBuf) -> Option Option> { +pub async fn acquire_path_lock(resolved_path: &Path) -> Option> { let coord = try_global()?; let mutex = { let mut locks = coord.path_locks.write(); locks - .entry(resolved_path.clone()) + .entry(resolved_path.to_path_buf()) .or_insert_with(|| Arc::new(Mutex::new(()))) .clone() }; diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index 3704acdad9..72de2d8eae 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -3561,7 +3561,6 @@ pub async fn flows_cancel_run(config: &Config, run_id: &str) -> Result match checkpointer.delete_thread(thread_id).await { Ok(()) => { @@ -4371,7 +4370,7 @@ pub async fn flows_build( fn text_looks_like_question(text: &str) -> bool { let trimmed = text .trim() - .trim_end_matches(|c: char| matches!(c, '"' | '\'' | ')' | ']' | '*' | '_' | '`' | '.')) + .trim_end_matches(['"', '\'', ')', ']', '*', '_', '`', '.']) .trim_end(); if trimmed.is_empty() { return false; @@ -4388,8 +4387,7 @@ fn text_looks_like_question(text: &str) -> bool { // invariant this function exists to protect. trimmed .lines() - .filter(|line| !line.trim().is_empty()) - .next_back() + .rfind(|line| !line.trim().is_empty()) .is_some_and(|last_line| last_line.trim_end().ends_with('?')) } diff --git a/src/openhuman/flows/ops_tests.rs b/src/openhuman/flows/ops_tests.rs index 93409a4793..448b35fb12 100644 --- a/src/openhuman/flows/ops_tests.rs +++ b/src/openhuman/flows/ops_tests.rs @@ -2306,7 +2306,7 @@ async fn validate_tool_contracts_rejects_a_hallucinated_slug() { { "id": "t", "kind": "trigger", "name": "Manual" }, { "id": "post", "kind": "tool_call", "name": "Post", "config": { "slug": "SLACK_POST_MESSAGE_TO_CHANNEL", - "args": { "channel": "#general", "text": "hi" } } } + "args": { "channel": "#general", "markdown_text": "hi" } } } ], "edges": [ { "from_node": "t", "to_node": "post" } ] })); diff --git a/src/openhuman/inference/local/install.rs b/src/openhuman/inference/local/install.rs index d6d8c6de6f..ae5ca6b8f3 100644 --- a/src/openhuman/inference/local/install.rs +++ b/src/openhuman/inference/local/install.rs @@ -339,7 +339,6 @@ mod tests { /// (OLLAMA_BIN, PATH) with other local-AI tests that also read these /// variables. Without this, cargo's test runner can interleave set/remove /// calls and cause flakes. - fn env_lock() -> std::sync::MutexGuard<'static, ()> { crate::openhuman::inference::inference_test_guard() } diff --git a/src/openhuman/inference/local/mod.rs b/src/openhuman/inference/local/mod.rs index a643700692..472eba8cdd 100644 --- a/src/openhuman/inference/local/mod.rs +++ b/src/openhuman/inference/local/mod.rs @@ -52,5 +52,6 @@ pub use schemas::{ all_controller_schemas as all_local_inference_controller_schemas, all_registered_controllers as all_local_inference_registered_controllers, }; +#[cfg(feature = "voice")] pub(crate) use service::whisper_engine; pub use service::LocalAiService; diff --git a/src/openhuman/inference/local/ollama.rs b/src/openhuman/inference/local/ollama.rs index e2d8462ab0..486c48e823 100644 --- a/src/openhuman/inference/local/ollama.rs +++ b/src/openhuman/inference/local/ollama.rs @@ -386,6 +386,7 @@ impl OllamaShowResponse { /// (unknown): /// * empty / absent capabilities (older Ollama, or an `/api/show` miss); /// * a tag set we don't recognise (e.g. `["insert"]` only). +/// /// Callers treat `None` as "keep visible" — fail-open, never hide a model /// that might be usable for chat. Mirrors the non-rejecting `Unknown` arm of /// [`super::model_requirements::ContextEligibility`]. See Sentry TAURI-RUST-4P6. diff --git a/src/openhuman/inference/provider/claude_code/driver.rs b/src/openhuman/inference/provider/claude_code/driver.rs index eca47e2fec..f2eccb414f 100644 --- a/src/openhuman/inference/provider/claude_code/driver.rs +++ b/src/openhuman/inference/provider/claude_code/driver.rs @@ -40,6 +40,7 @@ const DISALLOWED_CC_BUILTINS: &[&str] = &[ /// Whether the user opted into FULL access for Claude Code (`bypassPermissions` /// + full native toolset incl. Bash/network). Default is **off** → the safer +/// /// `acceptEdits` posture (file edits only). This is a deliberate user choice, /// not the default — enabling Claude Code alone does not grant shell/network /// power. diff --git a/src/openhuman/inference/provider/factory.rs b/src/openhuman/inference/provider/factory.rs index 58c83858f1..b074a831c0 100644 --- a/src/openhuman/inference/provider/factory.rs +++ b/src/openhuman/inference/provider/factory.rs @@ -1605,10 +1605,10 @@ pub(crate) fn create_turn_chat_model_from_string_with_native_tools( /// `make_omlx_provider` / `make_local_openai_provider` — they share the endpoint /// helpers but each builds its own client, so an endpoint/auth change must touch /// both until the `Provider` path is deleted. -fn try_create_local_runtime_chat_model( - role: &str, - config: &Config, -) -> Option>, String)>> { +type ResolvedChatModel = (Arc>, String); +type OptionalChatModelResult = Option>; + +fn try_create_local_runtime_chat_model(role: &str, config: &Config) -> OptionalChatModelResult { let resolved = provider_for_role(role, config); try_create_local_runtime_chat_model_from_string(role, &resolved, config, true) } @@ -1618,7 +1618,7 @@ fn try_create_local_runtime_chat_model_from_string( provider: &str, config: &Config, require_session: bool, -) -> Option>, String)>> { +) -> OptionalChatModelResult { use crate::openhuman::inference::local::profile::{ LOCAL_OPENAI_PROFILE, MLX_PROFILE, OMLX_PROFILE, }; @@ -2514,10 +2514,7 @@ fn make_cloud_provider_by_slug( /// `verify_session_active`) runs before building. Temperature rides the per-call /// `ModelRequest` (managed/local parity; the `@` suffix still bakes a fixed /// override). -fn try_create_cloud_slug_chat_model( - role: &str, - config: &Config, -) -> Option>, String)>> { +fn try_create_cloud_slug_chat_model(role: &str, config: &Config) -> OptionalChatModelResult { try_create_cloud_slug_chat_model_with_native_tools(role, config, true) } @@ -2525,7 +2522,7 @@ fn try_create_cloud_slug_chat_model_with_native_tools( role: &str, config: &Config, native_tool_calling: bool, -) -> Option>, String)>> { +) -> OptionalChatModelResult { // Resolve the role's provider string, expanding the empty / "cloud" sentinel // to the primary cloud target (mirroring create_chat_provider_from_string). let mut resolved = provider_for_role(role, config); @@ -2544,7 +2541,7 @@ fn try_create_cloud_slug_chat_model_from_string( role: &str, provider: &str, config: &Config, -) -> Option>, String)>> { +) -> OptionalChatModelResult { try_create_cloud_slug_chat_model_from_string_with_native_tools(role, provider, config, true) } @@ -2553,7 +2550,7 @@ fn try_create_cloud_slug_chat_model_from_string_with_native_tools( provider: &str, config: &Config, native_tool_calling: bool, -) -> Option>, String)>> { +) -> OptionalChatModelResult { let p = provider.trim().to_string(); // Only the ":[@temp]" cloud form routes here. The managed diff --git a/src/openhuman/inference/provider/factory_tests.rs b/src/openhuman/inference/provider/factory_tests.rs index 7be21342d8..d98cd1919c 100644 --- a/src/openhuman/inference/provider/factory_tests.rs +++ b/src/openhuman/inference/provider/factory_tests.rs @@ -927,7 +927,7 @@ fn verify_session_active_rejects_when_no_session_token() { #[test] fn verify_session_active_rejects_when_token_is_empty() { let tmp = TempDir::new().expect("tempdir"); - let mut config = config_in_tempdir(&tmp); + let config = config_in_tempdir(&tmp); let auth = AuthService::new(tmp.path(), config.secrets.encrypt); auth.store_provider_token("app-session", "default", "", Default::default(), false) .expect("store empty token"); @@ -941,7 +941,7 @@ fn verify_session_active_rejects_when_token_is_empty() { #[test] fn verify_session_active_passes_when_session_token_present() { let tmp = TempDir::new().expect("tempdir"); - let mut config = config_in_tempdir(&tmp); + let config = config_in_tempdir(&tmp); let auth = AuthService::new(tmp.path(), config.secrets.encrypt); auth.store_provider_token( "app-session", @@ -2780,7 +2780,7 @@ async fn create_chat_model_wraps_provider_and_round_trips() { use async_trait::async_trait; use std::sync::Arc; use tinyagents::harness::message::Message; - use tinyagents::harness::model::{ChatModel, ModelRequest}; + use tinyagents::harness::model::ModelRequest; let _guard = crate::openhuman::inference::inference_test_guard(); @@ -2834,8 +2834,6 @@ fn resolves_to_managed_backend_for_default_config_but_not_for_local() { #[test] fn create_chat_model_routes_managed_backend_to_crate_native() { - use tinyagents::harness::model::ChatModel; - let _guard = crate::openhuman::inference::inference_test_guard(); // No test-provider override installed → the managed short-circuit engages. let config = Config::default(); @@ -2851,8 +2849,6 @@ fn create_chat_model_routes_managed_backend_to_crate_native() { #[test] fn create_chat_model_routes_local_runtime_to_crate_native() { - use tinyagents::harness::model::ChatModel; - let _guard = crate::openhuman::inference::inference_test_guard(); let mut config = Config::default(); config.chat_provider = Some("ollama:qwen2.5".to_string()); @@ -2917,8 +2913,6 @@ fn deepseek_entry(id: &str) -> CloudProviderCreds { #[test] fn create_chat_model_routes_plain_bearer_cloud_slug_to_crate_native() { - use tinyagents::harness::model::ChatModel; - let _guard = crate::openhuman::inference::inference_test_guard(); // DeepSeek is a built-in chat-completions-only Bearer provider: no // `/v1/responses` fallback and no codex-oauth, so it is wire-equivalent and @@ -2961,8 +2955,6 @@ fn explicit_cloud_provider_string_routes_to_crate_native_model() { #[test] fn create_chat_model_routes_anthropic_auth_cloud_slug_to_crate_native() { - use tinyagents::harness::model::ChatModel; - let _guard = crate::openhuman::inference::inference_test_guard(); // Anthropic-auth cloud slugs are always wire-equivalent (their endpoints have // no `/v1/responses`, so the host's dormant fallback is behavior-neutral). @@ -2982,8 +2974,6 @@ fn create_chat_model_routes_anthropic_auth_cloud_slug_to_crate_native() { #[test] fn try_create_cloud_slug_flips_openai_but_declines_non_cloud() { - use tinyagents::harness::model::ChatModel; - let _guard = crate::openhuman::inference::inference_test_guard(); // `openai` (API-key Bearer, no codex OAuth) now flips crate-native on Chat // Completions — the legacy `/v1/responses` fallback is not replicated. diff --git a/src/openhuman/learning/extract/heuristics.rs b/src/openhuman/learning/extract/heuristics.rs index ac44cf6fe0..7cb749bb58 100644 --- a/src/openhuman/learning/extract/heuristics.rs +++ b/src/openhuman/learning/extract/heuristics.rs @@ -413,8 +413,6 @@ mod heuristics_tests { #[test] fn length_ratio_emits_compressed_when_user_msgs_shrink() { let session = fresh_session_id(); - let buf = Buffer::new(1024); - // First 15 turns: high ratio (user talks a lot). let now = now_secs(); for i in 0..15 { diff --git a/src/openhuman/learning/reflection.rs b/src/openhuman/learning/reflection.rs index a46a0e240f..f7a248a05c 100644 --- a/src/openhuman/learning/reflection.rs +++ b/src/openhuman/learning/reflection.rs @@ -60,7 +60,7 @@ async fn invoke_cloud_reflection( prompt: &str, ) -> anyhow::Result { use tinyagents::harness::message::Message; - use tinyagents::harness::model::{ChatModel, ModelRequest}; + use tinyagents::harness::model::ModelRequest; Ok(provider .invoke( &(), diff --git a/src/openhuman/learning/stability_detector.rs b/src/openhuman/learning/stability_detector.rs index 231fb6886b..67f040e4de 100644 --- a/src/openhuman/learning/stability_detector.rs +++ b/src/openhuman/learning/stability_detector.rs @@ -288,7 +288,7 @@ impl StabilityDetector { facet_type: FacetType::Preference, key: full_key.clone(), value, - confidence: (agg_score).min(1.0).max(0.0), + confidence: agg_score.clamp(0.0, 1.0), evidence_count: new_evidence_count, source_segment_ids: existing.and_then(|f| f.source_segment_ids.clone()), first_seen_at: first_seen, @@ -551,9 +551,7 @@ fn state_from_stability(score: f64, user_state: UserState) -> FacetState { return FacetState::Dropped; } - if score.is_infinite() { - FacetState::Active - } else if score >= TAU_PROMOTE { + if score.is_infinite() || score >= TAU_PROMOTE { FacetState::Active } else if score >= TAU_PROVISIONAL { FacetState::Provisional diff --git a/src/openhuman/mcp_registry/connections.rs b/src/openhuman/mcp_registry/connections.rs index fdc964593c..6e41e84cf3 100644 --- a/src/openhuman/mcp_registry/connections.rs +++ b/src/openhuman/mcp_registry/connections.rs @@ -595,6 +595,7 @@ pub async fn call_tool( /// and the re-auth affordance keyed off the status alone. /// - other recorded connect failure in `LAST_ERRORS` → `Error` + message. /// - otherwise → `Disconnected`. +/// /// Pure status decision for one installed server, factored out of /// [`all_status`] so the priority order is unit-testable without a live /// connection registry or store. Inputs: diff --git a/src/openhuman/mcp_registry/registries/mcp_official.rs b/src/openhuman/mcp_registry/registries/mcp_official.rs index 269aad977c..453d4002c9 100644 --- a/src/openhuman/mcp_registry/registries/mcp_official.rs +++ b/src/openhuman/mcp_registry/registries/mcp_official.rs @@ -75,8 +75,11 @@ const MAX_CURSOR_WALK_PAGES: u32 = 50; /// `parking_lot::Mutex` matches the rest of the memory subsystem and keeps /// the critical section synchronous — every access is a `HashMap` op, no /// `.await` while the lock is held. -fn cursor_cache() -> &'static Mutex> { - static CACHE: OnceLock>> = OnceLock::new(); +type CursorCacheKey = (String, u32, u32); +type CursorCache = Mutex>; + +fn cursor_cache() -> &'static CursorCache { + static CACHE: OnceLock = OnceLock::new(); CACHE.get_or_init(|| Mutex::new(HashMap::new())) } diff --git a/src/openhuman/mcp_server/write_dispatch.rs b/src/openhuman/mcp_server/write_dispatch.rs index ae4f1568a8..43c32d0009 100644 --- a/src/openhuman/mcp_server/write_dispatch.rs +++ b/src/openhuman/mcp_server/write_dispatch.rs @@ -142,11 +142,11 @@ pub(super) async fn dispatch_write_tool( fn audit_write(config: &Config, record: NewMcpWriteRecord) { let config = config.clone(); if let Ok(handle) = tokio::runtime::Handle::try_current() { - let _ = handle.spawn_blocking(move || { + std::mem::drop(handle.spawn_blocking(move || { if let Err(err) = mcp_audit::record_write(&config, record) { log::warn!("[mcp_server] mcp write audit insert failed: {err}"); } - }); + })); } else { let _ = std::thread::spawn(move || { if let Err(err) = mcp_audit::record_write(&config, record) { @@ -203,7 +203,7 @@ pub(super) fn audit_write_rejection_without_config( let args_summary = summarize_write_args(&tool_name, audit_arguments); match tokio::runtime::Handle::try_current() { Ok(handle) => { - let _ = handle.spawn(async move { + std::mem::drop(handle.spawn(async move { match config_rpc::load_config_with_timeout().await { Ok(config) => audit_write( &config, @@ -223,7 +223,7 @@ pub(super) fn audit_write_rejection_without_config( err ), } - }); + })); } Err(err) => log::warn!( "[mcp_server] write rejection audit skipped tool={} runtime unavailable error={}", diff --git a/src/openhuman/meet_agent/brain/llm.rs b/src/openhuman/meet_agent/brain/llm.rs index f0fd583743..5d54872bf8 100644 --- a/src/openhuman/meet_agent/brain/llm.rs +++ b/src/openhuman/meet_agent/brain/llm.rs @@ -15,7 +15,7 @@ use crate::openhuman::agent::harness::session::Agent; /// One rolling-history entry handed to the LLM. #[derive(Debug, Clone)] -pub(super) struct ConversationTurn { +pub(crate) struct ConversationTurn { pub role: &'static str, pub content: String, } diff --git a/src/openhuman/meet_agent/brain/speech.rs b/src/openhuman/meet_agent/brain/speech.rs index 88eb70f53b..ac84b5f6ea 100644 --- a/src/openhuman/meet_agent/brain/speech.rs +++ b/src/openhuman/meet_agent/brain/speech.rs @@ -58,7 +58,6 @@ pub(super) async fn tts(text: &str, voice_id: Option<&str>) -> Result, // Per-mascot voice for speaker alternation. `None` preserves the // backend's default-voice pick (single-mascot behavior). voice_id: voice_id.map(str::to_owned), - ..Default::default() }; let outcome = synthesize_reply(&config, text, &opts).await?; let result = outcome.value; diff --git a/src/openhuman/meet_agent/session.rs b/src/openhuman/meet_agent/session.rs index edfff41e78..334b5dce53 100644 --- a/src/openhuman/meet_agent/session.rs +++ b/src/openhuman/meet_agent/session.rs @@ -330,6 +330,7 @@ impl MeetAgentSession { /// * none present → `advance_speaker` yields `None` and the /// reply-speech backend keeps picking its own default voice /// (exact previous behavior). + /// /// The active slot starts at 0 so an idle session reports the primary /// mascot; [`Self::advance_speaker`] keeps the first reply there and /// rotates thereafter. diff --git a/src/openhuman/meet_agent/store.rs b/src/openhuman/meet_agent/store.rs index 600f9223f9..4bf527431f 100644 --- a/src/openhuman/meet_agent/store.rs +++ b/src/openhuman/meet_agent/store.rs @@ -163,7 +163,7 @@ async fn read_recent_from(path: &Path, limit: usize) -> Result Utc::now().timestamp_millis(), "deferred job should be rescheduled into the future" ); + let defer_reason = job.last_error.as_deref().unwrap_or(""); assert!( - job.last_error - .as_deref() - .unwrap_or("") - .contains("re-embed backfill"), - "defer reason should be recorded for visibility" + defer_reason.contains("re-embed backfill") + || defer_reason.contains("llm concurrency gate busy"), + "defer reason should identify the backfill or the shared gate: {defer_reason:?}" ); assert_eq!(count_by_status(&cfg, JobStatus::Ready).unwrap(), 1); } diff --git a/src/openhuman/memory_store/chunks/connection.rs b/src/openhuman/memory_store/chunks/connection.rs index 87fe5a2e4a..f21278718a 100644 --- a/src/openhuman/memory_store/chunks/connection.rs +++ b/src/openhuman/memory_store/chunks/connection.rs @@ -18,6 +18,3 @@ pub(crate) fn recover_corrupt_db(config: &Config) -> Result { log::warn!("[memory:chunks] checking corrupt database recovery"); tinycortex::memory::chunks::recover_corrupt_db(&engine_config(config)) } - -#[cfg(test)] -pub(crate) use tinycortex::memory::chunks::{is_transient_cold_start, try_cleanup_stale_files}; diff --git a/src/openhuman/memory_store/chunks/embeddings.rs b/src/openhuman/memory_store/chunks/embeddings.rs index 703c541a76..beedf1733e 100644 --- a/src/openhuman/memory_store/chunks/embeddings.rs +++ b/src/openhuman/memory_store/chunks/embeddings.rs @@ -7,9 +7,6 @@ use rusqlite::{Connection, Transaction}; use crate::openhuman::config::Config; -#[cfg(test)] -pub use tinycortex::memory::chunks::{embedding_to_blob, REEMBED_SKIP_KEY_MAX_LEN}; - fn engine_config(config: &Config) -> tinycortex::memory::MemoryConfig { crate::openhuman::tinycortex::memory_config_from(config, config.workspace_dir.clone()) } diff --git a/src/openhuman/memory_tools/capture.rs b/src/openhuman/memory_tools/capture.rs index b9934378a8..b885f86ed5 100644 --- a/src/openhuman/memory_tools/capture.rs +++ b/src/openhuman/memory_tools/capture.rs @@ -82,8 +82,10 @@ impl ToolMemoryCaptureHook { // phrases like "I want to stop working" don't trigger false captures. let stop_imperative = lower.starts_with("stop ") || lower.contains(". stop ") || lower.contains("\nstop "); - if !(lower.contains("never ") || lower.contains("don't ") || lower.contains("do not ")) - && !stop_imperative + if !(lower.contains("never ") + || lower.contains("don't ") + || lower.contains("do not ") + || stop_imperative) { return Vec::new(); } diff --git a/src/openhuman/memory_tree/tree/factory.rs b/src/openhuman/memory_tree/tree/factory.rs index e0ba2cc183..2b3f4daf43 100644 --- a/src/openhuman/memory_tree/tree/factory.rs +++ b/src/openhuman/memory_tree/tree/factory.rs @@ -80,8 +80,8 @@ impl<'a> TreeFactory<'a> { match self.kind() { TreeKind::Topic | TreeKind::Global => slugify_source_id(scope), TreeKind::Source => { - if scope.starts_with("gmail:") { - slugify_source_id(&scope["gmail:".len()..]) + if let Some(gmail_scope) = scope.strip_prefix("gmail:") { + slugify_source_id(gmail_scope) } else { slugify_source_id(scope) } diff --git a/src/openhuman/memory_tree/tree/registry.rs b/src/openhuman/memory_tree/tree/registry.rs index ec6b7386a8..d11ff6e943 100644 --- a/src/openhuman/memory_tree/tree/registry.rs +++ b/src/openhuman/memory_tree/tree/registry.rs @@ -76,10 +76,10 @@ pub fn get_or_create_tree(config: &Config, kind: TreeKind, scope: &str) -> Resul /// Matches both the anyhow-wrapped rusqlite error text and the raw SQLite /// error codes in case the wrapping chain is shorter. pub fn is_unique_violation(err: &anyhow::Error) -> bool { - if let Some(rusqlite_err) = err.downcast_ref::() { - if let rusqlite::Error::SqliteFailure(sqlite_err, _) = rusqlite_err { - return sqlite_err.code == rusqlite::ErrorCode::ConstraintViolation; - } + if let Some(rusqlite::Error::SqliteFailure(sqlite_err, _)) = + err.downcast_ref::() + { + return sqlite_err.code == rusqlite::ErrorCode::ConstraintViolation; } // Fallback for chained/wrapped errors: scan the rendered message. let msg = format!("{err:#}"); diff --git a/src/openhuman/memory_tree/tree/rpc.rs b/src/openhuman/memory_tree/tree/rpc.rs index 88301c201a..c277a850f4 100644 --- a/src/openhuman/memory_tree/tree/rpc.rs +++ b/src/openhuman/memory_tree/tree/rpc.rs @@ -832,10 +832,9 @@ pub async fn set_enabled_rpc( mod tests { use super::*; use crate::openhuman::memory_queue as jobs; - use crate::openhuman::memory_queue::store::count_total; use crate::openhuman::memory_store::chunks::types::SourceKind; use crate::openhuman::memory_sync::canonicalize::document::DocumentInput; - use chrono::{Duration as ChronoDuration, Utc}; + use chrono::Utc; use serde_json::json; use tempfile::TempDir; diff --git a/src/openhuman/people/store.rs b/src/openhuman/people/store.rs index b9f9fc8beb..d6ec23372d 100644 --- a/src/openhuman/people/store.rs +++ b/src/openhuman/people/store.rs @@ -16,6 +16,14 @@ use crate::openhuman::people::migrations; use crate::openhuman::people::types::{Handle, Interaction, Person, PersonId}; pub type ConnHandle = Arc>; +type PersonRow = ( + String, + Option, + Option, + Option, + i64, + i64, +); /// Process-global handle to the `PeopleStore`, tagged with the workspace it is /// bound to. Controller handlers are free functions with no `&self`, so they @@ -326,7 +334,7 @@ impl PeopleStore { let conn = self.conn.clone(); tokio::task::spawn_blocking(move || -> SqlResult> { let guard = conn.blocking_lock(); - let row: Option<(String, Option, Option, Option, i64, i64)> = + let row: Option = guard .query_row( "SELECT id, display_name, primary_email, primary_phone, created_at, updated_at \ diff --git a/src/openhuman/runtime_python/bootstrap.rs b/src/openhuman/runtime_python/bootstrap.rs index cfffb2a0a5..3bb729d077 100644 --- a/src/openhuman/runtime_python/bootstrap.rs +++ b/src/openhuman/runtime_python/bootstrap.rs @@ -276,6 +276,7 @@ async fn acquire_install_lock(install_dir: &Path) -> Result { let file = OpenOptions::new() .create(true) + .truncate(false) .read(true) .write(true) .open(&lock_path_for_task) diff --git a/src/openhuman/security/pii/rules.rs b/src/openhuman/security/pii/rules.rs index f3671f15b3..b4b6d3d45f 100644 --- a/src/openhuman/security/pii/rules.rs +++ b/src/openhuman/security/pii/rules.rs @@ -71,7 +71,7 @@ pub(crate) fn is_luhn_valid(raw: &str) -> bool { } sum += v; } - sum % 10 == 0 + sum.is_multiple_of(10) } /// Validate that a dotted-quad match is a real IPv4 address (each octet 0–255). diff --git a/src/openhuman/skills/e2e_plumbing_tests.rs b/src/openhuman/skills/e2e_plumbing_tests.rs index 6281524eb5..ee35174c1e 100644 --- a/src/openhuman/skills/e2e_plumbing_tests.rs +++ b/src/openhuman/skills/e2e_plumbing_tests.rs @@ -39,7 +39,6 @@ use crate::openhuman::skills::ops_create::{ use crate::openhuman::skills::ops_types::WorkflowScope; use crate::openhuman::skills::registry::get_workflow; use crate::openhuman::skills::run_log; -use crate::openhuman::tools::policy::DefaultToolPolicy; use crate::openhuman::tools::traits::Tool; // ── Mock LLM ───────────────────────────────────────────────────────────── diff --git a/src/openhuman/skills/e2e_run_tests.rs b/src/openhuman/skills/e2e_run_tests.rs index a7ff46bc66..03349f3e2c 100644 --- a/src/openhuman/skills/e2e_run_tests.rs +++ b/src/openhuman/skills/e2e_run_tests.rs @@ -42,7 +42,6 @@ use crate::openhuman::skill_runtime::{await_run_outcome, spawn_workflow_run_back use crate::openhuman::skills::schemas::resolve_workspace_dir; use crate::openhuman::todos::ops as board_ops; use crate::openhuman::todos::ops::{BoardLocation, CardPatch}; -use crate::openhuman::tools::policy::DefaultToolPolicy; use crate::openhuman::tools::traits::Tool; /// Serialize this module's tests (each touches process-global state). diff --git a/src/openhuman/skills/ops_tests.rs b/src/openhuman/skills/ops_tests.rs index d49ccfba77..75e2deb6bc 100644 --- a/src/openhuman/skills/ops_tests.rs +++ b/src/openhuman/skills/ops_tests.rs @@ -1678,7 +1678,6 @@ fn uninstall_resolves_agents_skills_legacy_root() { // discover_workflows surfaces ~/.agents/skills/, so uninstall must reach it // too — otherwise a listed workflow can never be deleted via this API. let home = tempfile::tempdir().unwrap(); - let ws = tempfile::tempdir().unwrap(); let dir = home.path().join(".agents").join("skills").join("agenty"); write( &dir.join(SKILL_MD), diff --git a/src/openhuman/skills/registry.rs b/src/openhuman/skills/registry.rs index 580d27b7ca..dc374b71c4 100644 --- a/src/openhuman/skills/registry.rs +++ b/src/openhuman/skills/registry.rs @@ -148,8 +148,9 @@ pub fn prune_legacy_default_workflows(workspace_dir: &Path) { /// `run_workflow`) returned "unknown workflow" for anything created via the UI. /// /// Per dir: `skill.toml` (id / `when_to_use` / `[[inputs]]` / `[github]`) -/// + the `SKILL.md` body as the inline system prompt; or, when there's no -/// `skill.toml`, a synthesized SKILL.md-only definition so a bare workflow is +/// + the `SKILL.md` body as the inline system prompt. +/// +/// Without `skill.toml`, a synthesized SKILL.md-only definition means a bare workflow is /// still runnable. A bad `skill.toml` falls back to the SKILL.md-only form. pub fn load_workflows(workspace_dir: &Path) -> Vec { // Prune any legacy bundled skills an older build left behind so discover's diff --git a/src/openhuman/socket/medulla/mod.rs b/src/openhuman/socket/medulla/mod.rs index 1a961e4c44..0656761ca4 100644 --- a/src/openhuman/socket/medulla/mod.rs +++ b/src/openhuman/socket/medulla/mod.rs @@ -373,10 +373,9 @@ async fn drain_steer(steer_rx: &mut mpsc::UnboundedReceiver) -> Option msg, - Err(_) => None, - } + tokio::time::timeout(STEER_DRAIN_GRACE, steer_rx.recv()) + .await + .unwrap_or_default() } /// Build (or resume) an agent session for a medulla task. diff --git a/src/openhuman/subconscious_triggers/runtime.rs b/src/openhuman/subconscious_triggers/runtime.rs index 941b1024a0..5ed4ac6e28 100644 --- a/src/openhuman/subconscious_triggers/runtime.rs +++ b/src/openhuman/subconscious_triggers/runtime.rs @@ -132,7 +132,8 @@ impl TriggerOrchestrator { /// Non-blocking ingestion entry point for the bus subscriber. Normalizes /// + admits synchronously, then spawns the gate task for admitted - /// triggers so event dispatch is never blocked on an LLM call. + /// + /// Triggers ensure event dispatch is never blocked on an LLM call. pub fn ingest(self: &Arc, event: &DomainEvent) { let now = now_secs(); let Some(trigger) = normalize(event, now) else { diff --git a/src/openhuman/test_support/introspect.rs b/src/openhuman/test_support/introspect.rs index 4bc75daa76..8198deb2c2 100644 --- a/src/openhuman/test_support/introspect.rs +++ b/src/openhuman/test_support/introspect.rs @@ -144,7 +144,7 @@ async fn walk_dir( size: if is_dir { 0 } else { meta.len() }, is_dir, }); - if is_dir && depth + 1 <= max_depth { + if is_dir && depth < max_depth { stack.push((path, depth + 1)); } } @@ -168,7 +168,7 @@ pub async fn list_workspace_files( Ok(RpcOutcome::single_log( ListResult { root: root.display().to_string(), - entries: entries.iter().cloned().collect(), + entries: entries.to_vec(), truncated, }, format!( diff --git a/src/openhuman/tinyagents/mod.rs b/src/openhuman/tinyagents/mod.rs index 4ba7386b55..4412ae314f 100644 --- a/src/openhuman/tinyagents/mod.rs +++ b/src/openhuman/tinyagents/mod.rs @@ -79,7 +79,7 @@ pub(crate) use embeddings::ProviderEmbeddingModel; pub(crate) use middleware::{ HandoffConfig, SuperContextConfig, TranscriptSnapshotSink, TurnContextMiddleware, }; -use model::ProviderModel; +use model::{BuiltTurnModels, ProviderModel, TierRoutes, TurnChatModel}; pub(crate) use observability::SubagentScope; use observability::{ CapPauser, IterationCursor, OpenhumanEventBridge, ProviderUsageCarry, ToolFailureMap, @@ -1172,13 +1172,13 @@ fn tinyagents_depth_error( /// `Provider` → `ChatModel` adaptation is confined to `build_turn_models`. pub(crate) struct TurnModels { /// The turn's effective/primary model (registry default + dispatch target). - primary: Arc>, + primary: TurnChatModel, /// Additive workload-tier routes (registry name → model), excluding the /// primary; the crate registry resolves fallback/selection across them. - routes: Vec<(String, Arc>)>, + routes: TierRoutes, /// A model for the context-window summarizer (a distinct adapter instance so /// its provider errors don't touch the turn's `error_slot`). - summarizer: Arc>, + summarizer: TurnChatModel, /// Recovers the primary's original (downcastable) provider error on failure. error_slot: crate::openhuman::tinyagents::model::ProviderErrorSlot, /// Provider telemetry id (`{provider_id}.{model}` in Langfuse), captured from @@ -1307,26 +1307,25 @@ fn build_turn_models_crate( // The primary honours an explicit provider-string override when the producer's // effective provider differs from `provider_for_role(role)` (triage #1257). - let build_primary = - |m: &str| -> anyhow::Result>> { - match primary_override { - Some(ps) => factory::create_turn_chat_model_from_string_with_native_tools( - role, - ps, - config, - m, - temperature, - !force_text_mode, - ), - None => factory::create_turn_chat_model_with_native_tools( - role, - config, - m, - temperature, - !force_text_mode, - ), - } - }; + let build_primary = |m: &str| -> anyhow::Result { + match primary_override { + Some(ps) => factory::create_turn_chat_model_from_string_with_native_tools( + role, + ps, + config, + m, + temperature, + !force_text_mode, + ), + None => factory::create_turn_chat_model_with_native_tools( + role, + config, + m, + temperature, + !force_text_mode, + ), + } + }; // Build the primary, every workload-tier route, and the summarizer under one // per-turn egress-dedup ledger: each managed construction resolves through the @@ -1334,44 +1333,39 @@ fn build_turn_models_crate( // separate `ExternalTransferPending` for the same logical destination (codex // P2, PR #4812). `dedup_turn_scope` collapses same-destination repeats to one // disclosure per turn while still surfacing each distinct tier model. - #[allow(clippy::type_complexity)] - let (primary, routes, summarizer): ( - Arc>, - Vec<(String, Arc>)>, - Arc>, - ) = crate::openhuman::security::egress::dedup_turn_scope(|| { - let primary = build_primary(model)?; - - // Additive workload-tier routes: one crate-native model per tier (skipping the - // turn's own model, which is registered as the default primary), each pinned to - // the tier alias so the crate registry resolves cross-route fallback across them. - let mut routes: Vec<(String, Arc>)> = - Vec::new(); - for &tier in routes::WORKLOAD_ROUTE_TIERS { - if tier == model { - continue; - } - let tier_role = factory::role_for_model_tier(tier); - match factory::create_turn_chat_model(tier_role, config, tier, temperature) { - Ok(route_model) => routes.push((tier.to_string(), route_model)), - Err(e) => { - // A route that can't be built (e.g. an unconfigured BYOK tier) is - // skipped, not fatal — the primary still dispatches (parity with the - // `Provider` path, where an unresolved tier simply isn't registered). - tracing::debug!( - route = tier, - error = %e, - "[models] skipping crate-native workload route that failed to build" - ); + let (primary, routes, summarizer): BuiltTurnModels = + crate::openhuman::security::egress::dedup_turn_scope(|| { + let primary = build_primary(model)?; + + // Additive workload-tier routes: one crate-native model per tier (skipping the + // turn's own model, which is registered as the default primary), each pinned to + // the tier alias so the crate registry resolves cross-route fallback across them. + let mut routes: TierRoutes = Vec::new(); + for &tier in routes::WORKLOAD_ROUTE_TIERS { + if tier == model { + continue; + } + let tier_role = factory::role_for_model_tier(tier); + match factory::create_turn_chat_model(tier_role, config, tier, temperature) { + Ok(route_model) => routes.push((tier.to_string(), route_model)), + Err(e) => { + // A route that can't be built (e.g. an unconfigured BYOK tier) is + // skipped, not fatal — the primary still dispatches (parity with the + // `Provider` path, where an unresolved tier simply isn't registered). + tracing::debug!( + route = tier, + error = %e, + "[models] skipping crate-native workload route that failed to build" + ); + } } } - } - // The summarizer is a distinct adapter instance (own empty error slot). - let summarizer = build_primary(model)?; + // The summarizer is a distinct adapter instance (own empty error slot). + let summarizer = build_primary(model)?; - anyhow::Ok((primary, routes, summarizer)) - })?; + anyhow::Ok((primary, routes, summarizer)) + })?; Ok(TurnModels { primary, @@ -1671,7 +1665,8 @@ struct AssembledTurnHarness { /// Shared `call_id → (success, failure, elapsed_ms, output_chars)` /// side-channel: the tool-outcome capture middleware classifies each outcome /// + records its duration/output size; the event bridge reads it to project - /// real success + a user-facing failure + timing onto `ToolCallCompleted`. + /// + /// Real success + a user-facing failure + timing onto `ToolCallCompleted`. failure_map: ToolFailureMap, /// Shared FIFO carry of per-call provider `UsageInfo` (charged USD + context /// window): the model adapter pushes, the event bridge pops when recording diff --git a/src/openhuman/tinyagents/model.rs b/src/openhuman/tinyagents/model.rs index dc8807c505..f6ca880061 100644 --- a/src/openhuman/tinyagents/model.rs +++ b/src/openhuman/tinyagents/model.rs @@ -26,6 +26,10 @@ use crate::openhuman::inference::provider::{ }; use crate::openhuman::tools::ToolSpec; +pub(super) type TurnChatModel = Arc>; +pub(super) type TierRoutes = Vec<(String, TurnChatModel)>; +pub(super) type BuiltTurnModels = (TurnChatModel, TierRoutes, TurnChatModel); + /// Translate a harness [`ModelRequest`] into openhuman's message list + tool /// specs (shared by the buffered and streaming paths). fn build_chat_inputs( diff --git a/src/openhuman/tinyagents/observability.rs b/src/openhuman/tinyagents/observability.rs index c38685a983..8c7bdeea25 100644 --- a/src/openhuman/tinyagents/observability.rs +++ b/src/openhuman/tinyagents/observability.rs @@ -52,6 +52,7 @@ pub(crate) type ToolNameMap = Arc) -> Retriever mod tests { use super::*; use async_trait::async_trait; - use tinyagents::harness::events::{EventListener, EventRecord, RecordingListener}; + use tinyagents::harness::events::{EventListener, EventRecord}; use crate::openhuman::memory::{MemoryCategory, MemoryEntry, NamespaceSummary}; diff --git a/src/openhuman/tinyagents/tests.rs b/src/openhuman/tinyagents/tests.rs index 8c54cf9e24..bce571eeeb 100644 --- a/src/openhuman/tinyagents/tests.rs +++ b/src/openhuman/tinyagents/tests.rs @@ -584,7 +584,6 @@ fn adapter_inventory_registers_model_tools_and_middleware() { // Capability profile (issue #4249, Phase 2): derived from the wrapped // provider plus the runner-threaded token limits. - use tinyagents::harness::model::ChatModel; let registered = assembled .harness .models() diff --git a/src/openhuman/tinycortex/embeddings.rs b/src/openhuman/tinycortex/embeddings.rs index c34c7f8439..76f1557a68 100644 --- a/src/openhuman/tinycortex/embeddings.rs +++ b/src/openhuman/tinycortex/embeddings.rs @@ -4,6 +4,7 @@ //! OpenHuman owns the concrete providers (voyage / openai / cohere / ollama / //! cloud / noop) and the `embeddings/factory.rs` construction policy (rate-limit //! + retry). TinyCortex "never makes a network call" — it takes compute through +//! //! [`EmbeddingBackend`] (the vector store) and [`Embedder`] (retrieval / seal //! scoring). This adapter wraps one `Arc` and re-exposes //! it as both, so the engine drives OpenHuman embeddings without cloning any diff --git a/src/openhuman/tinycortex/sync.rs b/src/openhuman/tinycortex/sync.rs index 37967d7b87..482bc63922 100644 --- a/src/openhuman/tinycortex/sync.rs +++ b/src/openhuman/tinycortex/sync.rs @@ -116,7 +116,7 @@ fn source_sync_context(memory: MemoryClientRef, config: &Config, local: bool) -> documents: adapter.clone(), state: adapter.clone(), local_documents: local.then(|| adapter.clone() as std::sync::Arc), - external_sources: local.then(|| adapter as std::sync::Arc), + external_sources: local.then_some(adapter as std::sync::Arc), summariser: local.then(|| { std::sync::Arc::new(super::HostSummariser::new(config.clone())) as std::sync::Arc diff --git a/src/openhuman/tinyflows/caps.rs b/src/openhuman/tinyflows/caps.rs index 12d37105a7..6e2470b06d 100644 --- a/src/openhuman/tinyflows/caps.rs +++ b/src/openhuman/tinyflows/caps.rs @@ -591,6 +591,7 @@ impl LlmProvider for OpenHumanLlm { /// the node builds a real session agent /// ([`Agent::from_config_for_agent`](crate::openhuman::agent::Agent::from_config_for_agent) /// + `set_agent_definition_name`) and drives one full turn via +/// /// [`Agent::run_single`](crate::openhuman::agent::Agent::run_single) — the /// complete tool loop. The definition's `ToolScope` / `sandbox_mode` / /// `max_iterations` govern the turn, so an agent node gains its curated diff --git a/src/openhuman/tinyplace/manifest.rs b/src/openhuman/tinyplace/manifest.rs index 574e27f5fa..5a6a488d2b 100644 --- a/src/openhuman/tinyplace/manifest.rs +++ b/src/openhuman/tinyplace/manifest.rs @@ -2668,7 +2668,6 @@ use std::sync::Arc; use tinyplace::signal::session::SignalSession; use tinyplace::signal::store::SessionStore; -use tinyplace::signal::store::SessionStore as _; /// Get the `Arc` from the client or fail with a clear message. /// diff --git a/src/openhuman/todos/graph_shadow.rs b/src/openhuman/todos/graph_shadow.rs index f74be76068..81b2af4f6d 100644 --- a/src/openhuman/todos/graph_shadow.rs +++ b/src/openhuman/todos/graph_shadow.rs @@ -4,8 +4,9 @@ //! //! ADAPTER-FIRST / SHADOW ONLY — nothing in this module changes product //! behavior. The legacy [`TaskBoardStore`](crate::openhuman::agent::task_board) -//! + [`todos::ops`](crate::openhuman::todos::ops) remain the single source of -//! truth. This module (C2b first slice) mirrors post-mutation card snapshots +//! + [`todos::ops`](crate::openhuman::todos::ops) remain the single source of truth. +//! +//! This module (C2b first slice) mirrors post-mutation card snapshots //! into a crate `Store` and shadow-runs the crate `claim_card` CAS purely to //! prove parity ahead of the C2 cutover, logging any divergence. All work is //! best-effort and fire-and-forget: a mirror/claim failure is logged and diff --git a/src/openhuman/tools/impl/filesystem/glob_search.rs b/src/openhuman/tools/impl/filesystem/glob_search.rs index 982c5474f2..c73f15d3cc 100644 --- a/src/openhuman/tools/impl/filesystem/glob_search.rs +++ b/src/openhuman/tools/impl/filesystem/glob_search.rs @@ -301,7 +301,7 @@ fn collect_matches( } // Newest first. - hits.sort_by(|a, b| b.0.cmp(&a.0)); + hits.sort_by_key(|item| std::cmp::Reverse(item.0)); let truncated = hits.len() > max_results; let paths: Vec = hits.into_iter().take(max_results).map(|(_, p)| p).collect(); (paths, truncated) diff --git a/src/openhuman/tools/impl/network/polymarket.rs b/src/openhuman/tools/impl/network/polymarket.rs index c0b3218f9e..0da511fc46 100644 --- a/src/openhuman/tools/impl/network/polymarket.rs +++ b/src/openhuman/tools/impl/network/polymarket.rs @@ -1192,10 +1192,10 @@ impl Tool for PolymarketTool { // rejected by the CLOB. Credential derivation is similarly // single-flight (see ensure_clob_credentials OnceCell). Reads remain // concurrency-safe. - match args.get("action").and_then(Value::as_str) { - Some("place_order") | Some("cancel_order") => false, - _ => true, - } + !matches!( + args.get("action").and_then(Value::as_str), + Some("place_order") | Some("cancel_order") + ) } async fn execute(&self, args: Value) -> Result { diff --git a/src/openhuman/tools/impl/system/schedule.rs b/src/openhuman/tools/impl/system/schedule.rs index 712bc86050..50501839ca 100644 --- a/src/openhuman/tools/impl/system/schedule.rs +++ b/src/openhuman/tools/impl/system/schedule.rs @@ -90,10 +90,10 @@ impl Tool for ScheduleTool { // (create/add/once/cancel/remove/pause/resume) persist or remove a // scheduled job and must go through the ApprovalGate // (GHSA-f46p-6vf9-64mm). - match args.get("action").and_then(|v| v.as_str()) { - Some("list") | Some("get") => false, - _ => true, - } + !matches!( + args.get("action").and_then(|v| v.as_str()), + Some("list") | Some("get") + ) } async fn execute(&self, args: serde_json::Value) -> Result { diff --git a/src/openhuman/voice/factory/tests.rs b/src/openhuman/voice/factory/tests.rs index a5884ff09c..7ad2d2fa6d 100644 --- a/src/openhuman/voice/factory/tests.rs +++ b/src/openhuman/voice/factory/tests.rs @@ -2,14 +2,12 @@ use super::entry::{ create_stt_provider, create_tts_provider, default_stt_provider, default_tts_provider, - DEFAULT_WHISPER_MODEL, WHISPER_MODEL_PRESETS, + WHISPER_MODEL_PRESETS, }; use super::helpers::{effective_stt_provider, effective_tts_provider, split_slug_model}; use super::stt_providers::WhisperSttProvider; use super::traits::SttProvider; -use crate::openhuman::config::schema::voice_providers::{ - SttApiStyle, TtsApiStyle, VoiceCapability, -}; +use crate::openhuman::config::schema::voice_providers::{SttApiStyle, VoiceCapability}; use crate::openhuman::config::Config; fn cfg() -> Config { diff --git a/src/openhuman/voice/schemas/mod.rs b/src/openhuman/voice/schemas/mod.rs index e4900a2bdc..034276fba0 100644 --- a/src/openhuman/voice/schemas/mod.rs +++ b/src/openhuman/voice/schemas/mod.rs @@ -23,9 +23,8 @@ use helpers::{ }; #[cfg(test)] use params::{ - OverlaySttNotifyParams, OverlaySttState, ReplySynthesizeParams, SetProvidersParams, - SttDispatchParams, TranscribeBytesParams, TranscribeParams, TtsDispatchParams, TtsParams, - VoiceListModelsParams, VoiceProviderCredUpdate, VoiceTestProviderParams, + SetProvidersParams, SttDispatchParams, TranscribeBytesParams, TranscribeParams, + TtsDispatchParams, TtsParams, VoiceListModelsParams, VoiceTestProviderParams, VoiceUpdateProviderSettingsParams, }; diff --git a/src/openhuman/voice/server.rs b/src/openhuman/voice/server.rs index eaa354f84e..d6cc73118d 100644 --- a/src/openhuman/voice/server.rs +++ b/src/openhuman/voice/server.rs @@ -537,7 +537,7 @@ impl HotkeyListenerKind { fn start_hotkey_listener( hotkey_str: &str, mode: hotkey::ActivationMode, - server_cancel: &CancellationToken, + _server_cancel: &CancellationToken, ) -> Result< ( HotkeyListenerKind, @@ -548,7 +548,7 @@ fn start_hotkey_listener( #[cfg(target_os = "macos")] { if hotkey_str.trim().eq_ignore_ascii_case("fn") { - return start_globe_hotkey_listener(mode, server_cancel); + return start_globe_hotkey_listener(mode, _server_cancel); } // rdev calls TSMGetInputSourceProperty off the main thread; macOS 26 // enforces main-queue-only access and crashes the process. Only the diff --git a/src/openhuman/wallet/chains/btc.rs b/src/openhuman/wallet/chains/btc.rs index 1a5d117bce..49f8b50f77 100644 --- a/src/openhuman/wallet/chains/btc.rs +++ b/src/openhuman/wallet/chains/btc.rs @@ -167,7 +167,7 @@ fn select_utxos( fee_sats: u64, ) -> Result<(Vec, u64), String> { let mut sorted = utxos.to_vec(); - sorted.sort_by(|a, b| b.value.cmp(&a.value)); + sorted.sort_by_key(|item| std::cmp::Reverse(item.value)); let target = amount_sats .checked_add(fee_sats) .ok_or_else(|| "amount + fee overflow".to_string())?; diff --git a/src/openhuman/webview_accounts/ops.rs b/src/openhuman/webview_accounts/ops.rs index 7b45682b76..67b9db7309 100644 --- a/src/openhuman/webview_accounts/ops.rs +++ b/src/openhuman/webview_accounts/ops.rs @@ -34,7 +34,7 @@ struct Provider { /// Providers tracked in the webview-account snapshot. Keep this list aligned /// with the webview accounts system in `app/src-tauri/src/webview_accounts/`. -pub(crate) const PROVIDERS: &[Provider] = &[ +const PROVIDERS: &[Provider] = &[ Provider { key: "gmail", host_suffixes: &[".google.com"], diff --git a/src/openhuman/webview_apis/client.rs b/src/openhuman/webview_apis/client.rs index a5bc26ad5c..8435dcf39a 100644 --- a/src/openhuman/webview_apis/client.rs +++ b/src/openhuman/webview_apis/client.rs @@ -34,6 +34,8 @@ pub const PORT_ENV: &str = "OPENHUMAN_WEBVIEW_APIS_PORT"; const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); static CLIENT: OnceLock = OnceLock::new(); +type PendingResponse = oneshot::Sender>; +type PendingRequests = Arc>>; fn client() -> &'static Client { CLIENT.get_or_init(Client::new) @@ -75,7 +77,7 @@ where struct Client { next_id: AtomicU64, - pending: Arc>>>>, + pending: PendingRequests, sink: Arc>>>, } diff --git a/src/openhuman/x402/x402_tests.rs b/src/openhuman/x402/x402_tests.rs index 9cfc74cbc1..d1b8746294 100644 --- a/src/openhuman/x402/x402_tests.rs +++ b/src/openhuman/x402/x402_tests.rs @@ -1,8 +1,6 @@ -use base64::engine::{general_purpose::STANDARD as B64, Engine as _}; -use serde_json::json; - use super::ops::{build_evm_payment_with_signer, eip3009_struct_hash, eip712_domain_separator}; use super::types::*; +use base64::engine::{general_purpose::STANDARD as B64, Engine as _}; #[test] fn parse_payment_required_round_trips() { diff --git a/tests/agent_harness_e2e.rs b/tests/agent_harness_e2e.rs index 9f854b7694..8bbd69760a 100644 --- a/tests/agent_harness_e2e.rs +++ b/tests/agent_harness_e2e.rs @@ -1861,7 +1861,8 @@ async fn approval_gate_timeout_inner() { // ─── Task 7: Max iterations + empty provider response ──────────────────────── // // max_iterations_exceeded: -// Default max_tool_iterations = 10 (tool_loop.rs:15). +// The orchestrator's effective max_tool_iterations comes from its agent +// definition (currently 15), rather than the global default of 10. // Circuit breakers (REPEAT_FAILURE_THRESHOLD=3 on failing calls, // NO_PROGRESS_FAILURE_THRESHOLD=6 on consecutive fails) only fire on // success=false outcomes. We must pick a tool that: @@ -1879,9 +1880,9 @@ async fn approval_gate_timeout_inner() { // resolve_time IS in ops.rs (line 192) and in orchestrator named (agent.toml:173). // It's a pure chrono calculation, no I/O, always succeeds. Varying the // `expression` arg (format!("{i}m ago")) gives a different hash each iteration, -// preventing REPEAT_OUTPUT_THRESHOLD from firing. Queuing 12 calls trips the -// max_tool_iterations cap at 10 (DEFAULT_MAX_TOOL_ITERATIONS, tool_loop.rs:15). -// AgentError::MaxIterationsExceeded → "Agent exceeded maximum tool iterations (10)" +// preventing REPEAT_OUTPUT_THRESHOLD from firing. Queuing beyond the +// definition-derived cap trips max_tool_iterations. AgentError::MaxIterationsExceeded → +// "Agent exceeded maximum tool iterations (N)" // (error.rs:89-90; MAX_ITERATIONS_ERROR_PREFIX at error.rs:176). // // empty_provider_response: @@ -1910,11 +1911,19 @@ fn max_iterations_exceeded() { async fn max_iterations_exceeded_inner() { let _lock = env_lock(); - // 12 tool calls > default max_tool_iterations (10). Each uses a unique + init_agent_def_registry(); + let max_iterations = AgentDefinitionRegistry::global() + .and_then(|registry| registry.get("orchestrator")) + .map(|definition| definition.effective_max_iterations()) + .expect("built-in orchestrator definition must exist"); + + // Queue beyond the definition-derived cap. Deriving this count from the + // same definition used by the session builder keeps the regression valid + // when the orchestrator's policy changes. Each call uses a unique // expression to prevent REPEAT_OUTPUT_THRESHOLD from firing first. // resolve_time is a pure computation tool (no I/O) that always succeeds. // The required parameter name is "expr" (resolve_time.rs schema). - let responses: Vec = (0..12) + let responses: Vec = (0..max_iterations + 5) .map(|i| tool_call_completion("resolve_time", json!({ "expr": format!("{}m ago", i + 1) }))) .collect(); reset_script(responses); diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 90668c3263..bcd7c0e0d6 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -485,12 +485,6 @@ fn mock_upstream_router() -> Router { let checkout_url = "http://127.0.0.1/mock-checkout"; let session_id = "cs_mock_abc"; - if checkout_url.is_empty() || session_id.is_empty() { - return Err(error_json( - StatusCode::BAD_REQUEST, - "missing checkoutUrl or sessionId", - )); - } Ok(Json(json!({ "success": true, @@ -501,9 +495,6 @@ fn mock_upstream_router() -> Router { async fn stripe_portal(headers: HeaderMap) -> Result, (StatusCode, Json)> { require_bearer(&headers, BILLING_TOKEN)?; let portal_url = "http://127.0.0.1/mock-portal"; - if portal_url.is_empty() { - return Err(error_json(StatusCode::BAD_REQUEST, "missing portalUrl")); - } Ok(Json(json!({ "success": true, @@ -1141,7 +1132,7 @@ async fn json_rpc_config_update_browser_settings_persists_backend() { let updated = post_json_rpc( &rpc_base, - 4124_1, + 41_241, "openhuman.config_update_browser_settings", json!({ "enabled": true, @@ -1167,7 +1158,7 @@ async fn json_rpc_config_update_browser_settings_persists_backend() { "browser backend should persist in update response: {updated_snapshot}" ); - let get = post_json_rpc(&rpc_base, 4124_2, "openhuman.config_get", json!({})).await; + let get = post_json_rpc(&rpc_base, 41_242, "openhuman.config_get", json!({})).await; let get_result = assert_no_jsonrpc_error(&get, "config_get"); let snapshot = peel_logs_envelope(get_result); assert_eq!( @@ -1180,7 +1171,7 @@ async fn json_rpc_config_update_browser_settings_persists_backend() { let invalid = post_json_rpc( &rpc_base, - 4124_3, + 41_243, "openhuman.config_update_browser_settings", json!({ "backend": "netscape" @@ -1201,7 +1192,7 @@ async fn json_rpc_tokenjuice_detect_and_cache_stats() { // detect: a JSON array of objects classifies as `json`. let detect = post_json_rpc( &rpc_base, - 1860_1, + 18_601, "openhuman.tokenjuice_detect", json!({ "content": r#"[{"a":1,"b":2},{"a":3,"b":4}]"# }), ) @@ -1215,7 +1206,7 @@ async fn json_rpc_tokenjuice_detect_and_cache_stats() { // cache_stats: returns numeric occupancy fields. let stats = post_json_rpc( &rpc_base, - 1860_2, + 18_602, "openhuman.tokenjuice_cache_stats", json!({}), ) @@ -1240,7 +1231,7 @@ async fn json_rpc_tokenjuice_settings_and_savings() { // CCR token threshold (default 500) and the router master switch. let get = post_json_rpc( &rpc_base, - 1861_1, + 18_611, "openhuman.tokenjuice_settings_get", json!({}), ) @@ -1261,7 +1252,7 @@ async fn json_rpc_tokenjuice_settings_and_savings() { // settings_update: flip the CCR token threshold and confirm it round-trips. let updated = post_json_rpc( &rpc_base, - 1861_2, + 18_612, "openhuman.tokenjuice_settings_update", json!({ "patch": { "ccr_min_tokens": 750 } }), ) @@ -1278,7 +1269,7 @@ async fn json_rpc_tokenjuice_settings_and_savings() { // savings_stats: returns the aggregate shape (total + attribution model + cache). let savings = post_json_rpc( &rpc_base, - 1861_3, + 18_613, "openhuman.tokenjuice_savings_stats", json!({}), ) @@ -1295,7 +1286,7 @@ async fn json_rpc_tokenjuice_settings_and_savings() { // savings_reset: zeroes the totals. let reset = post_json_rpc( &rpc_base, - 1861_4, + 18_614, "openhuman.tokenjuice_savings_reset", json!({}), ) @@ -1312,7 +1303,7 @@ async fn json_rpc_tool_registry_lists_and_gets_entries() { let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; let rpc_base = format!("http://{rpc_addr}"); - let list = post_json_rpc(&rpc_base, 1848_1, "openhuman.tool_registry_list", json!({})).await; + let list = post_json_rpc(&rpc_base, 18_481, "openhuman.tool_registry_list", json!({})).await; let list_result = assert_no_jsonrpc_error(&list, "tool_registry_list"); let tools = list_result .get("tools") @@ -1359,7 +1350,7 @@ async fn json_rpc_tool_registry_lists_and_gets_entries() { let get = post_json_rpc( &rpc_base, - 1848_2, + 18_482, "openhuman.tool_registry_get", json!({ "tool_id": "tools.web_search" }), ) @@ -1381,7 +1372,7 @@ async fn json_rpc_tool_registry_lists_and_gets_entries() { let missing = post_json_rpc( &rpc_base, - 1848_3, + 18_483, "openhuman.tool_registry_get", json!({ "tool_id": "missing.tool" }), ) @@ -1405,7 +1396,7 @@ async fn json_rpc_monitor_list_and_read_surface() { let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; let rpc_base = format!("http://{rpc_addr}"); - let list = post_json_rpc(&rpc_base, 3371_1, "openhuman.monitor_list", json!({})).await; + let list = post_json_rpc(&rpc_base, 33_711, "openhuman.monitor_list", json!({})).await; let list_result = assert_no_jsonrpc_error(&list, "monitor_list"); let monitors = list_result .get("monitors") @@ -1418,7 +1409,7 @@ async fn json_rpc_monitor_list_and_read_surface() { let missing = post_json_rpc( &rpc_base, - 3371_2, + 33_712, "openhuman.monitor_read", json!({ "monitor_id": "mon_missing" }), ) @@ -1447,7 +1438,7 @@ async fn json_rpc_harness_init_status_returns_snapshot_envelope() { // attempt real Python/Node/spaCy downloads). let resp = post_json_rpc( &rpc_base, - 4471_1, + 44_711, "openhuman.harness_init_status", json!({}), ) @@ -1500,7 +1491,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let list = post_json_rpc( &rpc_base, - 2862_1, + 28_621, "openhuman.agent_registry_list", json!({ "include_disabled": true }), ) @@ -1525,7 +1516,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let definitions = post_json_rpc( &rpc_base, - 2862_1_1, + 286_211, "openhuman.agent_list_definitions", json!({}), ) @@ -1560,7 +1551,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let missing = post_json_rpc( &rpc_base, - 2862_10, + 286_210, "openhuman.agent_registry_get", json!({ "id": "does_not_exist" }), ) @@ -1572,7 +1563,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let update_default = post_json_rpc( &rpc_base, - 2862_11, + 286_211, "openhuman.agent_registry_update", json!({ "id": "researcher", @@ -1604,7 +1595,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let update_missing = post_json_rpc( &rpc_base, - 2862_12, + 286_212, "openhuman.agent_registry_update", json!({ "id": "missing_agent", "enabled": false }), ) @@ -1622,7 +1613,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let disabled = post_json_rpc( &rpc_base, - 2862_2, + 28_622, "openhuman.agent_registry_set_enabled", json!({ "id": "code_executor", "enabled": false }), ) @@ -1645,7 +1636,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let visible = post_json_rpc( &rpc_base, - 2862_3, + 28_623, "openhuman.agent_registry_list", json!({}), ) @@ -1664,7 +1655,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let all_after_disable = post_json_rpc( &rpc_base, - 2862_13, + 286_213, "openhuman.agent_registry_list", json!({ "include_disabled": true }), ) @@ -1689,7 +1680,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let reenabled = post_json_rpc( &rpc_base, - 2862_14, + 286_214, "openhuman.agent_registry_set_enabled", json!({ "id": "code_executor", "enabled": true }), ) @@ -1704,7 +1695,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let disabled_orchestrator = post_json_rpc( &rpc_base, - 2862_31, + 286_231, "openhuman.agent_registry_set_enabled", json!({ "id": "orchestrator", "enabled": false }), ) @@ -1724,7 +1715,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let update_orchestrator_disabled = post_json_rpc( &rpc_base, - 2862_32, + 286_232, "openhuman.agent_registry_update", json!({ "id": "orchestrator", "enabled": false }), ) @@ -1744,7 +1735,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let created = post_json_rpc( &rpc_base, - 2862_4, + 28_624, "openhuman.agent_registry_create_custom", json!({ "id": "custom_writer", @@ -1780,7 +1771,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let get_custom = post_json_rpc( &rpc_base, - 2862_5, + 28_625, "openhuman.agent_registry_get", json!({ "id": "custom_writer" }), ) @@ -1797,7 +1788,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let updated_custom = post_json_rpc( &rpc_base, - 2862_15, + 286_215, "openhuman.agent_registry_update", json!({ "id": "custom_writer", @@ -1838,7 +1829,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let reenabled_custom = post_json_rpc( &rpc_base, - 2862_16, + 286_216, "openhuman.agent_registry_set_enabled", json!({ "id": "custom_writer", "enabled": true }), ) @@ -1853,7 +1844,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let full_upsert = post_json_rpc( &rpc_base, - 2862_17, + 286_217, "openhuman.agent_registry_upsert_custom", json!({ "agent": { @@ -1901,7 +1892,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let visible_after_custom_disable = post_json_rpc( &rpc_base, - 2862_18, + 286_218, "openhuman.agent_registry_list", json!({}), ) @@ -1922,7 +1913,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let default_collision = post_json_rpc( &rpc_base, - 2862_6, + 28_626, "openhuman.agent_registry_create_custom", json!({ "id": "orchestrator", @@ -1946,7 +1937,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let removed_reviewer = post_json_rpc( &rpc_base, - 2862_19, + 286_219, "openhuman.agent_registry_remove", json!({ "id": "custom_reviewer" }), ) @@ -1960,7 +1951,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let removed_custom = post_json_rpc( &rpc_base, - 2862_7, + 28_627, "openhuman.agent_registry_remove", json!({ "id": "custom_writer" }), ) @@ -1976,7 +1967,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let removed_missing = post_json_rpc( &rpc_base, - 2862_20, + 286_220, "openhuman.agent_registry_remove", json!({ "id": "missing_agent" }), ) @@ -1990,7 +1981,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let reset_default = post_json_rpc( &rpc_base, - 2862_21, + 286_221, "openhuman.agent_registry_remove", json!({ "id": "researcher" }), ) @@ -2004,7 +1995,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let reset_code_executor = post_json_rpc( &rpc_base, - 2862_22, + 286_222, "openhuman.agent_registry_remove", json!({ "id": "code_executor" }), ) @@ -2021,7 +2012,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let code_executor = post_json_rpc( &rpc_base, - 2862_23, + 286_223, "openhuman.agent_registry_get", json!({ "id": "code_executor" }), ) @@ -2038,7 +2029,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { // a {name, description} pair whose name is a valid tool_allowlist value. let available_tools = post_json_rpc( &rpc_base, - 2862_24, + 286_224, "openhuman.agent_registry_available_tools", json!({}), ) @@ -2201,12 +2192,11 @@ async fn json_rpc_protocol_auth_and_agent_hello_inner() { Some(thread_id) ); assert!( - sse_event + !sse_event .get("full_response") .and_then(Value::as_str) .unwrap_or_default() - .len() - > 0, + .is_empty(), "expected non-empty chat_done response payload: {sse_event}" ); @@ -3011,7 +3001,7 @@ async fn json_rpc_thread_goal_lifecycle() { // Pull the `{ goal }` payload out of either response shape: a bare // `{ goal }` (get) or the `{ result: { goal }, logs }` CLI envelope // (mutations). - fn goal_of<'a>(result: &'a Value) -> Option<&'a Value> { + fn goal_of(result: &Value) -> Option<&Value> { let envelope = result.get("result").unwrap_or(result); envelope.get("goal").filter(|g| !g.is_null()) } @@ -5535,9 +5525,9 @@ async fn json_rpc_web_chat_custom_chat_provider_with_auth_none_omits_auth_header ) .await; assert_no_jsonrpc_error(&update, "update_model_settings"); - let cfg = post_json_rpc(&rpc_base, 6102_1, "openhuman.config_get", json!({})).await; + let cfg = post_json_rpc(&rpc_base, 61_021, "openhuman.config_get", json!({})).await; let cfg_outer = assert_no_jsonrpc_error(&cfg, "config_get auth-none"); - let cfg_payload = cfg_outer.get("result").unwrap_or(&cfg_outer); + let cfg_payload = cfg_outer.get("result").unwrap_or(cfg_outer); let config = cfg_payload.get("config").unwrap_or(cfg_payload); assert_eq!( config.get("chat_provider").and_then(Value::as_str), @@ -6079,7 +6069,7 @@ async fn json_rpc_app_state_update_local_state_round_trips_into_snapshot() { ) .await; let update_result = assert_no_jsonrpc_error(&update, "app_state_update_local_state"); - let updated_state = update_result.get("result").unwrap_or(&update_result); + let updated_state = update_result.get("result").unwrap_or(update_result); assert_eq!( updated_state.get("encryptionKey").and_then(Value::as_str), Some("secret-key") @@ -6087,7 +6077,7 @@ async fn json_rpc_app_state_update_local_state_round_trips_into_snapshot() { let snapshot = post_json_rpc(&rpc_base, 10042, "openhuman.app_state_snapshot", json!({})).await; let snapshot_result = assert_no_jsonrpc_error(&snapshot, "app_state_snapshot after update"); - let body = snapshot_result.get("result").unwrap_or(&snapshot_result); + let body = snapshot_result.get("result").unwrap_or(snapshot_result); let local_state = body .get("localState") .and_then(Value::as_object) @@ -6275,7 +6265,7 @@ async fn json_rpc_wallet_execution_surface_round_trips() { ) .await; let body = assert_no_jsonrpc_error(&assets, "wallet_supported_assets"); - let result = body.get("result").unwrap_or(&body); + let result = body.get("result").unwrap_or(body); let list = result.as_array().expect("supported_assets array"); // Pin the actual expected multi-chain catalog (not just a lower bound) so a // regression that silently drops a network is caught. @@ -6320,7 +6310,7 @@ async fn json_rpc_wallet_execution_surface_round_trips() { // chain_status: every chain is configured, so the provider row is ready. let cs = post_json_rpc(&rpc_base, 2003, "openhuman.wallet_chain_status", json!({})).await; let body = assert_no_jsonrpc_error(&cs, "wallet_chain_status"); - let result = body.get("result").unwrap_or(&body); + let result = body.get("result").unwrap_or(body); let rows = result.as_array().expect("chain_status array"); // 6 EVM rows (one per L2 / mainnet, incl. BNB Chain) + 3 non-EVM chains. assert_eq!(rows.len(), 9); @@ -6335,7 +6325,7 @@ async fn json_rpc_wallet_execution_surface_round_trips() { // BTC + Solana + Tron = 6. let balances = post_json_rpc(&rpc_base, 2004, "openhuman.wallet_balances", json!({})).await; let body = assert_no_jsonrpc_error(&balances, "wallet_balances"); - let result = body.get("result").unwrap_or(&body); + let result = body.get("result").unwrap_or(body); let rows = result.as_array().expect("balances array"); assert_eq!(rows.len(), 6); assert_eq!( @@ -6369,7 +6359,7 @@ async fn json_rpc_wallet_execution_surface_round_trips() { ) .await; let body = assert_no_jsonrpc_error(&prep, "wallet_prepare_transfer"); - let result = body.get("result").unwrap_or(&body); + let result = body.get("result").unwrap_or(body); let quote_id = result .get("quoteId") .and_then(Value::as_str) @@ -6406,7 +6396,7 @@ async fn json_rpc_wallet_execution_surface_round_trips() { ) .await; let body = assert_no_jsonrpc_error(&exec, "wallet_execute_prepared"); - let result = body.get("result").unwrap_or(&body); + let result = body.get("result").unwrap_or(body); assert_eq!( result.get("status").and_then(Value::as_str), Some("broadcasted"), @@ -6482,7 +6472,7 @@ async fn json_rpc_wallet_tx_reads_and_web3_gates_round_trip() { ) .await; let body = assert_no_jsonrpc_error(&status, "wallet_tx_status"); - let result = body.get("result").unwrap_or(&body); + let result = body.get("result").unwrap_or(body); assert_eq!( result.get("state").and_then(Value::as_str), Some("confirmed") @@ -6497,7 +6487,7 @@ async fn json_rpc_wallet_tx_reads_and_web3_gates_round_trip() { ) .await; let body = assert_no_jsonrpc_error(&receipt, "wallet_tx_receipt"); - let result = body.get("result").unwrap_or(&body); + let result = body.get("result").unwrap_or(body); assert_eq!(result.get("success").and_then(Value::as_bool), Some(true)); assert_eq!(result.get("gasUsed").and_then(Value::as_str), Some("21000")); @@ -6510,7 +6500,7 @@ async fn json_rpc_wallet_tx_reads_and_web3_gates_round_trip() { ) .await; let body = assert_no_jsonrpc_error(&lookup, "wallet_lookup_tx"); - let result = body.get("result").unwrap_or(&body); + let result = body.get("result").unwrap_or(body); assert_eq!(result.get("found").and_then(Value::as_bool), Some(true)); // web3_bridge rejects same-chain requests. This gate runs *before* any @@ -7110,7 +7100,7 @@ async fn json_rpc_wallet_tron_prepare_execute_round_trips() { ); assert_eq!( exec_result.get("transactionHash").and_then(Value::as_str), - Some(format!("{}", "cd".repeat(32)).as_str()), + Some("cd".repeat(32).to_string().as_str()), ); // Native TRX must go through createtransaction, NOT triggersmartcontract. let create_hits = *tron_mock.state.create_hits.lock().unwrap(); @@ -12158,8 +12148,7 @@ async fn json_rpc_task_sources_crud_and_status() { mod task_sources_stub { use async_trait::async_trait; use openhuman_core::openhuman::memory_sync::composio::providers::{ - ComposioProvider, NormalizedTask, ProviderContext, ProviderUserProfile, SyncOutcome, - SyncReason, TaskFetchFilter, + ComposioProvider, NormalizedTask, ProviderContext, ProviderUserProfile, TaskFetchFilter, }; pub struct StubGithubProvider { @@ -13785,7 +13774,7 @@ async fn json_rpc_memory_sync_settings_roundtrip_interval_and_manual() { ) .await; let initial_outer = assert_no_jsonrpc_error(&initial, "get_memory_sync_settings initial"); - let initial_result = initial_outer.get("result").unwrap_or(&initial_outer); + let initial_result = initial_outer.get("result").unwrap_or(initial_outer); assert!( initial_result.get("sync_interval_secs").map(Value::is_null) == Some(true), "default stored value should be null, envelope: {initial_outer}" @@ -13810,7 +13799,7 @@ async fn json_rpc_memory_sync_settings_roundtrip_interval_and_manual() { ) .await; let update_outer = assert_no_jsonrpc_error(&update, "update_memory_sync_settings 4h"); - let update_result = update_outer.get("result").unwrap_or(&update_outer); + let update_result = update_outer.get("result").unwrap_or(update_outer); assert_eq!( update_result .get("sync_interval_secs") @@ -13832,7 +13821,7 @@ async fn json_rpc_memory_sync_settings_roundtrip_interval_and_manual() { ) .await; let after_outer = assert_no_jsonrpc_error(&after, "get_memory_sync_settings after 4h"); - let after_result = after_outer.get("result").unwrap_or(&after_outer); + let after_result = after_outer.get("result").unwrap_or(after_outer); assert_eq!( after_result .get("sync_interval_secs") @@ -13850,7 +13839,7 @@ async fn json_rpc_memory_sync_settings_roundtrip_interval_and_manual() { ) .await; let manual_outer = assert_no_jsonrpc_error(&manual, "update_memory_sync_settings manual"); - let manual_result = manual_outer.get("result").unwrap_or(&manual_outer); + let manual_result = manual_outer.get("result").unwrap_or(manual_outer); assert_eq!( manual_result.get("is_manual").and_then(Value::as_bool), Some(true), @@ -13866,7 +13855,7 @@ async fn json_rpc_memory_sync_settings_roundtrip_interval_and_manual() { ) .await; let manual_get_outer = assert_no_jsonrpc_error(&manual_get, "get_memory_sync_settings manual"); - let manual_get_result = manual_get_outer.get("result").unwrap_or(&manual_get_outer); + let manual_get_result = manual_get_outer.get("result").unwrap_or(manual_get_outer); assert_eq!( manual_get_result.get("is_manual").and_then(Value::as_bool), Some(true), @@ -13918,7 +13907,7 @@ async fn json_rpc_memory_sync_settings_env_override_is_reflected() { ) .await; let outer = assert_no_jsonrpc_error(&resp, "get_memory_sync_settings env-override"); - let result = outer.get("result").unwrap_or(&outer); + let result = outer.get("result").unwrap_or(outer); assert_eq!( result.get("sync_interval_secs").and_then(Value::as_u64), Some(28_800), @@ -14517,7 +14506,7 @@ async fn json_rpc_workflow_run_engine_executes_builtin_to_completion_inner() { // Authenticate so the child agents' provider has a backend session. let store = post_json_rpc( &rpc_base, - 37_500_1, + 375_001, "openhuman.auth_store_session", json!({ "token": "e2e-test-jwt", "user_id": "e2e-user" }), ) @@ -14527,7 +14516,7 @@ async fn json_rpc_workflow_run_engine_executes_builtin_to_completion_inner() { // Sanity: the builtin definition is listed. let defs = post_json_rpc( &rpc_base, - 37_500_2, + 375_002, "openhuman.workflow_run_list_definitions", json!({}), ) @@ -14548,7 +14537,7 @@ async fn json_rpc_workflow_run_engine_executes_builtin_to_completion_inner() { // `modelOverride` pin → every phase hits the mock chat-completions route. let start = post_json_rpc( &rpc_base, - 37_500_3, + 375_003, "openhuman.workflow_run_start", json!({ "definitionId": definition_id, @@ -14662,7 +14651,7 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { // Authenticate so the spawned workers' provider has a backend session. let store = post_json_rpc( &rpc_base, - 38_000_1, + 380_001, "openhuman.auth_store_session", json!({ "token": "e2e-test-jwt", "user_id": "e2e-user" }), ) @@ -14672,7 +14661,7 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { // Lead creates a team with two named teammates. let created = post_json_rpc( &rpc_base, - 38_000_2, + 380_002, "openhuman.agent_team_create", json!({ "leadAgentId": "lead", @@ -14709,7 +14698,7 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { // Task A (no deps) owned by alice; Task B depends on A, owned by bob. let assign_a = post_json_rpc( &rpc_base, - 38_000_3, + 380_003, "openhuman.agent_team_assign_task", json!({ "teamId": team_id, "title": "Task A", "ownerMemberId": alice_id, "dependsOn": [] }), ) @@ -14722,7 +14711,7 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { .to_string(); let assign_b = post_json_rpc( &rpc_base, - 38_000_4, + 380_004, "openhuman.agent_team_assign_task", json!({ "teamId": team_id, "title": "Task B", "ownerMemberId": bob_id, "dependsOn": [task_a_id.clone()] }), ) @@ -14737,7 +14726,7 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { // Lead messages a named teammate (fromMemberId omitted → lead origin). let msg = post_json_rpc( &rpc_base, - 38_000_5, + 380_005, "openhuman.agent_team_message_member", json!({ "teamId": team_id, "toMemberId": alice_id, "content": "please start Task A" }), ) @@ -14747,7 +14736,7 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { // Start alice live on Task A → kind "started". let start_a = post_json_rpc( &rpc_base, - 38_000_6, + 380_006, "openhuman.agent_team_start_member", json!({ "teamId": team_id, "memberId": alice_id, "taskId": task_a_id, "modelOverride": "e2e-mock-model" }), ) @@ -14770,7 +14759,7 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { // With A done, start bob live on Task B. let start_b = post_json_rpc( &rpc_base, - 38_000_7, + 380_007, "openhuman.agent_team_start_member", json!({ "teamId": team_id, "memberId": bob_id, "taskId": task_b_id, "modelOverride": "e2e-mock-model" }), ) @@ -14792,7 +14781,7 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { // lead message is in the team timeline. let got = post_json_rpc( &rpc_base, - 38_000_8, + 380_008, "openhuman.agent_team_get", json!({ "teamId": team_id }), ) @@ -14833,7 +14822,7 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { let messages = post_json_rpc( &rpc_base, - 38_000_9, + 380_009, "openhuman.agent_team_list_messages", json!({ "teamId": team_id }), ) diff --git a/tests/keyring_secretstore_e2e.rs b/tests/keyring_secretstore_e2e.rs index 5adaea2b6f..a5d3fa1cd5 100644 --- a/tests/keyring_secretstore_e2e.rs +++ b/tests/keyring_secretstore_e2e.rs @@ -1,10 +1,12 @@ use openhuman_core::openhuman::config::schema::{Config, StreamMode, TelegramConfig}; use openhuman_core::openhuman::keyring; -use std::sync::{Mutex, OnceLock}; +use std::sync::OnceLock; -fn env_lock() -> std::sync::MutexGuard<'static, ()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())).lock().unwrap() +async fn env_lock() -> tokio::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| tokio::sync::Mutex::new(())) + .lock() + .await } struct EnvGuard { @@ -45,7 +47,7 @@ impl Drop for EnvGuard { #[tokio::test] async fn config_secrets_roundtrip_via_keyring_backed_master_key_migration() { - let _guard = env_lock(); + let _guard = env_lock().await; let tmp = tempfile::tempdir().expect("tempdir"); let openhuman_dir = tmp.path().join("user-123"); let workspace_dir = openhuman_dir.join("workspace"); diff --git a/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs b/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs index 0e0d455b69..37de29f215 100644 --- a/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs +++ b/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs @@ -411,13 +411,21 @@ async fn max_iteration_checkpoint_uses_deterministic_fallback_and_hooks() { let calls = Arc::new(AtomicUsize::new(0)); let hook_calls = Arc::new(AtomicUsize::new(0)); let hook_contexts = Arc::new(Mutex::new(Vec::new())); - // The wrap-up model call yields nothing (empty text + no streamed deltas), so - // `summarize_turn_wrapup` returns an empty summary and the cap path falls back - // to `build_deterministic_checkpoint` — the exact path this test covers. (A - // non-empty wrap-up would instead be surfaced verbatim as the answer.) + // The wrap-up ignores the no-tools instruction and emits another + // prompt-formatted tool call plus a streamed delta. Validation must reject + // both before progress consumers see them, then use the deterministic + // checkpoint fallback. let provider = ScriptedProvider::with_stream( - vec![xml_tool_response("alpha"), text_response("", None)], - vec![], + vec![ + xml_tool_response("alpha"), + text_response( + "{\"name\":\"round24_echo\",\"arguments\":{\"value\":\"again\"}}", + None, + ), + ], + vec![ProviderDelta::TextDelta { + delta: "checkpoint delta".to_string(), + }], ); let mut agent = Agent::builder() @@ -476,12 +484,13 @@ async fn max_iteration_checkpoint_uses_deterministic_fallback_and_hooks() { while let Ok(event) = progress_rx.try_recv() { streamed.push(event); } - // The wrap-up produced no text, so no iteration-2 wrap-up delta is streamed; - // the deterministic fallback (asserted above) becomes the answer instead. assert!(!streamed.iter().any(|event| matches!( event, - openhuman_core::openhuman::agent::progress::AgentProgress::TextDelta { iteration: 2, .. } - ))); + openhuman_core::openhuman::agent::progress::AgentProgress::TextDelta { + delta, + iteration: 2 + } if delta == "checkpoint delta" + )), "rejected checkpoint deltas must not leak to progress consumers"); } #[tokio::test] diff --git a/tests/subconscious_conversation_e2e.rs b/tests/subconscious_conversation_e2e.rs index 8b4e2fd6cb..713c940ceb 100644 --- a/tests/subconscious_conversation_e2e.rs +++ b/tests/subconscious_conversation_e2e.rs @@ -217,11 +217,11 @@ impl SessionExecutor for ScriptedSession { // ───────────────────────────────────────────────────────────────────────────── /// Serializes tests that touch the process-global event bus. -fn bus_lock() -> std::sync::MutexGuard<'static, ()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| StdMutex::new(())) +async fn bus_lock() -> tokio::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| tokio::sync::Mutex::new(())) .lock() - .unwrap_or_else(|p| p.into_inner()) + .await } struct Harness { @@ -350,7 +350,7 @@ fn human_msg(channel: &str, sender: &str, message: &str) -> DomainEvent { #[tokio::test] async fn conversation_human_delegates_then_subagent_reports_back() { - let _g = bus_lock(); + let _g = bus_lock().await; let h = Harness::new(OrchestratorConfig::default()); // Human asks for deep work. @@ -382,7 +382,7 @@ async fn conversation_human_delegates_then_subagent_reports_back() { #[tokio::test] async fn conversation_subagent_failure_recovers_with_retry() { - let _g = bus_lock(); + let _g = bus_lock().await; let h = Harness::new(OrchestratorConfig::default()); // Inject a sub-agent FAILURE conclusion directly (as if a prior spawn failed). @@ -408,7 +408,7 @@ async fn conversation_subagent_failure_recovers_with_retry() { #[tokio::test] async fn conversation_interleaved_traffic_is_handled() { - let _g = bus_lock(); + let _g = bus_lock().await; let h = Harness::new(OrchestratorConfig::default()); // Routine cron tick — gate should drop it (no session run). @@ -446,7 +446,7 @@ async fn conversation_interleaved_traffic_is_handled() { #[tokio::test] async fn conversation_promotion_budget_caps_a_burst() { - let _g = bus_lock(); + let _g = bus_lock().await; // The scripted gate intentionally has no promotion budget (that lives in // the real GatePass), so this scenario isolates the *rate limiter*: a // flood of distinct user messages from one source is capped by the token diff --git a/tests/transcript_search_e2e.rs b/tests/transcript_search_e2e.rs index c2cecc013c..84b1f8b9f0 100644 --- a/tests/transcript_search_e2e.rs +++ b/tests/transcript_search_e2e.rs @@ -13,7 +13,7 @@ //! Run with: `cargo test --test transcript_search_e2e` use std::path::Path; -use std::sync::{Mutex, OnceLock}; +use std::sync::OnceLock; use serde_json::json; use tempfile::tempdir; @@ -53,13 +53,13 @@ impl Drop for EnvVarGuard { } /// Serialises tests: `HOME` + `OPENHUMAN_WORKSPACE` are process-global. -static ENV_LOCK: OnceLock> = OnceLock::new(); +static ENV_LOCK: OnceLock> = OnceLock::new(); -fn env_lock() -> std::sync::MutexGuard<'static, ()> { +async fn env_lock() -> tokio::sync::MutexGuard<'static, ()> { ENV_LOCK - .get_or_init(|| Mutex::new(())) + .get_or_init(|| tokio::sync::Mutex::new(())) .lock() - .expect("env lock poisoned") + .await } // ── Fixture helpers ────────────────────────────────────────────────────────── @@ -143,7 +143,7 @@ fn seed_workspace(workspace: &Path) -> ConversationStore { /// scopes the hit to the thread that actually contains it. #[tokio::test] async fn transcript_search_op_finds_message_in_prior_thread() { - let _lock = env_lock(); + let _lock = env_lock().await; let tmp = tempdir().expect("tempdir"); let _home = EnvVarGuard::set_to_path("HOME", tmp.path()); let workspace = tmp.path().join("workspace"); @@ -174,7 +174,7 @@ async fn transcript_search_op_finds_message_in_prior_thread() { /// orchestrator can use to omit the active chat it already has in hand. #[tokio::test] async fn transcript_search_op_honours_exclude_thread() { - let _lock = env_lock(); + let _lock = env_lock().await; let tmp = tempdir().expect("tempdir"); let _home = EnvVarGuard::set_to_path("HOME", tmp.path()); let workspace = tmp.path().join("workspace"); @@ -195,7 +195,7 @@ async fn transcript_search_op_honours_exclude_thread() { /// A query that matches nothing returns no hits (not an error). #[tokio::test] async fn transcript_search_op_returns_empty_on_no_match() { - let _lock = env_lock(); + let _lock = env_lock().await; let tmp = tempdir().expect("tempdir"); let _home = EnvVarGuard::set_to_path("HOME", tmp.path()); let workspace = tmp.path().join("workspace"); @@ -215,7 +215,7 @@ async fn transcript_search_op_returns_empty_on_no_match() { /// source thread and quotes a snippet of the matched message. #[tokio::test] async fn transcript_search_tool_formats_hits_for_the_agent() { - let _lock = env_lock(); + let _lock = env_lock().await; let tmp = tempdir().expect("tempdir"); let _home = EnvVarGuard::set_to_path("HOME", tmp.path()); let workspace = tmp.path().join("workspace"); @@ -247,7 +247,7 @@ async fn transcript_search_tool_formats_hits_for_the_agent() { /// can record "nothing in past chats" and move on. #[tokio::test] async fn transcript_search_tool_reports_no_match_cleanly() { - let _lock = env_lock(); + let _lock = env_lock().await; let tmp = tempdir().expect("tempdir"); let _home = EnvVarGuard::set_to_path("HOME", tmp.path()); let workspace = tmp.path().join("workspace"); @@ -276,7 +276,7 @@ async fn transcript_search_tool_reports_no_match_cleanly() { /// clean no-match line. #[tokio::test] async fn transcript_search_tool_excludes_named_thread() { - let _lock = env_lock(); + let _lock = env_lock().await; let tmp = tempdir().expect("tempdir"); let _home = EnvVarGuard::set_to_path("HOME", tmp.path()); let workspace = tmp.path().join("workspace"); @@ -301,7 +301,7 @@ async fn transcript_search_tool_excludes_named_thread() { /// searches all — this pins the empty-string contract regardless.) #[tokio::test] async fn transcript_search_tool_empty_exclude_searches_all_threads() { - let _lock = env_lock(); + let _lock = env_lock().await; let tmp = tempdir().expect("tempdir"); let _home = EnvVarGuard::set_to_path("HOME", tmp.path()); let workspace = tmp.path().join("workspace"); From bfaa261393383db8c41e60cbc4913c83cfee6b34 Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:48:48 +0530 Subject: [PATCH 17/18] fix(theme): polish Matrix + HAL 9000 (and other dark) presets to WCAG AA (#4897) Co-authored-by: Steven Enamakel --- app/src/lib/theme/color.test.ts | 16 +- app/src/lib/theme/color.ts | 14 ++ app/src/lib/theme/presets.contrast.test.ts | 187 +++++++++++++++++++++ app/src/lib/theme/presets.ts | 27 ++- 4 files changed, 238 insertions(+), 6 deletions(-) create mode 100644 app/src/lib/theme/presets.contrast.test.ts diff --git a/app/src/lib/theme/color.test.ts b/app/src/lib/theme/color.test.ts index d524195423..50e7d68f15 100644 --- a/app/src/lib/theme/color.test.ts +++ b/app/src/lib/theme/color.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { channelLuminance, channelsToHex, hexToChannels, isChannelTriple } from './color'; +import { + channelContrast, + channelLuminance, + channelsToHex, + hexToChannels, + isChannelTriple, +} from './color'; describe('theme colour helpers', () => { it('converts channel triples to hex', () => { @@ -38,4 +44,12 @@ describe('theme colour helpers', () => { expect(channelLuminance('0 0 0')).toBeCloseTo(0, 5); expect(channelLuminance('255 255 255')).toBeCloseTo(1, 5); }); + + it('computes WCAG contrast ratios (symmetric, 1…21)', () => { + expect(channelContrast('255 255 255', '0 0 0')).toBeCloseTo(21, 1); + expect(channelContrast('0 0 0', '255 255 255')).toBeCloseTo(21, 1); // symmetric + expect(channelContrast('128 128 128', '128 128 128')).toBeCloseTo(1, 5); // self + // sanity: a known AA-passing pair (stone-900 on white) is ≥ 4.5 + expect(channelContrast('23 23 23', '255 255 255')).toBeGreaterThan(4.5); + }); }); diff --git a/app/src/lib/theme/color.ts b/app/src/lib/theme/color.ts index 8a1f04db75..d6112d91df 100644 --- a/app/src/lib/theme/color.ts +++ b/app/src/lib/theme/color.ts @@ -63,3 +63,17 @@ export function channelLuminance(channels: string): number { const lin = parts.map(c => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)); return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]; } + +/** + * WCAG contrast ratio between two channel triples, in `[1, 21]`. Symmetric in + * its arguments. AA wants ≥ 4.5:1 for body text and ≥ 3:1 for large text / UI. + * Used to warn on custom themes and to gate the built-in presets (see + * `presets.contrast.test.ts`). + */ +export function channelContrast(a: string, b: string): number { + const la = channelLuminance(a); + const lb = channelLuminance(b); + const hi = Math.max(la, lb); + const lo = Math.min(la, lb); + return (hi + 0.05) / (lo + 0.05); +} diff --git a/app/src/lib/theme/presets.contrast.test.ts b/app/src/lib/theme/presets.contrast.test.ts new file mode 100644 index 0000000000..ad5459e62a --- /dev/null +++ b/app/src/lib/theme/presets.contrast.test.ts @@ -0,0 +1,187 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve as resolvePath } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import { channelContrast } from './color'; +import { PRESET_THEMES } from './presets'; +import type { Theme } from './types'; + +/** + * WCAG AA gate for every shipped **dark** preset. + * + * A preset only carries the tokens it overrides; everything else falls through + * to the `:root.dark` defaults in `app/src/styles/tokens.css`. Those defaults are + * not importable as JS, so we mirror the relevant subset here and resolve each + * theme by merging its `colors` over this base — the same layering ThemeProvider + * does at runtime. + * + * This mirror is not trusted blindly: the `DARK_BASE parity` test below parses + * tokens.css and fails if any value here drifts from the CSS source of truth, so + * a future token edit can't leave this gate green while runtime colours change. + */ +const DARK_BASE: Record = { + surface: '23 23 23', + 'surface-canvas': '0 0 0', + 'surface-muted': '38 38 38', + 'surface-subtle': '38 38 38', + 'surface-strong': '38 38 38', + 'surface-hover': '38 38 38', + 'surface-overlay': '0 0 0', + content: '245 245 245', + 'content-secondary': '212 212 212', + 'content-muted': '163 163 163', + 'content-faint': '115 115 115', + 'content-inverted': '255 255 255', + 'primary-200': '191 219 254', + 'primary-300': '147 197 253', + 'primary-400': '96 165 250', + 'primary-500': '47 110 244', + 'primary-600': '37 99 235', + 'primary-700': '29 78 216', +}; + +const AA_TEXT = 4.5; // body text +const AA_LARGE = 3.0; // large text / UI elements / disabled-placeholder + +/** + * Every surface layer text can land on — base, canvas, recessed wells, and the + * hover/pressed states — plus `surface-overlay` (the modal scrim, tested as a + * solid fill, which is the conservative worst case since it renders at < full + * opacity over another surface). + */ +const SURFACES = [ + 'surface', + 'surface-canvas', + 'surface-muted', + 'surface-subtle', + 'surface-strong', + 'surface-hover', + 'surface-overlay', +] as const; + +/** Text tiers held to full body contrast against every surface. */ +const BODY_TIERS = ['content', 'content-secondary', 'content-muted'] as const; + +function resolve(theme: Theme): Record { + return { ...DARK_BASE, ...theme.colors }; +} + +/** + * Parse the `--token: R G B;` declarations inside a single CSS rule block + * (`selector { … }`) from tokens.css into a `{ token: 'R G B' }` map. The theme + * blocks contain no nested braces, so a non-greedy `{ … }` match is sufficient. + */ +function parseTokenBlock(css: string, selector: string): Record { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = css.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`)); + if (!match) throw new Error(`tokens.css: block not found for "${selector}"`); + const out: Record = {}; + for (const line of match[1].split('\n')) { + const decl = line.match(/^\s*--([a-z0-9-]+):\s*([^;]+);/i); + if (decl) out[decl[1]] = decl[2].trim(); + } + return out; +} + +// Effective dark values = `:root` defaults overlaid by `:root.dark` — the same +// cascade the browser applies. Accents (primary-*) live only in `:root` and are +// inherited under dark, exactly as DARK_BASE encodes them. +const tokensCss = readFileSync( + resolvePath(dirname(fileURLToPath(import.meta.url)), '../../styles/tokens.css'), + 'utf8' +); +const effectiveDarkTokens = { + ...parseTokenBlock(tokensCss, ':root'), + ...parseTokenBlock(tokensCss, ':root.dark'), +}; + +describe('DARK_BASE parity with tokens.css', () => { + // If this fails, a tokens.css edit drifted from the mirror above — update + // DARK_BASE (and re-check the AA gate) rather than muting this test. + for (const key of Object.keys(DARK_BASE)) { + it(`--${key} matches the CSS source of truth`, () => { + expect(effectiveDarkTokens[key], `token --${key}`).toBe(DARK_BASE[key]); + }); + } +}); + +const darkPresets = PRESET_THEMES.filter(t => t.isDark && t.builtIn); + +describe('preset dark themes meet WCAG AA', () => { + it('ships the expected dark presets', () => { + // Guards against a preset being renamed/dropped without updating this gate. + expect(darkPresets.map(t => t.id).sort()).toEqual( + ['dark', 'hal9000', 'matrix', 'ocean-dark', 'sepia-dark'].sort() + ); + }); + + for (const theme of darkPresets) { + describe(`${theme.name} (${theme.id})`, () => { + const t = resolve(theme); + + it('body/muted text ≥ 4.5:1 on every surface state', () => { + for (const surface of SURFACES) { + for (const tier of BODY_TIERS) { + const ratio = channelContrast(t[tier], t[surface]); + expect( + ratio, + `${theme.id}: ${tier} on ${surface} = ${ratio.toFixed(2)}` + ).toBeGreaterThanOrEqual(AA_TEXT); + } + } + }); + + it('faint/placeholder text ≥ 3:1 on every surface state', () => { + for (const surface of SURFACES) { + const ratio = channelContrast(t['content-faint'], t[surface]); + expect( + ratio, + `${theme.id}: content-faint on ${surface} = ${ratio.toFixed(2)}` + ).toBeGreaterThanOrEqual(AA_LARGE); + } + }); + + it('primary button label ≥ 4.5:1 on its resting and active fills', () => { + // Button.tsx: bg-primary-500 (rest) / dark:active:bg-primary-600, label + // is text-content-inverted. The transient dark-mode hover fill + // (dark:hover:bg-primary-400) is deliberately NOT gated here: it lightens + // the fill app-wide, so even the untouched historical `dark` preset sits + // at ~2.5:1 white-on-primary-400. That is a shared Button behaviour, not a + // per-theme token, and fixing it needs a Button change, not a palette one. + for (const shade of ['primary-500', 'primary-600'] as const) { + const ratio = channelContrast(t['content-inverted'], t[shade]); + expect( + ratio, + `${theme.id}: content-inverted on ${shade} = ${ratio.toFixed(2)}` + ).toBeGreaterThanOrEqual(AA_TEXT); + } + }); + + it('accent/link text ≥ 4.5:1 on surface and canvas', () => { + // Dark-mode accent text uses the lighter shades (dark:text-primary-200…400). + for (const shade of ['primary-200', 'primary-300', 'primary-400'] as const) { + for (const surface of ['surface', 'surface-canvas'] as const) { + const ratio = channelContrast(t[shade], t[surface]); + expect( + ratio, + `${theme.id}: ${shade} text on ${surface} = ${ratio.toFixed(2)}` + ).toBeGreaterThanOrEqual(AA_TEXT); + } + } + }); + + it('primary-500 reads as a UI element ≥ 3:1 on every surface', () => { + // Focus ring (focus-visible:ring-primary-500), button fills, and control + // boundaries can sit on any surface layer, so hold the bar on all of them. + for (const surface of SURFACES) { + const ratio = channelContrast(t['primary-500'], t[surface]); + expect( + ratio, + `${theme.id}: primary-500 vs ${surface} = ${ratio.toFixed(2)}` + ).toBeGreaterThanOrEqual(AA_LARGE); + } + }); + }); + } +}); diff --git a/app/src/lib/theme/presets.ts b/app/src/lib/theme/presets.ts index 0a5b21acd2..5b20b7788a 100644 --- a/app/src/lib/theme/presets.ts +++ b/app/src/lib/theme/presets.ts @@ -118,8 +118,14 @@ const OCEAN_DARK: Theme = { 'line-subtle': '36 48 76', content: '224 232 244', 'content-secondary': '182 196 220', - 'content-muted': '140 156 188', - 'content-faint': '104 118 150', + // Muted/faint raised so they clear AA (body 4.5:1, faint 3:1) even on the + // lightest elevated surfaces (surface-hover/strong `40 52 84`), not just the + // base surface. + 'content-muted': '152 168 200', + 'content-faint': '128 142 172', + // The primary fills here are light blue, so labels over them read as dark — + // a deep navy clears AA on primary-500/600 where white (2.5:1) failed. + 'content-inverted': '8 14 28', 'primary-500': '96 165 250', 'primary-600': '59 130 246', }, @@ -169,8 +175,13 @@ const SEPIA_DARK: Theme = { 'line-subtle': '48 40 30', content: '236 228 214', 'content-secondary': '198 184 162', - 'content-muted': '156 142 120', - 'content-faint': '120 106 86', + // Raised so muted (4.5:1) and faint (3:1) hold on the lighter recessed/hover + // sepia surfaces, not only the base surface. + 'content-muted': '176 160 136', + 'content-faint': '150 134 110', + // Tan primary fills → dark labels. A near-black brown clears AA on + // primary-500/600 where white (2.6:1) failed. + 'content-inverted': '26 22 17', ...BROWN_RAMP, 'primary-500': '200 150 90', 'primary-600': '176 126 70', @@ -198,7 +209,9 @@ const MATRIX_DARK: Theme = { content: '134 255 168', 'content-secondary': '78 210 122', 'content-muted': '58 158 92', - 'content-faint': '44 112 68', + // Raised from `44 112 68` so faint text clears 3:1 even on the brighter + // hover/strong green surfaces (was ~2.6:1); still clearly dimmer than muted. + 'content-faint': '52 132 82', 'content-inverted': '2 8 4', ...GREEN_RAMP, }, @@ -252,6 +265,10 @@ const HAL_DARK: Theme = { 'content-muted': '170 130 130', 'content-faint': '130 96 96', ...RED_RAMP, + // Deepen the resting primary (was `230 40 40`) so the white button label + // clears AA (4.5:1 → 5.2:1). The active fill (primary-600) also clears it, + // and accent text uses the lighter 300/400 shades, so the red identity holds. + 'primary-500': '214 30 30', }, gradient: { canvas: 'radial-gradient(circle at 50% 16%, rgb(84 10 10), rgb(8 4 4) 56%)' }, fonts: {}, From a41c21a9efd9b98a578939e2f2cd20e0b5996965 Mon Sep 17 00:00:00 2001 From: CodeGhost21 <164498022+CodeGhost21@users.noreply.github.com> Date: Thu, 16 Jul 2026 04:11:27 +0530 Subject: [PATCH 18/18] fix(voice): ship the voice domain in the desktop build (#4901) (#4917) Co-authored-by: Steven Enamakel Co-authored-by: Claude Opus 4.8 (1M context) --- app/src-tauri/Cargo.lock | 8 +++ app/src-tauri/Cargo.toml | 20 +++++- app/src-tauri/src/lib.rs | 14 ++++ .../EditableFlowCanvas.validation.test.tsx | 8 ++- app/src/features/human/MicComposer.test.tsx | 39 ++++++++++- app/src/features/human/MicComposer.tsx | 11 ++- .../features/human/voice/sttClient.test.ts | 41 +++++++++-- app/src/features/human/voice/sttClient.ts | 69 +++++++++++++++---- app/src/lib/i18n/ar.ts | 2 + app/src/lib/i18n/bn.ts | 2 + app/src/lib/i18n/de.ts | 2 + app/src/lib/i18n/en.ts | 2 + app/src/lib/i18n/es.ts | 2 + app/src/lib/i18n/fr.ts | 2 + app/src/lib/i18n/hi.ts | 2 + app/src/lib/i18n/id.ts | 2 + app/src/lib/i18n/it.ts | 2 + app/src/lib/i18n/ko.ts | 2 + app/src/lib/i18n/pl.ts | 2 + app/src/lib/i18n/pt.ts | 2 + app/src/lib/i18n/ru.ts | 2 + app/src/lib/i18n/zh-CN.ts | 1 + src/openhuman/voice/compile_status.rs | 49 +++++++++++++ src/openhuman/voice/mod.rs | 6 ++ 24 files changed, 263 insertions(+), 29 deletions(-) create mode 100644 src/openhuman/voice/compile_status.rs diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index ee102721b5..9204d46616 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -3564,6 +3564,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + [[package]] name = "html5ever" version = "0.29.1" @@ -5608,9 +5614,11 @@ dependencies = [ "hkdf", "hmac 0.12.1", "hostname", + "hound", "iana-time-zone", "image", "keyring", + "lettre", "libc", "log", "mail-parser", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 2af2f6a3e3..dbf447cd81 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -137,11 +137,25 @@ cef = { version = "=146.4.1", default-features = false } # gates existed) means the embedded core does NOT inherit the root crate's # default gate set, so each default-ON gate must be forwarded explicitly to # keep the shipped desktop build byte-identical (AGENTS.md "Compile-time -# domain gates"). `media` re-registers the `media_generate_*` agent tools -# that #4804 moved behind `#[cfg(feature = "media")]`; it sheds no deps, so -# this only restores the pre-gate desktop tool surface. +# domain gates"). +# +# This list is NOT optional polish — a gate missing here vanishes from the +# shipped app silently, with no build error and no test failure: +# +# - `voice` — without it the `#[cfg(feature = "voice")]` controllers in +# `src/core/all.rs` are never registered, so the whole `openhuman.voice_*` +# namespace answers "unknown method" at runtime. This shipped broken from +# v0.58.19 to v0.61.x (#4901); the `VOICE_COMPILED_IN` const assert at the +# top of `src/lib.rs` now fails the build if it is dropped again. +# - `media` — re-registers the `media_generate_*` agent tools that #4804 moved +# behind `#[cfg(feature = "media")]`; it sheds no deps, so this only restores +# the pre-gate desktop tool surface. +# +# `tokenjuice-treesitter` is the remaining un-forwarded default — tracked in +# #4918, deliberately not bundled here because dropping it may be intentional. openhuman_core = { path = "../..", package = "openhuman", default-features = false, features = [ "media", + "voice", ] } tinyjuice = { version = "0.2.1", default-features = false } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 5ca2b6375b..ee15b7897e 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -3,6 +3,20 @@ #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] compile_error!("src-tauri host supports desktop (Windows/macOS/Linux) only. Mobile lives in app/src-tauri-mobile."); +// The shipped desktop app must always embed the real voice domain. Cargo +// features are per-crate, so `#[cfg(feature = "voice")]` here would test THIS +// crate's features, not the core's — a voice-less core is only observable via +// the core's own always-compiled facade. Without this assert the failure is +// silent and runtime-only: every `openhuman.voice_*` RPC answers "unknown +// method" and the UI blames a stale sidecar (#4901). Keep `voice` in the +// `openhuman_core` feature list in Cargo.toml to satisfy this. +const _: () = assert!( + openhuman_core::openhuman::voice::VOICE_COMPILED_IN, + "openhuman_core must be built with the `voice` feature: the desktop app ships voice, \ + and without it every openhuman.voice_* controller is unregistered (#4901). \ + Add \"voice\" to the openhuman_core `features` list in app/src-tauri/Cargo.toml." +); + mod app_update; // Artifact export commands (#2779, #3162) — both cross-platform // (macOS/Windows/Linux): native Save-As dialog (rfd) + Downloads copy. diff --git a/app/src/components/flows/canvas/__tests__/EditableFlowCanvas.validation.test.tsx b/app/src/components/flows/canvas/__tests__/EditableFlowCanvas.validation.test.tsx index 9a1a6450dc..bee206af37 100644 --- a/app/src/components/flows/canvas/__tests__/EditableFlowCanvas.validation.test.tsx +++ b/app/src/components/flows/canvas/__tests__/EditableFlowCanvas.validation.test.tsx @@ -100,7 +100,13 @@ describe('EditableFlowCanvas — validation + dirty state', () => { // The host header reads `hasErrors` off `onSaveMetaChange` to disable its // Save button; the canvas itself also refuses to fire `onSave` through // the imperative handle while hard errors exist, even though dirty. - expect(lastSaveMeta(onSaveMetaChange)).toMatchObject({ dirty: true, hasErrors: true }); + // `hasErrors` reaches the host via a follow-up effect that can lag the + // error-banner render (see `onSaveMetaChange` in EditableFlowCanvas), so + // poll the reported meta rather than reading it once — under a loaded + // full-suite run the synchronous read can still see the pre-validation meta. + await waitFor(() => + expect(lastSaveMeta(onSaveMetaChange)).toMatchObject({ dirty: true, hasErrors: true }) + ); act(() => ref.current?.save()); expect(onSave).not.toHaveBeenCalled(); diff --git a/app/src/features/human/MicComposer.test.tsx b/app/src/features/human/MicComposer.test.tsx index 42d418cf10..da5411b31d 100644 --- a/app/src/features/human/MicComposer.test.tsx +++ b/app/src/features/human/MicComposer.test.tsx @@ -7,12 +7,19 @@ import { MicComposer, STT_MAX_RETRIES, } from './MicComposer'; +import { VoiceNotCompiledError } from './voice/sttClient'; // transcribeWithFactory + encodeBlobToWav are the network/heavy boundaries — // mock them here so we can drive the state machine without touching real APIs. const transcribeWithFactoryMock = vi.fn(); const encodeBlobToWavMock = vi.fn(); -vi.mock('./voice/sttClient', () => ({ +// Spread the real module so `VoiceNotCompiledError` / `isVoiceNotCompiledError` +// keep their real behaviour; only the network-touching call is stubbed. A +// factory listing exports by hand silently yields `undefined` for anything it +// forgets, which fails as a TypeError at the call site rather than a clear mock +// error. +vi.mock('./voice/sttClient', async importOriginal => ({ + ...(await importOriginal()), transcribeWithFactory: (...args: unknown[]) => transcribeWithFactoryMock(...args), })); vi.mock('./voice/wavEncoder', () => ({ @@ -794,10 +801,11 @@ describe('MicComposer', () => { expect(onError).not.toHaveBeenCalled(); }); - it('does not retry permanent errors (stale sidecar)', async () => { + it('does not retry permanent errors (voice not compiled into the core)', async () => { transcribeWithFactoryMock.mockRejectedValueOnce( new Error( - 'Voice transcription is unavailable in this build. Restart the OpenHuman desktop app to pick up the latest core sidecar.' + 'Voice transcription is unavailable in this build — the voice module was not compiled into the app. ' + + 'Update OpenHuman to the latest version; restarting will not help.' ) ); const onError = vi.fn(); @@ -816,6 +824,31 @@ describe('MicComposer', () => { expect(onSubmit).not.toHaveBeenCalled(); }); + // #4901: the raw error text is untranslated developer copy, so a + // VoiceNotCompiledError must surface the localized `mic.voiceNotCompiled` + // string instead of being spliced into the `{message}` slot. + it('renders translated copy for a voice-not-compiled error, not the raw message', async () => { + transcribeWithFactoryMock.mockRejectedValueOnce(new VoiceNotCompiledError()); + const onError = vi.fn(); + const onSubmit = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /start recording/i })); + await waitFor(() => expect(getUserMediaMock).toHaveBeenCalled()); + await waitFor(() => + expect(screen.getByRole('button', { name: /stop recording and send/i })).toBeInTheDocument() + ); + fireEvent.click(screen.getByRole('button', { name: /stop recording and send/i })); + + await waitFor(() => expect(onError).toHaveBeenCalled()); + const shown = onError.mock.calls[0][0] as string; + // The English `mic.voiceNotCompiled` value, resolved through useT(). + expect(shown).toMatch(/not included in this version/i); + // The untranslated developer copy must not leak into the UI. + expect(shown).not.toMatch(/not compiled into the app/i); + expect(onSubmit).not.toHaveBeenCalled(); + }); + // ── Low-confidence transcript detection (#1206) ──────────────────────────── it('rejects a single-character transcript as low confidence', async () => { diff --git a/app/src/features/human/MicComposer.tsx b/app/src/features/human/MicComposer.tsx index 992a83f014..b843cac268 100644 --- a/app/src/features/human/MicComposer.tsx +++ b/app/src/features/human/MicComposer.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react import { createPortal } from 'react-dom'; import { useT } from '../../lib/i18n/I18nContext'; -import { transcribeWithFactory } from './voice/sttClient'; +import { isVoiceNotCompiledError, transcribeWithFactory } from './voice/sttClient'; import { encodeBlobToWav } from './voice/wavEncoder'; /** Minimal descriptor for an audio input device. */ @@ -33,7 +33,7 @@ const STT_RETRY_BASE_MS = 500; * Matched case-insensitively against the error message. */ const PERMANENT_ERROR_PATTERNS = [ - 'unknown method', // stale sidecar + 'unknown method', // core built without the `voice` feature (#4901) 'audio blob is empty', 'unavailable in this build', ]; @@ -576,6 +576,13 @@ export function MicComposer({ } const msg = err instanceof Error ? err.message : String(err); composerLog('transcribe failed: %s', msg); + // The core has no voice domain compiled in (#4901). `msg` is untranslated + // developer copy, so render the localized string rather than splicing it + // into the `{message}` slot. + if (isVoiceNotCompiledError(err)) { + onError?.(t('mic.voiceNotCompiled')); + return; + } onError?.(t('mic.transcriptionFailed').replace('{message}', msg)); } finally { if (!disposedRef.current) setState('idle'); diff --git a/app/src/features/human/voice/sttClient.test.ts b/app/src/features/human/voice/sttClient.test.ts index b4d93cc942..924bc98a8d 100644 --- a/app/src/features/human/voice/sttClient.test.ts +++ b/app/src/features/human/voice/sttClient.test.ts @@ -93,14 +93,31 @@ describe('transcribeCloud', () => { expect(await transcribeCloud(blob)).toBe(''); }); - // Issue #1289: stale sidecar binaries surface a generic - // "unknown method" error. Frontend rewrites it to an actionable - // message so users know to restart the desktop app. - it('rewrites "unknown method" errors to an actionable restart hint', async () => { + // #4901: a core built without the `voice` feature never registers the + // `openhuman.voice_*` controllers, so they answer "unknown method". That is a + // compile-time property of the binary, so the message must NOT tell users to + // restart (the pre-#4901 copy did, which could never work). + it('rewrites "unknown method" errors to a not-compiled-in message', async () => { const mock = callCoreRpc as ReturnType; mock.mockRejectedValueOnce(new Error('unknown method: openhuman.voice_cloud_transcribe')); const blob = new Blob([new Uint8Array([1])], { type: 'audio/webm' }); - await expect(transcribeCloud(blob)).rejects.toThrow(/Restart the OpenHuman desktop app/i); + await expect(transcribeCloud(blob)).rejects.toThrow(/not compiled into the app/i); + }); + + it('does not advise restarting for a compile-time voice gate', async () => { + const mock = callCoreRpc as ReturnType; + mock.mockRejectedValueOnce(new Error('unknown method: openhuman.voice_cloud_transcribe')); + const blob = new Blob([new Uint8Array([1])], { type: 'audio/webm' }); + await expect(transcribeCloud(blob)).rejects.not.toThrow(/restart the openhuman desktop app/i); + }); + + // `MicComposer`'s PERMANENT_ERROR_PATTERNS matches this substring to skip the + // retry/backoff loop — a compile-time gate can never succeed on retry. + it('keeps the "unavailable in this build" substring for retry suppression', async () => { + const mock = callCoreRpc as ReturnType; + mock.mockRejectedValueOnce(new Error('unknown method: openhuman.voice_cloud_transcribe')); + const blob = new Blob([new Uint8Array([1])], { type: 'audio/webm' }); + await expect(transcribeCloud(blob)).rejects.toThrow(/unavailable in this build/i); }); it('passes through non-unknown-method errors verbatim', async () => { @@ -148,11 +165,21 @@ describe('transcribeWithFactory', () => { expect(mock).not.toHaveBeenCalled(); }); - it('rewrites stale-sidecar "unknown method" errors', async () => { + // #4901 — same compile-time gate as the cloud path above. + it('rewrites "unknown method" errors to a not-compiled-in message', async () => { + const mock = callCoreRpc as ReturnType; + mock.mockRejectedValueOnce(new Error('unknown method: openhuman.voice_stt_dispatch')); + const blob = new Blob([new Uint8Array([1])], { type: 'audio/webm' }); + await expect(transcribeWithFactory(blob)).rejects.toThrow(/not compiled into the app/i); + }); + + it('does not advise restarting for a compile-time voice gate', async () => { const mock = callCoreRpc as ReturnType; mock.mockRejectedValueOnce(new Error('unknown method: openhuman.voice_stt_dispatch')); const blob = new Blob([new Uint8Array([1])], { type: 'audio/webm' }); - await expect(transcribeWithFactory(blob)).rejects.toThrow(/Restart the OpenHuman desktop app/i); + await expect(transcribeWithFactory(blob)).rejects.not.toThrow( + /restart the openhuman desktop app/i + ); }); it('passes through non-unknown-method errors verbatim', async () => { diff --git a/app/src/features/human/voice/sttClient.ts b/app/src/features/human/voice/sttClient.ts index 1700b067d0..4432a69a10 100644 --- a/app/src/features/human/voice/sttClient.ts +++ b/app/src/features/human/voice/sttClient.ts @@ -4,6 +4,52 @@ import { callCoreRpc } from '../../../services/coreRpcClient'; const sttLog = debug('human:stt'); +/** + * Stable identifier for "the core has no voice domain compiled in" (#4901). + * + * Callers must branch on this code rather than on the message text: the message + * is untranslated developer/log copy, and the UI is responsible for rendering + * translated copy via `useT()` (see `MicComposer`'s `mic.voiceNotCompiled`). + */ +export const VOICE_NOT_COMPILED_CODE = 'voice_not_compiled'; + +/** + * Thrown when the core answers `unknown method` for a `voice_*` RPC — i.e. the + * core was compiled without the `voice` feature, so the domain is absent from + * the binary entirely (#4901). + * + * The `message` is English on purpose: it is what reaches debug logs and Sentry. + * User-facing copy is resolved from `code` at the UI boundary. + * + * Keep the `unavailable in this build` substring: `MicComposer`'s + * `PERMANENT_ERROR_PATTERNS` matches on it to skip the retry/backoff loop, + * which can never succeed for a compile-time gate. + */ +export class VoiceNotCompiledError extends Error { + readonly code = VOICE_NOT_COMPILED_CODE; + + constructor() { + super( + 'Voice transcription is unavailable in this build — the voice module was not compiled into the app. ' + + 'Update OpenHuman to the latest version; restarting will not help.' + ); + this.name = 'VoiceNotCompiledError'; + } +} + +/** + * Duck-typed on `code` rather than `instanceof`: the error crosses async + + * retry boundaries, and structured-clone/serialization paths can strip the + * prototype while preserving own properties. + */ +export function isVoiceNotCompiledError(err: unknown): err is VoiceNotCompiledError { + return ( + typeof err === 'object' && + err !== null && + (err as { code?: unknown }).code === VOICE_NOT_COMPILED_CODE + ); +} + export interface CloudTranscribeOptions { /** Override the backend STT model id. Default is whatever the backend * resolves `whisper-v1` to today. */ @@ -64,17 +110,16 @@ export async function transcribeCloud( params, }); } catch (err) { - // Issue #1289: an "unknown method" error means the bundled core - // sidecar is older than the frontend (e.g. a stale dev build, or a - // cached binary the desktop auto-update hasn't refreshed yet). - // The raw "unknown method: openhuman.voice_cloud_transcribe" string - // is opaque to end users — surface an actionable message instead. + // An "unknown method" error means the core serving this app was built + // without the `voice` Cargo feature, so the `openhuman.voice_*` + // controllers were never registered (#4901). This is a compile-time + // property of the binary — restarting cannot change it, which is why the + // old #1289-era "restart to pick up the latest core sidecar" copy was + // unactionable (and the sidecar itself is gone since #1061). const msg = err instanceof Error ? err.message : String(err); if (msg.includes('unknown method')) { - sttLog('transcribe rpc stale-sidecar path hit; rewriting unknown-method error: %s', msg); - throw new Error( - 'Voice transcription is unavailable in this build. Restart the OpenHuman desktop app to pick up the latest core sidecar.' - ); + sttLog('[voice-stt] transcribe rpc: voice domain absent from core build: %s', msg); + throw new VoiceNotCompiledError(); } sttLog('transcribe rpc failed (passthrough): %O', err); throw err; @@ -149,10 +194,8 @@ export async function transcribeWithFactory( } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes('unknown method')) { - sttLog('[voice-stt] dispatch stale-sidecar path: %s', msg); - throw new Error( - 'Voice transcription is unavailable in this build. Restart the OpenHuman desktop app to pick up the latest core sidecar.' - ); + sttLog('[voice-stt] dispatch: voice domain absent from core build: %s', msg); + throw new VoiceNotCompiledError(); } sttLog('[voice-stt] dispatch failed (passthrough): %O', err); throw err; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index a86721859b..9b9662d400 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -3163,6 +3163,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'تعذّر فهم الصوت بوضوح: يرجى المحاولة مرة أخرى', 'mic.failedToStopRecording': 'فشل إيقاف التسجيل: {message}', 'mic.transcriptionFailed': 'فشل النسخ: {message}', + 'mic.voiceNotCompiled': + 'خاصية تحويل الصوت إلى نص غير متوفرة في هذا الإصدار من التطبيق. حدّث OpenHuman لتفعيلها.', 'reflections.kind.retrospective': 'مراجعة', 'reflections.kind.derivedFact': 'حقيقة مستنتجة', 'reflections.kind.moodInsight': 'رؤية المزاج', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 340eb931f6..1e34ff6cc7 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -3238,6 +3238,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'অডিও স্পষ্টভাবে বোঝা যায়নি: আবার চেষ্টা করুন', 'mic.failedToStopRecording': 'রেকর্ডিং বন্ধ করতে ব্যর্থ: {message}', 'mic.transcriptionFailed': 'ট্রান্সক্রিপশন ব্যর্থ: {message}', + 'mic.voiceNotCompiled': + 'অ্যাপের এই সংস্করণে ভয়েস ট্রান্সক্রিপশন অন্তর্ভুক্ত নেই। এটি চালু করতে OpenHuman আপডেট করুন।', 'reflections.kind.retrospective': 'পূর্বদর্শন', 'reflections.kind.derivedFact': 'ডেরাইভড ফ্যাক্ট', 'reflections.kind.moodInsight': 'মুড ইনসাইট', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 77cc64948d..871f54a665 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -3328,6 +3328,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'Audio konnte nicht klar verstanden werden: bitte erneut versuchen', 'mic.failedToStopRecording': 'Aufzeichnung konnte nicht gestoppt werden: {message}', 'mic.transcriptionFailed': 'Transkription fehlgeschlagen: {message}', + 'mic.voiceNotCompiled': + 'Sprachtranskription ist in dieser App-Version nicht enthalten. Aktualisiere OpenHuman, um sie zu aktivieren.', 'reflections.kind.retrospective': 'Retrospektive', 'reflections.kind.derivedFact': 'Abgeleitete Tatsache', 'reflections.kind.moodInsight': 'Stimmungseinsicht', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index a1dfc6742f..0291d524c1 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -3629,6 +3629,8 @@ const en: TranslationMap = { 'mic.lowConfidenceResult': 'Could not understand the audio clearly: please try again', 'mic.failedToStopRecording': 'Failed to stop recording: {message}', 'mic.transcriptionFailed': 'Transcription failed: {message}', + 'mic.voiceNotCompiled': + 'Voice transcription is not included in this version of the app. Update OpenHuman to enable it.', // Reflections: kind labels 'reflections.kind.retrospective': 'Retrospective', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index d8d676616a..b41d056590 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -3299,6 +3299,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'No se pudo entender el audio con claridad: intenta de nuevo', 'mic.failedToStopRecording': 'No se pudo detener la grabación: {message}', 'mic.transcriptionFailed': 'Transcripción fallida: {message}', + 'mic.voiceNotCompiled': + 'La transcripción de voz no está incluida en esta versión de la aplicación. Actualiza OpenHuman para activarla.', 'reflections.kind.retrospective': 'Retrospectiva', 'reflections.kind.derivedFact': 'Hecho derivado', 'reflections.kind.moodInsight': 'Insight de ánimo', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index c6a8907d4f..4d34ed6dfa 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -3323,6 +3323,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': "Impossible de comprendre l'audio clairement: réessaie", 'mic.failedToStopRecording': "Échec de l'arrêt de l'enregistrement : {message}", 'mic.transcriptionFailed': 'Échec de la transcription : {message}', + 'mic.voiceNotCompiled': + "La transcription vocale n'est pas incluse dans cette version de l'application. Mettez à jour OpenHuman pour l'activer.", 'reflections.kind.retrospective': 'Rétrospective', 'reflections.kind.derivedFact': 'Fait dérivé', 'reflections.kind.moodInsight': 'Insight émotionnel', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 32c565246a..ce2250f929 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -3236,6 +3236,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'ऑडियो स्पष्ट रूप से समझ नहीं आया: कृपया पुनः प्रयास करें', 'mic.failedToStopRecording': 'रिकॉर्डिंग रोकने में दिक्कत: {message}', 'mic.transcriptionFailed': 'ट्रांसक्रिप्शन विफल: {message}', + 'mic.voiceNotCompiled': + 'इस ऐप संस्करण में वॉइस ट्रांसक्रिप्शन शामिल नहीं है। इसे चालू करने के लिए OpenHuman को अपडेट करें।', 'reflections.kind.retrospective': 'रेट्रोस्पेक्टिव', 'reflections.kind.derivedFact': 'डिराइव्ड फैक्ट', 'reflections.kind.moodInsight': 'मूड इनसाइट', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 71dcb44573..2c9eddecaf 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -3248,6 +3248,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'Tidak dapat memahami audio dengan jelas: silakan coba lagi', 'mic.failedToStopRecording': 'Gagal menghentikan perekaman: {message}', 'mic.transcriptionFailed': 'Transkripsi gagal: {message}', + 'mic.voiceNotCompiled': + 'Transkripsi suara tidak tersedia di versi aplikasi ini. Perbarui OpenHuman untuk mengaktifkannya.', 'reflections.kind.retrospective': 'Retrospektif', 'reflections.kind.derivedFact': 'Fakta Turunan', 'reflections.kind.moodInsight': 'Wawasan Suasana Hati', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 92c48d9082..6ca7a6820e 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -3297,6 +3297,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': "Impossibile comprendere l'audio chiaramente: riprova", 'mic.failedToStopRecording': 'Impossibile fermare la registrazione: {message}', 'mic.transcriptionFailed': 'Trascrizione fallita: {message}', + 'mic.voiceNotCompiled': + "La trascrizione vocale non è inclusa in questa versione dell'app. Aggiorna OpenHuman per attivarla.", 'reflections.kind.retrospective': 'Retrospettiva', 'reflections.kind.derivedFact': 'Fatto derivato', 'reflections.kind.moodInsight': "Insight sull'umore", diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 77a920ae01..0f484310e5 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -3201,6 +3201,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': '오디오를 명확하게 이해할 수 없습니다: 다시 시도해 주세요', 'mic.failedToStopRecording': '녹음을 중지하지 못했습니다: {message}', 'mic.transcriptionFailed': '전사에 실패했습니다: {message}', + 'mic.voiceNotCompiled': + '이 버전의 앱에는 음성 받아쓰기가 포함되어 있지 않습니다. OpenHuman을 업데이트하면 사용할 수 있습니다.', 'reflections.kind.retrospective': '회고', 'reflections.kind.derivedFact': '파생된 사실', 'reflections.kind.moodInsight': '기분 인사이트', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 26d6481937..71a6700c09 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -3274,6 +3274,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'Nie udało się wyraźnie zrozumieć dźwięku: spróbuj ponownie', 'mic.failedToStopRecording': 'Nie udało się zatrzymać nagrywania: {message}', 'mic.transcriptionFailed': 'Transkrypcja nie powiodła się: {message}', + 'mic.voiceNotCompiled': + 'Transkrypcja głosu nie jest dostępna w tej wersji aplikacji. Zaktualizuj OpenHuman, aby ją włączyć.', 'reflections.kind.retrospective': 'Retrospektywa', 'reflections.kind.derivedFact': 'Wywiedziony fakt', 'reflections.kind.moodInsight': 'Wnioski o nastroju', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 8ed1b3f750..f88efaeb90 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -3291,6 +3291,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'Não foi possível entender o áudio com clareza: tente novamente', 'mic.failedToStopRecording': 'Falha ao parar a gravação: {message}', 'mic.transcriptionFailed': 'Falha na transcrição: {message}', + 'mic.voiceNotCompiled': + 'A transcrição de voz não está incluída nesta versão do aplicativo. Atualize o OpenHuman para ativá-la.', 'reflections.kind.retrospective': 'Retrospectiva', 'reflections.kind.derivedFact': 'Fato Derivado', 'reflections.kind.moodInsight': 'Insight de Humor', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 897d272052..45ea259140 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -3264,6 +3264,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'Не удалось чётко распознать аудио: попробуйте ещё раз', 'mic.failedToStopRecording': 'Не удалось остановить запись: {message}', 'mic.transcriptionFailed': 'Ошибка транскрипции: {message}', + 'mic.voiceNotCompiled': + 'Расшифровка речи не включена в эту версию приложения. Обновите OpenHuman, чтобы включить её.', 'reflections.kind.retrospective': 'Ретроспектива', 'reflections.kind.derivedFact': 'Выведенный факт', 'reflections.kind.moodInsight': 'Инсайт о настроении', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 23d8f0446c..517b1b8fc0 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -3065,6 +3065,7 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': '无法清楚地理解音频:请重试', 'mic.failedToStopRecording': '停止录音失败: {message}', 'mic.transcriptionFailed': '转录失败: {message}', + 'mic.voiceNotCompiled': '此版本的应用未包含语音转文字功能。请更新 OpenHuman 后再使用。', 'reflections.kind.retrospective': '回顾', 'reflections.kind.derivedFact': '派生事实', 'reflections.kind.moodInsight': '情绪洞察', diff --git a/src/openhuman/voice/compile_status.rs b/src/openhuman/voice/compile_status.rs new file mode 100644 index 0000000000..c4973cd244 --- /dev/null +++ b/src/openhuman/voice/compile_status.rs @@ -0,0 +1,49 @@ +//! Compile-time visibility into the `voice` gate. +//! +//! Deliberately **ungated**: unlike the rest of the domain, this module is +//! compiled in both feature states, because its whole purpose is to report +//! which state the binary ended up in. It is part of the always-compiled facade +//! described in [`super`], alongside `stub`. + +/// Whether the real voice domain was compiled into this binary. +/// +/// Cargo features are per-crate and invisible to dependents' `#[cfg]`, so a +/// consumer that *requires* voice (the desktop shell) has no other way to detect +/// that it silently got the stubbed build — which is exactly how #4901 shipped: +/// `app/src-tauri/Cargo.toml` set `default-features = false`, dropping the +/// default-ON `voice` feature, so every `openhuman.voice_*` controller went +/// unregistered and answered "unknown method" at runtime. +/// +/// The shell asserts this at compile time (`const _: () = assert!(...)` in +/// `app/src-tauri/src/lib.rs`), turning that silent runtime failure into a build +/// failure. +pub const VOICE_COMPILED_IN: bool = cfg!(feature = "voice"); + +#[cfg(test)] +mod tests { + use super::VOICE_COMPILED_IN; + + /// Pins the constant to the gate rather than to a hardcoded value: the + /// assertion inverts with the feature, so it holds for both the default + /// build and the slim (`--no-default-features`) build. + #[test] + fn reports_the_compiled_gate_state() { + assert_eq!(VOICE_COMPILED_IN, cfg!(feature = "voice")); + } + + /// The default build ships voice; this is the state the desktop app + /// requires (#4901). Skipped when the slim build is under test. + #[test] + #[cfg(feature = "voice")] + fn is_true_when_the_voice_feature_is_on() { + assert!(VOICE_COMPILED_IN); + } + + /// The slim build must report honestly, otherwise the shell's const assert + /// would pass against a stubbed core and #4901 could ship again. + #[test] + #[cfg(not(feature = "voice"))] + fn is_false_when_the_voice_feature_is_off() { + assert!(!VOICE_COMPILED_IN); + } +} diff --git a/src/openhuman/voice/mod.rs b/src/openhuman/voice/mod.rs index 993dca1479..50b3508d5d 100644 --- a/src/openhuman/voice/mod.rs +++ b/src/openhuman/voice/mod.rs @@ -23,6 +23,12 @@ //! (`cargo check --no-default-features --features ""`): any //! signature drift fails that build. +// Ungated — part of the always-compiled facade (see the module docs above): it +// reports which side of the gate this binary landed on, so it must exist in +// both states. +pub mod compile_status; +pub use compile_status::VOICE_COMPILED_IN; + #[cfg(feature = "voice")] pub mod always_on; #[cfg(feature = "voice")]