From 9c83a32a3bc68550ccb1312385174a0d6ce55e44 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 21 Jul 2026 07:16:57 +0000 Subject: [PATCH 01/56] chore(staging): v0.61.7 [skip ci] --- Cargo.lock | 2 +- Cargo.toml | 2 +- app/package.json | 2 +- app/src-tauri/Cargo.lock | 4 ++-- app/src-tauri/Cargo.toml | 2 +- app/src-tauri/tauri.conf.json | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0f41c1de1f..4211992566 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4374,7 +4374,7 @@ dependencies = [ [[package]] name = "openhuman" -version = "0.61.6" +version = "0.61.7" dependencies = [ "aes-gcm", "aho-corasick", diff --git a/Cargo.toml b/Cargo.toml index 135cd383a5..8ac6962ac7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openhuman" -version = "0.61.6" +version = "0.61.7" edition = "2021" description = "OpenHuman core business logic and RPC server" autobins = false diff --git a/app/package.json b/app/package.json index 76bb999dbd..83386f13a7 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "openhuman-app", - "version": "0.61.6", + "version": "0.61.7", "type": "module", "engines": { "node": ">=24.0.0" diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 695043073c..60b18144fd 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "OpenHuman" -version = "0.61.6" +version = "0.61.7" dependencies = [ "anyhow", "async-trait", @@ -5603,7 +5603,7 @@ dependencies = [ [[package]] name = "openhuman" -version = "0.61.6" +version = "0.61.7" dependencies = [ "aes-gcm", "aho-corasick", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index fa6aba19e3..2e69451e6d 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "OpenHuman" -version = "0.61.6" +version = "0.61.7" description = "OpenHuman - AI-powered Super Assistant" authors = ["OpenHuman"] edition = "2021" diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 7a71527e3f..2660735901 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenHuman", - "version": "0.61.6", + "version": "0.61.7", "identifier": "com.openhuman.app", "build": { "beforeDevCommand": "pnpm run dev", From 00708c324e2616edfb21d398e789a70641f2b087 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:21:29 +0300 Subject: [PATCH 02/56] fix(orchestration): async-by-default delegations with durable, resumable subagent sessions + persistent workflow proposals (#5074) --- .../components/chat/WorkflowProposalCard.tsx | 20 ++ .../lib/workflows/workflowProposal.test.ts | 118 +++++++++ app/src/lib/workflows/workflowProposal.ts | 88 +++++++ app/src/providers/ChatRuntimeProvider.tsx | 75 +----- app/src/store/chatRuntimeSlice.ts | 7 + app/src/store/threadSlice.ts | 12 + docs/TEST-COVERAGE-MATRIX.md | 5 + .../developing/architecture/agent-harness.md | 2 + .../agent/harness/session/turn/core.rs | 1 + .../agent_orchestration/running_subagents.rs | 154 +++++++++-- .../tools/archetype_delegation.rs | 40 ++- .../tools/continue_subagent.rs | 125 ++++++++- .../agent_orchestration/tools/dispatch.rs | 86 ++++++- .../tools/skill_delegation.rs | 7 +- .../tools/spawn_async_subagent.rs | 216 +++++++++++++++- .../tools/tools_e2e_tests.rs | 242 ++++++++++++++++++ .../agents/orchestrator/agent.toml | 5 + 17 files changed, 1097 insertions(+), 106 deletions(-) create mode 100644 app/src/lib/workflows/workflowProposal.test.ts create mode 100644 app/src/lib/workflows/workflowProposal.ts diff --git a/app/src/components/chat/WorkflowProposalCard.tsx b/app/src/components/chat/WorkflowProposalCard.tsx index 37e66c4b6e..a5fd612e9b 100644 --- a/app/src/components/chat/WorkflowProposalCard.tsx +++ b/app/src/components/chat/WorkflowProposalCard.tsx @@ -6,6 +6,7 @@ import { FLOW_CANVAS_DRAFT_ROUTE, type FlowCanvasDraftState } from '../../lib/fl import type { WorkflowGraph } from '../../lib/flows/types'; import { useT } from '../../lib/i18n/I18nContext'; import { createFlow, setFlowEnabled } from '../../services/api/flowsApi'; +import { threadApi } from '../../services/api/threadApi'; import { clearWorkflowProposalForThread, type WorkflowProposal, @@ -64,7 +65,24 @@ export const WorkflowProposalCard: React.FC = ({ threadId, proposal, onSa // and only retry the enable step. const [savedFlowId, setSavedFlowId] = useState(null); + /** + * When this proposal was rehydrated from a persisted thread message (the + * durable backstop the core writes for async builder runs), mark that + * message consumed so the card does not resurrect on the next thread load. + * Fire-and-forget: a failed mark only risks a stale card reappearing. + */ + const markSourceMessageConsumed = () => { + if (!proposal.sourceMessageId) return; + threadApi + .updateMessage(threadId, proposal.sourceMessageId, { + scope: 'workflow_proposal', + consumed: true, + }) + .catch(e => log('markSourceMessageConsumed failed (non-fatal): %o', e)); + }; + const dismiss = () => { + markSourceMessageConsumed(); dispatch(clearWorkflowProposalForThread({ threadId })); }; @@ -110,6 +128,7 @@ export const WorkflowProposalCard: React.FC = ({ threadId, proposal, onSa flowPersisted = true; if (flow.enabled) { log('save: createFlow returned enabled — nothing further to arm id=%s', flow.id); + markSourceMessageConsumed(); dispatch(clearWorkflowProposalForThread({ threadId })); onSaved?.(); return; @@ -121,6 +140,7 @@ export const WorkflowProposalCard: React.FC = ({ threadId, proposal, onSa setSavedFlowId(flow.id); } await setFlowEnabled(flowId, true); + markSourceMessageConsumed(); dispatch(clearWorkflowProposalForThread({ threadId })); onSaved?.(); } catch (e) { diff --git a/app/src/lib/workflows/workflowProposal.test.ts b/app/src/lib/workflows/workflowProposal.test.ts new file mode 100644 index 0000000000..5aabda3d79 --- /dev/null +++ b/app/src/lib/workflows/workflowProposal.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; + +import type { ThreadMessage } from '../../types/thread'; +import { + coerceWorkflowProposal, + extractWorkflowProposalFromMessages, + maybeParseWorkflowProposalTool, + parseWorkflowProposal, +} from './workflowProposal'; + +const proposalPayload = (name: string) => ({ + type: 'workflow_proposal', + persisted: false, + name, + graph: { nodes: [], edges: [] }, + require_approval: true, + summary: { + trigger: 'schedule: 0 9 * * *', + steps: [{ kind: 'tool_call', name: 'Fetch trending tweets', config_hint: 'TWITTER_SEARCH' }], + }, +}); + +const message = ( + id: string, + extraMetadata: Record, + overrides: Partial = {} +): ThreadMessage => ({ + id, + content: 'Workflow proposal ready', + type: 'text', + extraMetadata, + sender: 'agent', + createdAt: '2026-07-21T06:00:00Z', + ...overrides, +}); + +describe('parseWorkflowProposal / coerceWorkflowProposal', () => { + it('parses a valid proposal payload with summary steps', () => { + const proposal = parseWorkflowProposal(JSON.stringify(proposalPayload('Daily X Trending'))); + expect(proposal).not.toBeNull(); + expect(proposal?.name).toBe('Daily X Trending'); + expect(proposal?.requireApproval).toBe(true); + expect(proposal?.summary.trigger).toBe('schedule: 0 9 * * *'); + expect(proposal?.summary.steps).toEqual([ + { kind: 'tool_call', name: 'Fetch trending tweets', config_hint: 'TWITTER_SEARCH' }, + ]); + }); + + it('defaults requireApproval to true unless explicitly false', () => { + const explicit = coerceWorkflowProposal({ ...proposalPayload('X'), require_approval: false }); + expect(explicit?.requireApproval).toBe(false); + const omitted = coerceWorkflowProposal({ type: 'workflow_proposal', name: 'X', graph: {} }); + expect(omitted?.requireApproval).toBe(true); + }); + + it('rejects non-proposal payloads, malformed JSON, and missing fields', () => { + expect(parseWorkflowProposal('not json')).toBeNull(); + expect(coerceWorkflowProposal(null)).toBeNull(); + expect(coerceWorkflowProposal({ type: 'something_else' })).toBeNull(); + expect(coerceWorkflowProposal({ type: 'workflow_proposal', graph: {} })).toBeNull(); + expect(coerceWorkflowProposal({ type: 'workflow_proposal', name: 'x' })).toBeNull(); + }); +}); + +describe('maybeParseWorkflowProposalTool', () => { + it('is content-based: any tool name with a proposal-shaped output matches', () => { + const output = JSON.stringify(proposalPayload('Via Edit Tool')); + expect(maybeParseWorkflowProposalTool('edit_workflow', true, output)?.name).toBe( + 'Via Edit Tool' + ); + expect(maybeParseWorkflowProposalTool('totally_new_tool', true, output)?.name).toBe( + 'Via Edit Tool' + ); + }); + + it('ignores failed calls and empty output', () => { + const output = JSON.stringify(proposalPayload('X')); + expect(maybeParseWorkflowProposalTool('propose_workflow', false, output)).toBeNull(); + expect(maybeParseWorkflowProposalTool('propose_workflow', true, undefined)).toBeNull(); + }); +}); + +describe('extractWorkflowProposalFromMessages', () => { + it('rehydrates the newest unconsumed proposal and tags its source message', () => { + const messages = [ + message('m1', { scope: 'workflow_proposal', proposal: proposalPayload('Old Draft') }), + message('m2', {}), + message('m3', { scope: 'workflow_proposal', proposal: proposalPayload('Latest Draft') }), + ]; + const proposal = extractWorkflowProposalFromMessages(messages); + expect(proposal?.name).toBe('Latest Draft'); + expect(proposal?.sourceMessageId).toBe('m3'); + }); + + it('skips consumed proposals so a saved/dismissed card does not resurrect', () => { + const messages = [ + message('m1', { scope: 'workflow_proposal', proposal: proposalPayload('Kept') }), + message('m2', { + scope: 'workflow_proposal', + proposal: proposalPayload('Already Saved'), + consumed: true, + }), + ]; + const proposal = extractWorkflowProposalFromMessages(messages); + expect(proposal?.name).toBe('Kept'); + expect(proposal?.sourceMessageId).toBe('m1'); + }); + + it('returns null when there is no proposal metadata or the payload is malformed', () => { + expect(extractWorkflowProposalFromMessages([])).toBeNull(); + expect(extractWorkflowProposalFromMessages([message('m1', {})])).toBeNull(); + expect( + extractWorkflowProposalFromMessages([ + message('m1', { scope: 'workflow_proposal', proposal: { type: 'wrong' } }), + ]) + ).toBeNull(); + }); +}); diff --git a/app/src/lib/workflows/workflowProposal.ts b/app/src/lib/workflows/workflowProposal.ts new file mode 100644 index 0000000000..82df0a7404 --- /dev/null +++ b/app/src/lib/workflows/workflowProposal.ts @@ -0,0 +1,88 @@ +import type { WorkflowProposal } from '../../store/chatRuntimeSlice'; +import type { ThreadMessage } from '../../types/thread'; + +/** + * Shared parsing for `workflow_proposal` payloads produced by the Rust + * `propose_workflow` / `revise_workflow` / `edit_workflow` tools. + * + * Three delivery channels funnel through here so they can never drift: + * 1. live `tool_result` socket events (main-agent tool call), + * 2. live `subagent_tool_result` socket events (`build_workflow` delegate), + * 3. persisted thread messages whose `extraMetadata.scope` is + * `workflow_proposal` — the durable backstop written by the Rust core + * when an async `workflow_builder` run completes, which lets the card + * rehydrate after a reload or a dropped socket event. + * + * IMPORTANT: match on the payload's `type` field, NOT on tool names. This + * mirrors the Rust `flows_build` path's `extract_workflow_proposal`, which + * also scans tool results by payload `type` — a name allowlist here can + * silently drop proposals from newly added tools (as happened when + * `edit_workflow` was added without updating an earlier list). + */ +export function coerceWorkflowProposal(parsed: unknown): WorkflowProposal | null { + if (!parsed || typeof parsed !== 'object') return null; + const obj = parsed as Record; + if (obj.type !== 'workflow_proposal') return null; + if (typeof obj.name !== 'string' || obj.graph == null) return null; + + const summary = (obj.summary ?? {}) as Record; + const rawSteps = Array.isArray(summary.steps) ? summary.steps : []; + const steps = rawSteps + .filter((s): s is Record => !!s && typeof s === 'object') + .map(s => ({ + kind: typeof s.kind === 'string' ? s.kind : 'unknown', + name: typeof s.name === 'string' ? s.name : '', + config_hint: typeof s.config_hint === 'string' ? s.config_hint : undefined, + })); + + return { + name: obj.name, + graph: obj.graph, + // The Rust tool defaults `require_approval` to `true` when the caller + // omits it, so treat anything other than an explicit `false` as `true` + // here too — keeps the client's fallback in lockstep with the server's. + requireApproval: obj.require_approval !== false, + summary: { trigger: typeof summary.trigger === 'string' ? summary.trigger : '', steps }, + }; +} + +export function parseWorkflowProposal(output: string): WorkflowProposal | null { + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch { + return null; + } + return coerceWorkflowProposal(parsed); +} + +export function maybeParseWorkflowProposalTool( + _toolName: string, + success: boolean, + output: string | undefined +): WorkflowProposal | null { + if (!success || !output) return null; + return parseWorkflowProposal(output); +} + +/** + * Rehydrate the newest unconsumed workflow proposal from a thread's persisted + * messages. The Rust core appends a message with + * `extraMetadata: { scope: 'workflow_proposal', proposal, task_id, ... }` + * when an async `workflow_builder` run finishes; once the user saves or + * dismisses the card the message is marked `consumed: true` so it does not + * resurrect on the next load. + */ +export function extractWorkflowProposalFromMessages( + messages: ThreadMessage[] +): WorkflowProposal | null { + for (let i = messages.length - 1; i >= 0; i--) { + const meta = messages[i].extraMetadata; + if (!meta || meta.scope !== 'workflow_proposal' || meta.consumed === true) continue; + const proposal = coerceWorkflowProposal(meta.proposal); + if (proposal) { + return { ...proposal, sourceMessageId: messages[i].id }; + } + } + return null; +} diff --git a/app/src/providers/ChatRuntimeProvider.tsx b/app/src/providers/ChatRuntimeProvider.tsx index ba137661d4..d8c7fc0e57 100644 --- a/app/src/providers/ChatRuntimeProvider.tsx +++ b/app/src/providers/ChatRuntimeProvider.tsx @@ -8,6 +8,7 @@ import { SKILL_TOOL_CHAIN_TARGET_MS, } from '../lib/ai/skillToolChainLatency'; import { ingestRuntimeErrorSignal } from '../lib/userErrors/report'; +import { maybeParseWorkflowProposalTool } from '../lib/workflows/workflowProposal'; import { type ChatApprovalRequestEvent, type ChatDoneEvent, @@ -63,7 +64,6 @@ import { upsertArtifactFailedForThread, upsertArtifactInProgressForThread, upsertArtifactReadyForThread, - type WorkflowProposal, } from '../store/chatRuntimeSlice'; import { useAppDispatch, useAppSelector } from '../store/hooks'; import { selectSocketStatus } from '../store/socketSelectors'; @@ -259,75 +259,10 @@ function chatTurnUsagePayload(event: ChatDoneEvent): { }; } -/** - * Parses a completed `propose_workflow` tool call's JSON `output` into a - * `WorkflowProposal` for `WorkflowProposalCard` (issue B4 — agent-first - * Workflow authoring). The tool's `execute()` - * (`src/openhuman/flows/tools.rs`) returns - * `{ type: "workflow_proposal", name, graph, require_approval, summary }` as - * its `ToolResult` body; this maps that wire shape onto the store's camelCase - * `WorkflowProposal`. Returns `null` for anything that fails to parse or - * doesn't match the expected shape — defensive, since a malformed proposal - * must never crash the chat runtime, it should just silently not render a - * card. - */ -/** - * 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, - success: boolean, - output: string | undefined -): WorkflowProposal | null { - if (!success || !output) return null; - return parseWorkflowProposal(output); -} - -function parseWorkflowProposal(output: string): WorkflowProposal | null { - let parsed: unknown; - try { - parsed = JSON.parse(output); - } catch { - return null; - } - if (!parsed || typeof parsed !== 'object') return null; - const obj = parsed as Record; - if (obj.type !== 'workflow_proposal') return null; - if (typeof obj.name !== 'string' || obj.graph == null) return null; - - const summary = (obj.summary ?? {}) as Record; - const rawSteps = Array.isArray(summary.steps) ? summary.steps : []; - const steps = rawSteps - .filter((s): s is Record => !!s && typeof s === 'object') - .map(s => ({ - kind: typeof s.kind === 'string' ? s.kind : 'unknown', - name: typeof s.name === 'string' ? s.name : '', - config_hint: typeof s.config_hint === 'string' ? s.config_hint : undefined, - })); - - return { - name: obj.name, - graph: obj.graph, - // The Rust tool defaults `require_approval` to `true` when the caller - // omits it, so treat anything other than an explicit `false` as `true` - // here too — keeps the client's fallback in lockstep with the server's. - requireApproval: obj.require_approval !== false, - summary: { trigger: typeof summary.trigger === 'string' ? summary.trigger : '', steps }, - }; -} +// `maybeParseWorkflowProposalTool` / `parseWorkflowProposal` moved to +// `lib/workflows/workflowProposal.ts` so the live socket handlers below and +// the persisted-message rehydration path (`threadSlice.loadThreadMessages`) +// share one content-based parser. See that module's doc comment. const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => { const dispatch = useAppDispatch(); diff --git a/app/src/store/chatRuntimeSlice.ts b/app/src/store/chatRuntimeSlice.ts index 7e01b5fbf6..c06fb27336 100644 --- a/app/src/store/chatRuntimeSlice.ts +++ b/app/src/store/chatRuntimeSlice.ts @@ -536,6 +536,13 @@ export interface WorkflowProposal { /** Ordered non-trigger steps. */ steps: WorkflowProposalStep[]; }; + /** + * Id of the persisted thread message this proposal was rehydrated from + * (`extraMetadata.scope === 'workflow_proposal'`), when it came from the + * durable backstop rather than a live socket event. Save/Dismiss mark that + * message `consumed: true` so the card does not resurrect on reload. + */ + sourceMessageId?: string; } /** diff --git a/app/src/store/threadSlice.ts b/app/src/store/threadSlice.ts index 7e32c141d8..1362732fd0 100644 --- a/app/src/store/threadSlice.ts +++ b/app/src/store/threadSlice.ts @@ -1,9 +1,11 @@ import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import { extractWorkflowProposalFromMessages } from '../lib/workflows/workflowProposal'; import { threadApi } from '../services/api/threadApi'; import { isThreadNotFoundCoreRpcError } from '../services/coreRpcClient'; import type { Thread, ThreadMessage } from '../types/thread'; import { IS_DEV } from '../utils/config'; +import { setWorkflowProposalForThread } from './chatRuntimeSlice'; import { resetUserScopedState } from './resetActions'; export const THREAD_NOT_FOUND_MESSAGE = 'This thread is no longer available.'; @@ -115,6 +117,16 @@ export const loadThreadMessages = createAsyncThunk( async (threadId: string, { dispatch, rejectWithValue }) => { try { const response = await threadApi.getThreadMessages(threadId); + // Durable workflow-proposal rehydration: the Rust core persists a + // proposal produced by an async workflow_builder run as a thread + // message (extraMetadata.scope === 'workflow_proposal'). Restoring it + // here means the proposal card survives reloads, reconnects, and + // dropped socket events — the live `tool_result` events remain the + // fast path and simply overwrite this with the same payload. + const persistedProposal = extractWorkflowProposalFromMessages(response.messages); + if (persistedProposal) { + dispatch(setWorkflowProposalForThread({ threadId, proposal: persistedProposal })); + } return { threadId, messages: response.messages }; } catch (error) { // A cached/seeded thread id (e.g. the Flows copilot's persisted diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index 17e04eba91..a579f09f68 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -295,6 +295,11 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an | 6.3.9 | Vision sub-agent reads attached images | RU | `src/openhuman/agent_registry/agents/loader.rs::vision_agent_loads_on_vision_hint`, `src/openhuman/inference/provider/factory_tests.rs::vision_tier_is_vision_capable`, `src/openhuman/agent/harness/engine/core.rs::gate_tests`, `src/openhuman/agent/multimodal_tests.rs::extract_image_placeholders_pulls_att_tokens_in_order` | ✅ | Orchestrator (non-vision `chat-v1`) keeps the image as a placeholder, delegates to `vision_agent` on the `vision-v1` tier, which rehydrates the on-disk attachment and reads it. Engine gate prefers per-tier `current_model_vision`; turn placeholders forwarded into the sub-agent prompt. | | 6.3.10 | Auto-accept contact requests from linked agents | RU | `src/openhuman/agent_orchestration/pairing.rs::{auto_accept_gate_accepts_linked_but_leaves_others_pending,auto_accept_gate_unifies_base58_and_base64_of_same_key,auto_accept_gate_accepts_nothing_with_empty_linked_set,auto_accept_fails_closed_on_unreadable_pairing_store,incoming_pending_requesters_filters_and_resolves_requester}` | ✅ | On an inbound tiny.place `contact_request`, OpenHuman auto-accepts iff the requester is in `linked_agent_ids()` (its own paired agents) and otherwise leaves it pending for the human — the e2e gate that stops the relay dropping a linked agent's `session_info` intro. Fail-closed: a pairing-store read error yields an empty linked set → nothing auto-accepted. base58/base64 encodings of the same key unify via the shared DM-ingest matcher. | +| 6.3.11 | Async-by-default archetype delegation (durable ref) | RU | `src/openhuman/agent_orchestration/tools/tools_e2e_tests.rs::archetype_delegation_defaults_to_async_with_durable_session_e2e`, `src/openhuman/agent_orchestration/tools/archetype_delegation.rs::parameters_schema_advertises_async_default_blocking_opt_in` | ✅ | `delegate_*` / `build_workflow` with a parent turn + chat thread return `[async_subagent_ref]` (task_id + subagent_session_id) immediately, persist a durable reusable session, and queue the result for background delivery as a new turn; `blocking: true` opts back into inline dispatch, and no-thread contexts fall back automatically. | +| 6.3.12 | Cross-turn sub-agent roster (durable store merge) | RU | `src/openhuman/agent_orchestration/running_subagents.rs::snapshot_and_block_scope_to_parent_and_reflect_live_status` | ✅ | `[active_subagents]` merges the in-memory registry with the per-workspace `subagent_sessions` store, so a cold-booted parent still sees earlier workers (id, status, task title) and resumes instead of re-delegating; Closed sessions excluded, other parents' sessions never leak. | +| 6.3.13 | continue_subagent durable resume (no pause checkpoint) | RU | `src/openhuman/agent_orchestration/tools/tools_e2e_tests.rs::{continue_subagent_resumes_idle_durable_session_e2e,continue_subagent_without_checkpoint_or_durable_session_names_the_roster}` | ✅ | With no `ask_user_clarification` checkpoint, `continue_subagent` resolves the durable session by subagent_session_id / task id, resumes it via the reusable async path seeded with persisted history, and keeps the same durable id; missing sessions error with a pointer at the roster. | +| 6.3.14 | Workflow proposal durability (async builder → chat card) | RU+VU | `src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs::{extract_workflow_proposal_finds_last_proposal_tool_result,attach_workflow_proposal_persists_thread_message_and_extends_summary,attach_workflow_proposal_without_proposal_returns_summary_unchanged}`, `app/src/lib/workflows/workflowProposal.test.ts` | ✅ | A `workflow_proposal` payload in a finished async child's history is persisted as a parent-thread message (metadata scope `workflow_proposal`) and embedded in the delivery notice; the frontend rehydrates the newest unconsumed proposal into the card on thread load, and Save/Dismiss mark the source message consumed. | + ### 6.4 Managed Cloud File Storage | ID | Feature | Layer | Test path(s) | Status | Notes | diff --git a/gitbooks/developing/architecture/agent-harness.md b/gitbooks/developing/architecture/agent-harness.md index 21095bd1c9..fa1eb37289 100644 --- a/gitbooks/developing/architecture/agent-harness.md +++ b/gitbooks/developing/architecture/agent-harness.md @@ -242,6 +242,8 @@ The child run itself still uses the same runner: `wait_subagent` and `steer_subagent` accept either the durable `subagent_session_id` or the transient `task_id`; durable ids are preferred across turns. `list_subagents` shows reusable children for the current parent thread, and `close_subagent` marks a worker non-reusable and cancels it if it is still running. Inline blocking is explicit via `blocking: true`; it is no longer the default. +The synthesized archetype delegations (`delegate_*`, `build_workflow`, and the other `delegate_name` tools) follow the same contract: they route through the durable async path by default, returning an `[async_subagent_ref]` (with `subagent_session_id` + `task_id`) immediately, and the finished result is inserted into the parent chat as a new system turn via `background_completions`/`background_delivery`. They fall back to inline blocking automatically when there is no parent agent turn or no chat thread to deliver into (cron/CLI), or when `blocking: true` is passed. Cross-turn continuity comes from three pieces: the per-turn `[active_subagents]` roster merges the live in-memory registry with the durable `subagent_sessions` store (so a cold-booted orchestrator still sees earlier workers); `continue_subagent` falls back from pause checkpoints to the durable store, resuming an idle worker with its persisted history; and a `workflow_proposal` payload found in a finished child's history is persisted as a parent-thread message (`extraMetadata.scope = "workflow_proposal"`) that the frontend rehydrates into the proposal card on thread load. + ### Spawn hierarchy and tiers Not every agent is allowed to spawn every other agent. The harness models a three-tier hierarchy that mirrors the cost / latency / depth-of-thought split between models: diff --git a/src/openhuman/agent/harness/session/turn/core.rs b/src/openhuman/agent/harness/session/turn/core.rs index b6ca114c1f..e7ef2fdfc1 100644 --- a/src/openhuman/agent/harness/session/turn/core.rs +++ b/src/openhuman/agent/harness/session/turn/core.rs @@ -748,6 +748,7 @@ impl Agent { if let Some(block) = crate::openhuman::agent_orchestration::running_subagents::active_subagents_context_block( &self.event_session_id, + &self.workspace_dir, ) { log::info!( diff --git a/src/openhuman/agent_orchestration/running_subagents.rs b/src/openhuman/agent_orchestration/running_subagents.rs index 1319585c74..49c1ab1380 100644 --- a/src/openhuman/agent_orchestration/running_subagents.rs +++ b/src/openhuman/agent_orchestration/running_subagents.rs @@ -768,26 +768,76 @@ pub(crate) fn snapshot_for_parent(parent_session: &str) -> Vec out } +/// Most-recent durable sessions surfaced in the roster when they are not in +/// the live registry (cold boot / later turn). Bounds prompt growth on +/// threads with a long delegation history. +const DURABLE_ROSTER_CAP: usize = 12; + /// Build the ambient `[active_subagents]` block prepended to a parent's turn -/// context when it has async/parallel workers registered. Returns `None` when the -/// parent owns no registered sub-agents, so the block only appears when it is -/// actionable — turns for agents that never spawn are untouched. Mirrors the -/// thread-goal `[active_goal]` block: it rides the per-turn context (not the -/// cached system-prompt prefix), so it reflects live status every turn. -pub(crate) fn active_subagents_context_block(parent_session: &str) -> Option { +/// context. Returns `None` when the parent owns no sub-agents at all, so the +/// block only appears when it is actionable — turns for agents that never +/// spawn are untouched. Mirrors the thread-goal `[active_goal]` block: it +/// rides the per-turn context (not the cached system-prompt prefix), so it +/// reflects live status every turn. +/// +/// The roster merges two sources: +/// 1. the in-memory registry (live async workers spawned this process), and +/// 2. the durable per-workspace `subagent_sessions` store — workers from +/// EARLIER turns / process lifetimes. Without this second source a +/// cold-booted parent had no idea its previous sub-agents existed and +/// would re-delegate from scratch instead of resuming by +/// `subagent_session_id` (the "fresh context from day 0" bug). +pub(crate) fn active_subagents_context_block( + parent_session: &str, + workspace_dir: &std::path::Path, +) -> Option { let workers = snapshot_for_parent(parent_session); - if workers.is_empty() { + + // Durable sessions not already represented by a live registry entry. + let live_session_ids: std::collections::HashSet = workers + .iter() + .filter_map(|w| w.subagent_session_id.clone()) + .collect(); + let store = crate::openhuman::agent_orchestration::subagent_sessions::SubagentSessionStore { + workspace_dir: workspace_dir.to_path_buf(), + }; + let durable: Vec<_> = + match crate::openhuman::agent_orchestration::subagent_sessions::list_for_parent( + &store, + parent_session, + None, + ) { + Ok(sessions) => sessions + .into_iter() + .filter(|s| { + use crate::openhuman::agent_orchestration::subagent_sessions::DurableSubagentStatus; + s.status != DurableSubagentStatus::Closed + && !live_session_ids.contains(&s.subagent_session_id) + }) + .take(DURABLE_ROSTER_CAP) + .collect(), + Err(err) => { + log::warn!( + "[running_subagents] durable roster load failed parent_session={parent_session} error={err}" + ); + Vec::new() + } + }; + + if workers.is_empty() && durable.is_empty() { return None; } let mut block = format!( "[active_subagents]\n\ - You have {} background sub-agent(s) in flight (spawned earlier this session). \ - This is your live roster — trust it over memory. Track each by subagent_session_id; \ - use wait_subagent to collect a `completed` one, steer_subagent to redirect a `running` \ - one, continue_subagent to answer an `awaiting_user` one, close_subagent when done, and \ - list_subagents to re-enumerate. Never fabricate a result for a worker still running or \ - one that has failed.\n", - workers.len() + You have {} sub-agent worker(s) for this conversation (live and/or from earlier \ + turns). This is your authoritative roster — trust it over memory. Track each by \ + subagent_session_id; use wait_subagent to collect a `completed` one, steer_subagent \ + to redirect a `running` one, continue_subagent to answer an `awaiting_user` one or \ + to RESUME an `idle` one with a follow-up (it keeps its full prior context — do NOT \ + re-delegate the same task from scratch), close_subagent when done, and \ + list_subagents to re-enumerate. Never fabricate a result for a worker still running \ + or one that has failed.\n", + workers.len() + durable.len() ); for w in &workers { let session = w.subagent_session_id.as_deref().unwrap_or("(none)"); @@ -796,6 +846,21 @@ pub(crate) fn active_subagents_context_block(parent_session: &str) -> Option "running", + DurableSubagentStatus::Idle => "idle", + DurableSubagentStatus::AwaitingUser => "awaiting_user", + DurableSubagentStatus::Failed => "failed", + DurableSubagentStatus::Closed => "closed", + }; + let task = s.current_task_id.as_deref().unwrap_or("(none)"); + block.push_str(&format!( + "- {} · session={} · task={} · status={} · about: {}\n", + s.agent_id, s.subagent_session_id, task, status, s.task_title + )); + } block.push_str("[/active_subagents]\n\n"); Some(block) } @@ -1612,15 +1677,70 @@ mod tests { assert_eq!(snap[1].agent_id, "researcher"); assert_eq!(snap[1].status, "running"); - let block = active_subagents_context_block("fleet-parent").expect("block present"); + let block = active_subagents_context_block("fleet-parent", &test_workspace()) + .expect("block present"); assert!(block.contains("[active_subagents]")); - assert!(block.contains("You have 2 background sub-agent(s)")); + assert!(block.contains("You have 2 sub-agent worker(s)")); assert!(block.contains("session=subsess-a")); assert!(block.contains("session=subsess-b · task=task-fleet-b · status=awaiting_user")); assert!(block.ends_with("[/active_subagents]\n\n")); // A parent with no registered workers gets no block (no perturbation). - assert!(active_subagents_context_block("nobody-here").is_none()); + assert!(active_subagents_context_block("nobody-here", &test_workspace()).is_none()); + + // Durable-store fallback: a session persisted by an EARLIER turn / + // process lifetime (empty live registry for this parent) must still + // surface in the roster, so a cold-booted orchestrator can resume by + // subagent_session_id instead of re-delegating from scratch. + { + use crate::openhuman::agent::harness::subagent_runner::SubagentRunStatus; + use crate::openhuman::agent_orchestration::subagent_sessions::{ + self, SubagentSessionSelector, SubagentSessionStore, SubagentSessionUpsert, + }; + let durable_ws = tempfile::tempdir().expect("durable roster tempdir"); + let store = SubagentSessionStore { + workspace_dir: durable_ws.path().to_path_buf(), + }; + let session = subagent_sessions::upsert_running( + &store, + SubagentSessionUpsert { + selector: SubagentSessionSelector { + parent_session: "cold-parent".into(), + parent_thread_id: Some("thread-cold".into()), + agent_id: "workflow_builder".into(), + toolkit: None, + model: None, + sandbox_mode: "None".into(), + action_root: None, + task_key: "daily-x-trending".into(), + }, + display_name: Some("Workflow Builder".into()), + task_title: "Daily X trending email workflow".into(), + worker_thread_id: None, + task_id: "task-cold-1".into(), + }, + None, + ) + .expect("upsert durable session"); + subagent_sessions::mark_finished( + &store, + &session.subagent_session_id, + "task-cold-1", + &SubagentRunStatus::Completed, + Vec::new(), + ) + .expect("mark idle"); + + let block = active_subagents_context_block("cold-parent", durable_ws.path()) + .expect("durable-only roster present"); + assert!(block.contains(&format!("session={}", session.subagent_session_id))); + assert!(block.contains("status=idle")); + assert!(block.contains("about: Daily X trending email workflow")); + // Other parents' durable sessions must not leak in. + assert!( + active_subagents_context_block("unrelated-parent", durable_ws.path()).is_none() + ); + } let _ = tx_a.send(SubagentStatus::Completed { output: "x".into(), diff --git a/src/openhuman/agent_orchestration/tools/archetype_delegation.rs b/src/openhuman/agent_orchestration/tools/archetype_delegation.rs index d051f40074..b0dc6c413b 100644 --- a/src/openhuman/agent_orchestration/tools/archetype_delegation.rs +++ b/src/openhuman/agent_orchestration/tools/archetype_delegation.rs @@ -63,6 +63,10 @@ impl Tool for ArchetypeDelegationTool { "model": { "type": "string", "description": "Optional exact model id for this delegation only. Keeps the parent provider/routing, but pins the child agent to this model instead of the agent definition's default." + }, + "blocking": { + "type": "boolean", + "description": "Default false: the delegation runs as a durable async worker — you immediately get an [async_subagent_ref] with a subagent_session_id (steer_subagent / wait_subagent / continue_subagent / close_subagent operate on it), and the finished result is inserted into this chat as a new turn. Pass true ONLY when the sub-agent's result must gate THIS reply (e.g. verify/review X before answering)." } } }) @@ -124,13 +128,29 @@ impl Tool for ArchetypeDelegationTool { .map(str::trim) .filter(|s| !s.is_empty()); + // Async by default: the delegated specialist runs as a durable, + // resumable worker and its result comes back as a new chat turn. + // `blocking: true` is the opt-in for results that must gate this + // reply. (`dispatch_subagent` itself falls back to blocking when + // there is no chat thread to deliver an async result into.) + let blocking = args + .get("blocking") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let mode = if blocking { + super::dispatch::DispatchMode::Blocking + } else { + super::dispatch::DispatchMode::PreferAsync + }; + super::dispatch_subagent( &self.agent_id, &self.tool_name, &prompt, None, model_override, - tool_context.and_then(|ctx| ctx.workspace.clone()), + tool_context, + mode, ) .await } @@ -231,6 +251,24 @@ mod tests { ); } + #[test] + fn parameters_schema_advertises_async_default_blocking_opt_in() { + // Delegations are async by default (durable worker + follow-up + // delivery turn); `blocking: true` is the explicit opt-in for + // results that must gate the current reply. The flag must be + // advertised but never required. + let schema = sample_tool().parameters_schema(); + let blocking = &schema["properties"]["blocking"]; + assert_eq!(blocking["type"], "boolean"); + let desc = blocking["description"].as_str().unwrap_or_default(); + assert!(desc.contains("async"), "explains the async default: {desc}"); + assert!( + desc.contains("continue_subagent") && desc.contains("subagent_session_id"), + "points at the resume contract: {desc}" + ); + assert_eq!(schema["required"], json!(["prompt"])); + } + #[test] fn parameters_schema_requires_prompt_only() { let tool = sample_tool(); diff --git a/src/openhuman/agent_orchestration/tools/continue_subagent.rs b/src/openhuman/agent_orchestration/tools/continue_subagent.rs index fefcf59e42..a0319da997 100644 --- a/src/openhuman/agent_orchestration/tools/continue_subagent.rs +++ b/src/openhuman/agent_orchestration/tools/continue_subagent.rs @@ -32,6 +32,95 @@ impl ContinueSubagentTool { pub fn new() -> Self { Self } + + /// Resume a durable sub-agent session (no pause checkpoint on disk). + /// + /// Accepts either the transient `task_id` (`sub-…`) or the durable + /// `subagent_session_id` in the tool's `task_id` argument — the ambient + /// `[active_subagents]` roster advertises both. The resume routes through + /// `spawn_async_subagent` pinned to the session's `task_key`, so the + /// reusable-session machinery reattaches: a `running` worker is steered, + /// an `idle` one is relaunched seeded with its persisted `latest_history` + /// plus the new message, and the finished result is delivered back into + /// the parent chat as a new turn. + async fn resume_from_durable_store( + &self, + parent: &crate::openhuman::agent::harness::fork_context::ParentExecutionContext, + task_id: &str, + agent_id: &str, + message: &str, + tool_context: Option<&ToolExecutionContext>, + ) -> anyhow::Result { + use crate::openhuman::agent_orchestration::subagent_sessions::{ + self, SubagentSessionStore, + }; + + let store = SubagentSessionStore::new(parent.workspace_dir.clone()); + let sessions = match subagent_sessions::list_for_parent(&store, &parent.session_id, None) { + Ok(sessions) => sessions, + Err(err) => { + return Ok(ToolResult::error(format!( + "continue_subagent: no checkpoint found for task_id '{task_id}' and \ + the durable session store could not be read: {err}" + ))); + } + }; + let session = sessions.iter().find(|s| { + s.subagent_session_id == task_id || s.current_task_id.as_deref() == Some(task_id) + }); + let Some(session) = session else { + return Ok(ToolResult::error(format!( + "continue_subagent: no checkpoint and no durable session found for \ + task_id '{task_id}'. Check the [active_subagents] roster (or call \ + list_subagents) and pass that subagent_session_id — or delegate a \ + fresh task if the worker was closed." + ))); + }; + if session.agent_id != agent_id { + return Ok(ToolResult::error(format!( + "continue_subagent: agent_id mismatch — durable session '{}' belongs to \ + '{}', caller passed '{agent_id}'", + session.subagent_session_id, session.agent_id + ))); + } + if !session.status.reusable() { + return Ok(ToolResult::error(format!( + "continue_subagent: durable session '{}' is {:?} and cannot be resumed. \ + Delegate a fresh task instead.", + session.subagent_session_id, session.status + ))); + } + + tracing::info!( + task_id = %task_id, + subagent_session_id = %session.subagent_session_id, + agent_id = %agent_id, + status = ?session.status, + "[continue_subagent] resuming durable session via reusable async path" + ); + + let mut async_args = json!({ + "agent_id": session.agent_id.clone(), + "prompt": message, + "task_key": session.task_key.clone(), + "task_title": session.task_title.clone(), + }); + if let (Some(obj), Some(toolkit)) = (async_args.as_object_mut(), &session.toolkit) { + obj.insert( + "toolkit".to_string(), + serde_json::Value::String(toolkit.clone()), + ); + } + if let (Some(obj), Some(model)) = (async_args.as_object_mut(), &session.model) { + obj.insert( + "model".to_string(), + serde_json::Value::String(model.clone()), + ); + } + super::spawn_async_subagent::SpawnAsyncSubagentTool::new() + .execute_with_context(async_args, ToolCallOptions::default(), tool_context) + .await + } } #[async_trait] @@ -41,11 +130,15 @@ impl Tool for ContinueSubagentTool { } fn description(&self) -> &str { - "Resume a paused sub-agent that requested user input via \ - ask_user_clarification. Pass the task_id from the \ - [SUBAGENT_AWAITING_USER] envelope and the user's answer \ - as the message. The sub-agent continues from its checkpoint \ - with full prior context." + "Resume an existing sub-agent with a follow-up message, keeping its \ + full prior context. Two cases: (1) a sub-agent paused on \ + ask_user_clarification — pass the task_id from the \ + [SUBAGENT_AWAITING_USER] envelope and the user's answer; (2) a \ + durable worker from the [active_subagents] roster (e.g. an `idle` \ + workflow_builder whose proposal the user is now reacting to) — pass \ + its subagent_session_id (or task id) and the follow-up. Always \ + prefer this over re-delegating the same task from scratch: a fresh \ + delegation loses everything the worker already did." } fn parameters_schema(&self) -> serde_json::Value { @@ -55,11 +148,11 @@ impl Tool for ContinueSubagentTool { "properties": { "task_id": { "type": "string", - "description": "The task_id from the [SUBAGENT_AWAITING_USER] envelope." + "description": "The task_id from the [SUBAGENT_AWAITING_USER] envelope, or the subagent_session_id (preferred) / task id of a durable worker from the [active_subagents] roster." }, "agent_id": { "type": "string", - "description": "The agent_id from the [SUBAGENT_AWAITING_USER] envelope." + "description": "The worker's agent_id (from the envelope or the roster line)." }, "message": { "type": "string", @@ -135,16 +228,22 @@ impl Tool for ContinueSubagentTool { let checkpoint_json = match std::fs::read_to_string(&checkpoint_path) { Ok(json) => json, Err(e) => { - tracing::warn!( + tracing::info!( task_id = %task_id, path = %checkpoint_path.display(), error = %e, - "[continue_subagent] checkpoint not found" + "[continue_subagent] no pause checkpoint — trying durable session store" ); - return Ok(ToolResult::error(format!( - "continue_subagent: no checkpoint found for task_id '{task_id}'. \ - The sub-agent may not have paused, or the checkpoint expired." - ))); + // Durable-session fallback: the sub-agent did not pause on a + // clarification (no checkpoint), but it may be a durable + // worker from this or an earlier turn — resumable with its + // full prior history via the reusable async path. This is + // what lets the orchestrator continue a finished + // workflow_builder ("looks good, save it") instead of + // re-delegating a fresh, stateless one. + return self + .resume_from_durable_store(&parent, &task_id, &agent_id, &message, tool_context) + .await; } }; diff --git a/src/openhuman/agent_orchestration/tools/dispatch.rs b/src/openhuman/agent_orchestration/tools/dispatch.rs index 74529cdf9e..f7fa69bed8 100644 --- a/src/openhuman/agent_orchestration/tools/dispatch.rs +++ b/src/openhuman/agent_orchestration/tools/dispatch.rs @@ -6,8 +6,25 @@ use crate::openhuman::agent::harness::subagent_runner::{ run_subagent, SubagentRunOptions, SubagentRunStatus, }; use crate::openhuman::agent::progress::AgentProgress; -use crate::openhuman::tools::traits::ToolResult; -use tinyagents::harness::workspace::WorkspaceDescriptor; +use crate::openhuman::tools::traits::{Tool as _, ToolCallOptions, ToolResult}; +use tinyagents::harness::tool::ToolExecutionContext; + +/// How a delegated sub-agent run should be scheduled relative to the parent +/// turn. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DispatchMode { + /// Run the sub-agent as a durable async worker (the default for + /// interactive archetype delegations): the tool returns immediately with + /// an `[async_subagent_ref]` carrying `task_id` + `subagent_session_id`, + /// and the finished result is delivered back into the parent chat as a + /// new system-injected turn (`background_delivery`). Falls back to + /// [`DispatchMode::Blocking`] when there is no parent agent turn or no + /// current chat thread to deliver the result into (cron/CLI contexts) — + /// an async result with nowhere to land would be silently lost. + PreferAsync, + /// Run the sub-agent inline and return its final output in this turn. + Blocking, +} pub(crate) async fn dispatch_subagent( agent_id: &str, @@ -15,8 +32,10 @@ pub(crate) async fn dispatch_subagent( prompt: &str, skill_filter: Option<&str>, model_override: Option<&str>, - parent_workspace_descriptor: Option, + tool_context: Option<&ToolExecutionContext>, + mode: DispatchMode, ) -> anyhow::Result { + let parent_workspace_descriptor = tool_context.and_then(|ctx| ctx.workspace.clone()); let registry = match AgentDefinitionRegistry::global() { Some(reg) => reg, None => { @@ -90,6 +109,66 @@ pub(crate) async fn dispatch_subagent( } }; + // ── Async-by-default delegation (#continuity) ───────────────────────── + // Interactive delegations route through the durable async sub-agent + // machinery: the parent gets an immediate `[async_subagent_ref]` with a + // stable `subagent_session_id` it can steer/wait/continue by, the session + // (including full history) is persisted in the per-workspace + // `subagent_sessions` store, and the finished result is inserted into the + // parent thread as a NEW turn via `background_completions` + + // `background_delivery`. This is what keeps a `build_workflow` proposal + // resumable on the next user turn instead of respawning a fresh, + // stateless builder (the "day 0 context" bug). + if mode == DispatchMode::PreferAsync { + let has_parent_turn = parent_ctx.is_some(); + let has_delivery_thread = + crate::openhuman::inference::provider::thread_context::current_thread_id().is_some(); + if has_parent_turn && has_delivery_thread { + let mut async_args = serde_json::json!({ + "agent_id": definition.id.clone(), + "prompt": prompt, + "task_title": + crate::openhuman::agent_orchestration::subagent_sessions::task_title_from_prompt( + prompt, + ), + }); + if let (Some(obj), Some(model)) = (async_args.as_object_mut(), model_override) { + obj.insert( + "model".to_string(), + serde_json::Value::String(model.to_string()), + ); + } + if let (Some(obj), Some(toolkit)) = (async_args.as_object_mut(), skill_filter) { + obj.insert( + "toolkit".to_string(), + serde_json::Value::String(toolkit.to_string()), + ); + } + log::info!( + "[agent] routing {tool_name} delegation of '{}' to durable async sub-agent \ + (result will be delivered as a follow-up turn)", + definition.id + ); + // Box the forwarded future: `SpawnAsyncSubagentTool`'s + // `execute_with_context` future is large, and embedding it inline + // in every delegation tool's future (which itself nests inside + // agent-turn futures) overflows the test-thread stack on deep + // parallel-delegation flows. + return Box::pin(async move { + super::spawn_async_subagent::SpawnAsyncSubagentTool::new() + .execute_with_context(async_args, ToolCallOptions::default(), tool_context) + .await + }) + .await; + } + log::info!( + "[agent] {tool_name}: async delegation requested but parent_turn={} \ + delivery_thread={} — falling back to blocking dispatch", + has_parent_turn, + has_delivery_thread + ); + } + let parent_session = parent_ctx .as_ref() .map(|p| p.session_id.clone()) @@ -373,6 +452,7 @@ mod tests { None, None, None, + DispatchMode::Blocking, ) .await .expect("dispatch_subagent should not return Err on these inputs"); diff --git a/src/openhuman/agent_orchestration/tools/skill_delegation.rs b/src/openhuman/agent_orchestration/tools/skill_delegation.rs index 8a98040413..b9092a4504 100644 --- a/src/openhuman/agent_orchestration/tools/skill_delegation.rs +++ b/src/openhuman/agent_orchestration/tools/skill_delegation.rs @@ -293,13 +293,18 @@ impl Tool for SkillDelegationTool { slug, prompt.chars().count() ); + // Integration delegations stay blocking: their outcomes (send the + // email, create the page, …) are usually approval-gated mid-turn and + // the orchestrator's reply reports the concrete result. The durable + // async default applies to archetype delegations only for now. super::dispatch_subagent( "integrations_agent", &self.tool_name, &prompt, Some(&slug), model_override, - tool_context.and_then(|ctx| ctx.workspace.clone()), + tool_context, + super::dispatch::DispatchMode::Blocking, ) .await } diff --git a/src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs b/src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs index 4e710e0b3f..b4b5cf7d27 100644 --- a/src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs +++ b/src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs @@ -17,6 +17,7 @@ use crate::openhuman::agent_orchestration::subagent_sessions::{ SubagentSessionUpsert, }; use crate::openhuman::inference::provider::ChatMessage; +use crate::openhuman::memory_conversations::{self as conversations, ConversationMessage}; use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; use async_trait::async_trait; use serde_json::json; @@ -466,6 +467,7 @@ impl Tool for SpawnAsyncSubagentTool { let (status_tx, status_rx) = running_subagents::status_channel(); let background_parent = parent.clone(); + let background_workspace_dir = parent.workspace_dir.clone(); let background_definition = definition.clone(); let background_agent_id = definition.id.clone(); let background_task_id = task_id.clone(); @@ -547,6 +549,20 @@ impl Tool for SpawnAsyncSubagentTool { output: outcome.output.clone(), iterations: outcome.iterations, }); + // A workflow proposal produced inside the child's tool + // history is durable state, not prose: persist it into + // the parent chat thread (survives reload / reconnect — + // the old socket-only delivery could silently drop it) + // and carry the full payload in the delivery notice so + // the follow-up turn can present it faithfully. + let delivery_summary = attach_workflow_proposal( + &background_workspace_dir, + background_parent_thread_id.as_deref(), + &outcome.task_id, + &outcome.agent_id, + &outcome.final_history, + outcome.output.clone(), + ); // Queue the finished result for idle-gated, batched // delivery back into the parent chat (the session // runtime drains this when the session is next idle). @@ -554,7 +570,7 @@ impl Tool for SpawnAsyncSubagentTool { background_parent_session.clone(), outcome.task_id.clone(), outcome.agent_id.clone(), - outcome.output.clone(), + delivery_summary, background_parent_thread_id.clone(), ); crate::openhuman::agent_orchestration::subagent_events::publish_subagent_completed( @@ -610,6 +626,16 @@ impl Tool for SpawnAsyncSubagentTool { output: framed.clone(), iterations: outcome.iterations, }); + // An incomplete run may still have produced a full + // proposal before stalling — preserve it durably too. + let framed = attach_workflow_proposal( + &background_workspace_dir, + background_parent_thread_id.as_deref(), + &outcome.task_id, + &outcome.agent_id, + &outcome.final_history, + framed, + ); crate::openhuman::agent_orchestration::background_completions::record_completion( background_parent_session.clone(), outcome.task_id.clone(), @@ -908,6 +934,97 @@ fn durable_task_key_source( } } +/// Scan a finished child's history for the LAST `workflow_proposal` tool +/// result (the workflow_builder's `propose_workflow` / `revise_workflow` / +/// `edit_workflow` all return `{"type":"workflow_proposal", ...}` JSON). +/// Returns the parsed payload, or `None` when the run produced no proposal. +/// Lives here (not in `flows`) so the always-on orchestration path has no +/// dependency on the feature-gated flows domain — it is a generic scan for a +/// structured tool payload. +pub(crate) fn extract_workflow_proposal_from_history( + history: &[ChatMessage], +) -> Option { + history + .iter() + .rev() + .filter(|message| message.role == "tool") + .find_map(|message| { + let value: serde_json::Value = serde_json::from_str(message.content.trim()).ok()?; + (value.get("type").and_then(|t| t.as_str()) == Some("workflow_proposal")) + .then_some(value) + }) +} + +/// Durably surface a workflow proposal found in a finished child's history: +/// persist it as a parent-thread conversation message (metadata carries the +/// full payload so the UI can rehydrate the proposal card after reload) and +/// append a `[workflow_proposal]` envelope to the delivery summary so the +/// follow-up turn presents it faithfully. Returns the (possibly extended) +/// summary; on any persistence error the summary still carries the envelope — +/// losing durability must not lose delivery. +fn attach_workflow_proposal( + workspace_dir: &std::path::Path, + parent_thread_id: Option<&str>, + task_id: &str, + agent_id: &str, + final_history: &[ChatMessage], + summary: String, +) -> String { + let Some(proposal) = extract_workflow_proposal_from_history(final_history) else { + return summary; + }; + let proposal_json = match serde_json::to_string(&proposal) { + Ok(json) => json, + Err(err) => { + log::warn!( + "[spawn_async_subagent] workflow proposal re-serialize failed task_id={task_id} error={err}" + ); + return summary; + } + }; + let name = proposal + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or("Untitled workflow"); + log::info!( + "[spawn_async_subagent] extracted workflow proposal '{name}' task_id={task_id} \ + ({} chars) — persisting to parent thread {:?}", + proposal_json.len(), + parent_thread_id + ); + if let Some(thread_id) = parent_thread_id { + let persisted = conversations::append_message( + workspace_dir.to_path_buf(), + thread_id, + ConversationMessage { + id: format!("workflow-proposal:{task_id}"), + content: format!("Workflow proposal ready: {name}"), + message_type: "text".to_string(), + extra_metadata: json!({ + "scope": "workflow_proposal", + "proposal": proposal, + "task_id": task_id, + "agent_id": agent_id, + }), + sender: "agent".to_string(), + created_at: chrono::Utc::now().to_rfc3339(), + }, + ); + if let Err(err) = persisted { + log::warn!( + "[spawn_async_subagent] workflow proposal persistence failed \ + thread_id={thread_id} task_id={task_id} error={err} — proposal still \ + rides the delivery notice" + ); + } + } + format!( + "{summary}\n\n[workflow_proposal]\n{proposal_json}\n[/workflow_proposal]\n\ + (The full proposal above was also saved to the chat thread; present it to the \ + user for review — do not re-run the builder unless they ask for changes.)" + ) +} + fn reusable_follow_up_message(prompt: &str, context: Option<&str>) -> String { let mut message = String::from("[Follow-up instruction for reusable sub-agent]\n"); if let Some(context) = context.map(str::trim).filter(|s| !s.is_empty()) { @@ -1048,6 +1165,103 @@ mod tests { assert!(rendered.contains("[Task]\nContinue the audit")); } + #[test] + fn extract_workflow_proposal_finds_last_proposal_tool_result() { + let history = vec![ + ChatMessage::user("build me a workflow"), + ChatMessage::tool(r#"{"type":"something_else","x":1}"#), + ChatMessage::tool( + r#"{"type":"workflow_proposal","persisted":false,"name":"Old Draft"}"#, + ), + ChatMessage::assistant("revising…"), + ChatMessage::tool( + r#"{"type":"workflow_proposal","persisted":false,"name":"Daily X Trending Email"}"#, + ), + ChatMessage::assistant("Here's the proposed workflow."), + ]; + let proposal = + extract_workflow_proposal_from_history(&history).expect("proposal extracted"); + // The LAST proposal wins — later revisions supersede earlier drafts. + assert_eq!(proposal["name"], "Daily X Trending Email"); + } + + #[test] + fn extract_workflow_proposal_ignores_non_proposal_history() { + let history = vec![ + ChatMessage::user("hello"), + ChatMessage::tool("plain text tool output, not json"), + ChatMessage::assistant("done"), + ]; + assert!(extract_workflow_proposal_from_history(&history).is_none()); + } + + #[test] + fn attach_workflow_proposal_persists_thread_message_and_extends_summary() { + use crate::openhuman::memory_conversations::CreateConversationThread; + let temp = tempfile::tempdir().expect("tempdir"); + conversations::ensure_thread( + temp.path().to_path_buf(), + CreateConversationThread { + id: "thread-parent".into(), + title: "Main chat".into(), + created_at: chrono::Utc::now().to_rfc3339(), + parent_thread_id: None, + labels: None, + personality_id: None, + }, + ) + .expect("thread created"); + + let history = vec![ChatMessage::tool( + r#"{"type":"workflow_proposal","persisted":false,"name":"Daily X Trending Email","graph":{"nodes":[],"edges":[]}}"#, + )]; + let summary = attach_workflow_proposal( + temp.path(), + Some("thread-parent"), + "sub-task-1", + "workflow_builder", + &history, + "Here's the proposed workflow.".to_string(), + ); + + // Delivery notice carries the machine-readable envelope. + assert!(summary.starts_with("Here's the proposed workflow.")); + assert!(summary.contains("[workflow_proposal]")); + assert!(summary.contains("\"name\":\"Daily X Trending Email\"")); + assert!(summary.contains("[/workflow_proposal]")); + + // Proposal is durably persisted in the parent thread with rehydratable + // metadata (this is what survives reload / a dropped socket event). + let messages = conversations::get_messages(temp.path().to_path_buf(), "thread-parent") + .expect("messages"); + let proposal_msg = messages + .iter() + .find(|m| m.id == "workflow-proposal:sub-task-1") + .expect("proposal message persisted"); + assert_eq!(proposal_msg.sender, "agent"); + assert!(proposal_msg.content.contains("Daily X Trending Email")); + assert_eq!(proposal_msg.extra_metadata["scope"], "workflow_proposal"); + assert_eq!( + proposal_msg.extra_metadata["proposal"]["name"], + "Daily X Trending Email" + ); + assert_eq!(proposal_msg.extra_metadata["task_id"], "sub-task-1"); + } + + #[test] + fn attach_workflow_proposal_without_proposal_returns_summary_unchanged() { + let temp = tempfile::tempdir().expect("tempdir"); + let summary = attach_workflow_proposal( + temp.path(), + Some("thread-x"), + "sub-task-2", + "researcher", + &[ChatMessage::tool("no proposal here")], + "research done".to_string(), + ); + assert_eq!(summary, "research done"); + } + #[tokio::test] async fn missing_agent_id_returns_error() { let tool = SpawnAsyncSubagentTool::new(); diff --git a/src/openhuman/agent_orchestration/tools/tools_e2e_tests.rs b/src/openhuman/agent_orchestration/tools/tools_e2e_tests.rs index 801555c239..33782c9b4f 100644 --- a/src/openhuman/agent_orchestration/tools/tools_e2e_tests.rs +++ b/src/openhuman/agent_orchestration/tools/tools_e2e_tests.rs @@ -83,6 +83,248 @@ async fn archetype_delegation_tool_runs_child_agent_e2e() { assert!(provider.saw(ARCHETYPE_DELEGATION_CANARY)); } +#[tokio::test] +async fn archetype_delegation_defaults_to_async_with_durable_session_e2e() { + // The continuity contract: with a parent turn AND a chat thread to + // deliver into, a delegate_* call must NOT run inline — it returns an + // [async_subagent_ref] immediately (task_id + subagent_session_id the + // orchestrator can steer/wait/continue by) and persists a durable + // session in the workspace store. The finished result is then queued + // for background delivery as a new chat turn. + let _ = AgentDefinitionRegistry::init_global_builtins(); + let workspace = tempfile::TempDir::new().expect("workspace"); + let provider = Arc::new(ScriptedProvider::new(vec![( + ARCHETYPE_DELEGATION_CANARY, + "async-delegation-child-answer", + )])); + let tool = ArchetypeDelegationTool { + tool_name: "delegate_researcher".to_string(), + agent_id: "researcher".to_string(), + tool_description: "Delegate research work.".to_string(), + }; + + let mut ctx = parent_context(workspace.path(), provider.clone(), vec![]); + ctx.session_id = "tools-e2e-async-session".into(); + let result = with_parent_context(ctx, async { + crate::openhuman::inference::provider::thread_context::with_thread_id( + "thread-async-parent", + async { + tool.execute(json!({ + "prompt": format!("Research {ARCHETYPE_DELEGATION_CANARY} in the background"), + "model": "test-model" + })) + .await + }, + ) + .await + }) + .await + .expect("tool execution"); + + assert!(!result.is_error, "{}", result.output()); + let out = result.output(); + assert!( + out.contains("[async_subagent_ref]"), + "async ref returned immediately, not the child's answer: {out}" + ); + assert!(out.contains("\"task_id\":\"sub-"), "carries task_id: {out}"); + assert!( + out.contains("subagent_session_id"), + "carries durable id: {out}" + ); + + // The durable session must exist for this parent, and reach a terminal + // reusable state once the (scripted, near-instant) child finishes. + use crate::openhuman::agent_orchestration::subagent_sessions::{ + self, DurableSubagentStatus, SubagentSessionStore, + }; + let store = SubagentSessionStore::new(workspace.path().to_path_buf()); + let mut finished = None; + for _ in 0..600 { + let sessions = subagent_sessions::list_for_parent(&store, "tools-e2e-async-session", None) + .expect("durable store readable"); + if let Some(session) = sessions.first() { + if session.status == DurableSubagentStatus::Idle { + finished = Some(session.clone()); + break; + } + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + let session = finished.expect("durable session reached Idle after background completion"); + assert_eq!(session.agent_id, "researcher"); + assert!( + session + .latest_history + .as_ref() + .is_some_and(|h| !h.is_empty()), + "resumable history persisted" + ); + // Completion queued for delivery back into the parent chat as a new turn. + assert!( + crate::openhuman::agent_orchestration::background_completions::has_pending( + "tools-e2e-async-session" + ), + "finished result queued for background delivery" + ); + let _ = crate::openhuman::agent_orchestration::background_completions::take_pending( + "tools-e2e-async-session", + ); +} + +#[tokio::test] +async fn continue_subagent_resumes_idle_durable_session_e2e() { + // The "looks good" flow: a workflow_builder-style worker finished in an + // EARLIER turn (durable session Idle, history persisted, no pause + // checkpoint on disk). continue_subagent must resume that same session + // with the follow-up — seeding the persisted history — instead of + // erroring "no checkpoint" or spawning a stateless fresh worker. + use super::ContinueSubagentTool; + use crate::openhuman::agent_orchestration::subagent_sessions::{ + self, SubagentSessionSelector, SubagentSessionStore, SubagentSessionUpsert, + }; + + let _ = env_logger::builder().is_test(true).try_init(); + let _ = AgentDefinitionRegistry::init_global_builtins(); + let registry = AgentDefinitionRegistry::global().expect("registry"); + let definition = registry.get("researcher").expect("researcher definition"); + let workspace = tempfile::TempDir::new().expect("workspace"); + let provider = Arc::new(ScriptedProvider::new(vec![( + "continue-durable-canary", + "resumed-child-answer", + )])); + + // Seed the durable session exactly as an earlier async run would have + // left it, with a selector matching what the resume path will compute. + let store = SubagentSessionStore::new(workspace.path().to_path_buf()); + let action_root = subagent_sessions::action_root_key( + crate::openhuman::security::live_policy::current() + .map(|policy| policy.action_dir.clone()) + .as_deref(), + ); + let session = subagent_sessions::upsert_running( + &store, + SubagentSessionUpsert { + selector: SubagentSessionSelector { + parent_session: "tools-e2e-continue-session".into(), + parent_thread_id: Some("thread-continue-parent".into()), + agent_id: "researcher".into(), + toolkit: None, + // Pin the seeded session to the parent's scripted provider — + // continue_subagent forwards session.model into the resume, so + // without this the child would resolve the definition's managed + // tier and dial a REAL provider instead of the test double. + model: Some("test-model".into()), + sandbox_mode: format!("{:?}", definition.sandbox_mode), + action_root, + task_key: "durable-resume-task".into(), + }, + display_name: Some("Researcher".into()), + task_title: "Durable resume task".into(), + worker_thread_id: None, + task_id: "sub-earlier-task".into(), + }, + None, + ) + .expect("seed durable session"); + subagent_sessions::mark_finished( + &store, + &session.subagent_session_id, + "sub-earlier-task", + &crate::openhuman::agent::harness::subagent_runner::SubagentRunStatus::Completed, + vec![ + ChatMessage::user("original task from an earlier turn"), + ChatMessage::assistant("earlier proposal result"), + ], + ) + .expect("mark idle with history"); + + let mut ctx = parent_context(workspace.path(), provider.clone(), vec![]); + ctx.session_id = "tools-e2e-continue-session".into(); + let session_id = session.subagent_session_id.clone(); + let result = with_parent_context(ctx, async { + crate::openhuman::inference::provider::thread_context::with_thread_id( + "thread-continue-parent", + async { + ContinueSubagentTool::new() + .execute(json!({ + "task_id": session_id, + "agent_id": "researcher", + "message": "looks good — proceed with continue-durable-canary" + })) + .await + }, + ) + .await + }) + .await + .expect("tool execution"); + + assert!(!result.is_error, "{}", result.output()); + let out = result.output(); + assert!( + out.contains("[async_subagent_ref]"), + "resume goes async with a durable ref: {out}" + ); + assert!( + out.contains(&session.subagent_session_id), + "resume keeps the SAME durable session id: {out}" + ); + + // The background resume must reach the provider carrying BOTH the + // persisted prior history and the new follow-up. + for _ in 0..600 { + if provider.saw("continue-durable-canary") { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + assert!( + provider.saw("continue-durable-canary"), + "follow-up reached the resumed child" + ); + assert!( + provider.saw("original task from an earlier turn"), + "persisted history was replayed into the resumed run" + ); + let _ = crate::openhuman::agent_orchestration::background_completions::take_pending( + "tools-e2e-continue-session", + ); +} + +#[tokio::test] +async fn continue_subagent_without_checkpoint_or_durable_session_names_the_roster() { + use super::ContinueSubagentTool; + let _ = AgentDefinitionRegistry::init_global_builtins(); + let workspace = tempfile::TempDir::new().expect("workspace"); + let provider = Arc::new(ScriptedProvider::new(vec![])); + + let mut ctx = parent_context(workspace.path(), provider, vec![]); + ctx.session_id = "tools-e2e-continue-missing".into(); + let result = with_parent_context(ctx, async { + ContinueSubagentTool::new() + .execute(json!({ + "task_id": "sub-does-not-exist", + "agent_id": "researcher", + "message": "hello?" + })) + .await + }) + .await + .expect("tool execution"); + + assert!(result.is_error); + let out = result.output(); + assert!( + out.contains("no checkpoint and no durable session"), + "explains both lookups failed: {out}" + ); + assert!( + out.contains("[active_subagents]"), + "points the model at the roster: {out}" + ); +} + #[tokio::test] async fn skill_delegation_tool_runs_integrations_agent_e2e() { let _ = AgentDefinitionRegistry::init_global_builtins(); diff --git a/src/openhuman/agent_registry/agents/orchestrator/agent.toml b/src/openhuman/agent_registry/agents/orchestrator/agent.toml index 9a075c322e..e8817a7d9d 100644 --- a/src/openhuman/agent_registry/agents/orchestrator/agent.toml +++ b/src/openhuman/agent_registry/agents/orchestrator/agent.toml @@ -160,6 +160,11 @@ allowlist = [ # or flow" request here — it grounds nodes in real tool slugs + connections, # can sandbox dry-run a draft, and returns a workflow PROPOSAL for the user # to review and save. It never creates/enables/runs a real flow itself. + # Delegations run ASYNC by default: `build_workflow` returns an + # [async_subagent_ref] immediately and the proposal arrives as a follow-up + # turn; iterate on it via continue_subagent with the subagent_session_id + # from that ref / the [active_subagents] roster — never re-delegate the + # same build from scratch. "workflow_builder", # Workflow-discovery specialist (the "Flow Scout"). Synthesised into a # `discover_workflows` tool at agent-build time. Route "what workflows From 9420a2983afeebc44f98c16124e6fcadbb43be06 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:39:46 +0300 Subject: [PATCH 03/56] revert(safety): remove Emergency Stop for desktop automation (#4600) (#5073) --- app/src/App.tsx | 27 - .../safety/AutomationHaltedBanner.test.tsx | 116 -- .../safety/AutomationHaltedBanner.tsx | 89 -- .../safety/EmergencyStopButton.test.tsx | 65 - .../components/safety/EmergencyStopButton.tsx | 73 - app/src/lib/i18n/ar.ts | 7 - app/src/lib/i18n/bn.ts | 7 - app/src/lib/i18n/de.ts | 9 - app/src/lib/i18n/en.ts | 9 - app/src/lib/i18n/es.ts | 9 - app/src/lib/i18n/fr.ts | 9 - app/src/lib/i18n/hi.ts | 7 - app/src/lib/i18n/id.ts | 7 - app/src/lib/i18n/it.ts | 7 - app/src/lib/i18n/ko.ts | 7 - app/src/lib/i18n/pl.ts | 7 - app/src/lib/i18n/pt.ts | 7 - app/src/lib/i18n/ru.ts | 9 - app/src/lib/i18n/zh-CN.ts | 7 - .../__tests__/socketService.events.test.ts | 153 -- app/src/services/api/emergencyApi.test.ts | 38 - app/src/services/api/emergencyApi.ts | 31 - .../safety/hydrateEmergencyState.test.ts | 49 - .../services/safety/hydrateEmergencyState.ts | 21 - app/src/services/socketService.ts | 41 - app/src/store/index.ts | 2 - app/src/store/safetySlice.test.ts | 26 - app/src/store/safetySlice.ts | 49 - app/src/test/test-utils.tsx | 2 - .../plans/2026-07-06-emergency-stop.md | 1358 ----------------- .../specs/2026-07-06-emergency-stop-design.md | 102 -- src/core/all.rs | 6 - src/core/event_bus/events.rs | 22 +- src/core/event_bus/events_tests.rs | 15 - src/core/jsonrpc.rs | 7 - src/openhuman/channels/runtime/startup.rs | 5 - src/openhuman/cron/scheduler.rs | 38 - src/openhuman/cron/scheduler_tests.rs | 37 - src/openhuman/emergency_stop/mod.rs | 16 - src/openhuman/emergency_stop/ops.rs | 165 -- src/openhuman/emergency_stop/schemas.rs | 166 -- src/openhuman/emergency_stop/state.rs | 174 --- src/openhuman/emergency_stop/types.rs | 51 - src/openhuman/learning/startup.rs | 30 +- src/openhuman/mod.rs | 1 - src/openhuman/screen_intelligence/ops.rs | 68 - src/openhuman/tinyagents/middleware.rs | 59 - src/openhuman/web_chat/event_bus.rs | 177 --- src/openhuman/web_chat/mod.rs | 4 +- tests/json_rpc_e2e.rs | 84 - 50 files changed, 28 insertions(+), 3447 deletions(-) delete mode 100644 app/src/components/safety/AutomationHaltedBanner.test.tsx delete mode 100644 app/src/components/safety/AutomationHaltedBanner.tsx delete mode 100644 app/src/components/safety/EmergencyStopButton.test.tsx delete mode 100644 app/src/components/safety/EmergencyStopButton.tsx delete mode 100644 app/src/services/api/emergencyApi.test.ts delete mode 100644 app/src/services/api/emergencyApi.ts delete mode 100644 app/src/services/safety/hydrateEmergencyState.test.ts delete mode 100644 app/src/services/safety/hydrateEmergencyState.ts delete mode 100644 app/src/store/safetySlice.test.ts delete mode 100644 app/src/store/safetySlice.ts delete mode 100644 docs/superpowers/plans/2026-07-06-emergency-stop.md delete mode 100644 docs/superpowers/specs/2026-07-06-emergency-stop-design.md delete mode 100644 src/openhuman/emergency_stop/mod.rs delete mode 100644 src/openhuman/emergency_stop/ops.rs delete mode 100644 src/openhuman/emergency_stop/schemas.rs delete mode 100644 src/openhuman/emergency_stop/state.rs delete mode 100644 src/openhuman/emergency_stop/types.rs diff --git a/app/src/App.tsx b/app/src/App.tsx index 645b948731..09969b5df9 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -30,8 +30,6 @@ import SecretPromptDialog from './components/mcp-setup/SecretPromptDialog'; import OpenhumanLinkModal from './components/OpenhumanLinkModal'; import PersistRehydrationScreen from './components/PersistRehydrationScreen'; import PttHotkeyManager from './components/PttHotkeyManager'; -import { AutomationHaltedBanner } from './components/safety/AutomationHaltedBanner'; -import { EmergencyStopButton } from './components/safety/EmergencyStopButton'; import SecurityBanner from './components/SecurityBanner'; import SettingsModal from './components/settings/modal/SettingsModal'; import { resolveSettingsOverlay } from './components/settings/modal/settingsOverlay'; @@ -59,7 +57,6 @@ import { startInternetStatusListener, stopInternetStatusListener, } from './services/internetStatusListener'; -import { hydrateEmergencyState } from './services/safety/hydrateEmergencyState'; import { hideWebviewAccount, startWebviewAccountService, @@ -259,16 +256,6 @@ export function AppShellDesktop() { // the core is ready (once per boot). Extracted to a hook so it's testable. useNotchBootSync(isBootstrapping); - // Boot hydration: read the authoritative halt state from the core once on - // mount so the UI reflects any halt that was engaged before this window - // opened (e.g. another tab, CLI, or a crash-recovery scenario). Errors are - // swallowed inside hydrateEmergencyState so a degraded core never blanks the shell. - useEffect(() => { - void hydrateEmergencyState(dispatch); - // Intentionally runs once on mount only. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const scrollRef = useRef(null); const navType = useNavigationType(); @@ -304,9 +291,6 @@ export function AppShellDesktop() { const content = (
- {/* Automation halt banner — renders at the top of the content area when - emergency stop is engaged. Always visible during automation sessions. */} - {activeProviderAccount && !accountsOverlayOpen && ( @@ -348,17 +332,6 @@ export function AppShellDesktop() { exhaustion). Mounted outside the routes so entries survive route changes and background-job completion. */} - {/* Emergency Stop — persistent safety control pinned to the top-right, - clear of the chat composer (bottom) and the sidebar (left); the - macOS traffic lights sit top-left, so the top-right stays free. The - button hides itself while halted (the AutomationHaltedBanner's - Resume takes over). Only shown when the shell chrome is visible - (i.e. the user is authenticated and past onboarding). */} - {!chromeless && ( -
- -
- )} {/* Hidden Remotion-driven producer for the Meet camera. Mounts a 640×480 JPEG frame stream to the Rust frame bus while a meet call is active; idle no-op otherwise. See diff --git a/app/src/components/safety/AutomationHaltedBanner.test.tsx b/app/src/components/safety/AutomationHaltedBanner.test.tsx deleted file mode 100644 index f9b2d2034d..0000000000 --- a/app/src/components/safety/AutomationHaltedBanner.test.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { act, fireEvent, screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { clearHalt, setHalt } from '../../store/safetySlice'; -import { renderWithProviders } from '../../test/test-utils'; -import { AutomationHaltedBanner } from './AutomationHaltedBanner'; - -const resume = vi.fn().mockResolvedValue({ engaged: false }); -vi.mock('../../services/api/emergencyApi', () => ({ - emergencyResume: (...a: unknown[]) => resume(...a), -})); - -beforeEach(() => resume.mockClear()); - -describe('AutomationHaltedBanner', () => { - it('renders nothing when not halted', () => { - const { container } = renderWithProviders(); - expect(container.firstChild).toBeNull(); - }); - - it('renders the banner when halted', () => { - const { store } = renderWithProviders(, { - preloadedState: { safety: { halted: true } }, - }); - expect(screen.getByRole('alert')).toBeDefined(); - expect(screen.getByRole('alert').getAttribute('data-analytics-id')).toBe( - 'automation-halted-banner' - ); - // safety state is engaged - expect((store.getState() as { safety: { halted: boolean } }).safety.halted).toBe(true); - }); - - it('shows reason when available', () => { - renderWithProviders(, { - preloadedState: { safety: { halted: true, reason: 'custom reason' } }, - }); - expect(screen.getByText('custom reason')).toBeDefined(); - }); - - it('falls back to haltedBody when reason is absent', () => { - renderWithProviders(, { - preloadedState: { safety: { halted: true } }, - }); - expect(screen.getByText(/desktop automation is stopped/i)).toBeDefined(); - }); - - it('calls emergencyResume and clears halt when Resume is clicked', async () => { - const { store } = renderWithProviders(, { - preloadedState: { safety: { halted: true, reason: 'test' } }, - }); - fireEvent.click(screen.getByRole('button', { name: /resume/i })); - await waitFor(() => expect(resume).toHaveBeenCalled()); - const safetyState = (store.getState() as { safety: { halted: boolean } }).safety; - expect(safetyState.halted).toBe(false); - }); - - it('preserves halt and surfaces a retry message when emergencyResume fails', async () => { - // Fail-closed: on RPC failure the core is still halted, so the UI must - // NOT silently clear the halt. Clearing locally would re-expose the Stop - // button while every external-effect action remained blocked, giving a - // false "resumed" signal (#4255 codex P2). - resume.mockRejectedValueOnce(new Error('core error')); - const { store } = renderWithProviders(, { - preloadedState: { safety: { halted: true } }, - }); - fireEvent.click(screen.getByRole('button', { name: /resume/i })); - await waitFor(() => expect(resume).toHaveBeenCalled()); - // Halt state must remain engaged after the failed RPC. - const safetyState = (store.getState() as { safety: { halted: boolean } }).safety; - expect(safetyState.halted).toBe(true); - // Visible retry indicator appears. - await waitFor(() => - expect(screen.getByRole('status', { name: /could not resume/i })).toBeDefined() - ); - // Banner is still there so the user retains a Resume button to try again. - expect(screen.getByRole('alert')).toBeDefined(); - }); - - it('clears the stale retry indicator on a new halt cycle', async () => { - // Guards the cross-cycle leak: the banner is mounted permanently, so a failed - // resume in one cycle must not surface a stale "could not resume" indicator on - // a later, unrelated halt. Drive: fail a resume → clear the halt via the - // external socket path (not the successful-RPC branch) → start a fresh halt. - resume.mockRejectedValueOnce(new Error('core error')); - const { store } = renderWithProviders(, { - preloadedState: { safety: { halted: true } }, - }); - fireEvent.click(screen.getByRole('button', { name: /resume/i })); - await waitFor(() => - expect(screen.getByRole('status', { name: /could not resume/i })).toBeDefined() - ); - // Halt lifts via the socket-driven clear (bypasses the successful resume path). - act(() => { - store.dispatch(clearHalt()); - }); - // A brand-new halt cycle begins. - act(() => { - store.dispatch(setHalt({ reason: 'second cycle', source: 'test' })); - }); - await waitFor(() => expect(screen.getByRole('alert')).toBeDefined()); - // The retry indicator from the previous cycle must not carry over. - expect(screen.queryByRole('status', { name: /could not resume/i })).toBeNull(); - }); - - it('dispatches halt and then renders banner after setHalt dispatch', async () => { - const { store } = renderWithProviders(); - // Initially not halted - expect((store.getState() as { safety: { halted: boolean } }).safety.halted).toBe(false); - // Dispatch halt and let React re-render - act(() => { - store.dispatch(setHalt({ reason: 'dispatched', source: 'test' })); - }); - // Banner should appear - await waitFor(() => expect(screen.getByRole('alert')).toBeDefined()); - }); -}); diff --git a/app/src/components/safety/AutomationHaltedBanner.tsx b/app/src/components/safety/AutomationHaltedBanner.tsx deleted file mode 100644 index ded9a7a25b..0000000000 --- a/app/src/components/safety/AutomationHaltedBanner.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; - -import { useT } from '../../lib/i18n/I18nContext'; -import { emergencyResume } from '../../services/api/emergencyApi'; -import { useAppDispatch, useAppSelector } from '../../store/hooks'; -import { clearHalt, selectHalted, selectHaltReason } from '../../store/safetySlice'; - -/** - * AutomationHaltedBanner — renders at the top of main content when automation - * is halted via the emergency stop. Provides a Resume button to lift the halt. - * - * The Redux `clearHalt` only fires on a confirmed resume from the core. If the - * `emergency_resume` RPC fails (timeout, auth, core unavailable), the halt is - * preserved locally and a visible retry message is shown — because the core is - * still halted and clearing the banner would silently re-enable the Stop button - * while every external-effect action remained blocked. The authoritative source - * of truth is the core; the `automation_halt` socket broadcast will also clear - * the state if the resume succeeds server-side after an in-flight RPC failure. - */ -export function AutomationHaltedBanner() { - const { t } = useT(); - const dispatch = useAppDispatch(); - const halted = useAppSelector(selectHalted); - const reason = useAppSelector(selectHaltReason); - const [resumeFailed, setResumeFailed] = useState(false); - - const onResume = useCallback(async () => { - setResumeFailed(false); - console.debug('[emergency] resume requested (source=user)'); - try { - await emergencyResume(); - console.debug('[emergency] resume confirmed by core'); - // Only clear locally on a CONFIRMED resume. On failure the core is still - // halted, so clearing here would give a false "safe to run" signal. - dispatch(clearHalt()); - } catch (err) { - console.error('[emergency] resume FAILED — halt preserved locally, retry required', err); - setResumeFailed(true); - } - }, [dispatch]); - - // The banner is mounted permanently (it only returns null when not halted), so - // `resumeFailed` would otherwise leak across halt cycles: a failed resume, then - // an external socket-driven clear, then a fresh halt would show a stale "could - // not resume" retry indicator the user never triggered. Reset it whenever the - // halt lifts so each new cycle starts clean. - useEffect(() => { - if (!halted) setResumeFailed(false); - }, [halted]); - - if (!halted) return null; - - // `sticky top-0 z-40` keeps the halt banner (and its Resume button) visible - // and reachable ABOVE the provider WebviewHost overlay (absolute inset-0 z-30, - // rendered as a sibling below); otherwise an active provider account fully - // covers the safety banner. Stays below the settings modal portal (z-50), - // matching the app's documented stacking convention. - return ( -
-
- {t('safety.haltedTitle')} - - {reason || t('safety.haltedBody')} - -
-
- {resumeFailed && ( - - {t('safety.resumeFailed')} - - )} - -
-
- ); -} diff --git a/app/src/components/safety/EmergencyStopButton.test.tsx b/app/src/components/safety/EmergencyStopButton.test.tsx deleted file mode 100644 index 12f72d8da1..0000000000 --- a/app/src/components/safety/EmergencyStopButton.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { setHalt } from '../../store/safetySlice'; -import { renderWithProviders } from '../../test/test-utils'; -import { EmergencyStopButton } from './EmergencyStopButton'; - -const stop = vi - .fn() - .mockResolvedValue({ - engaged: true, - reason: undefined, - source: undefined, - engaged_at_ms: undefined, - }); -vi.mock('../../services/api/emergencyApi', () => ({ - emergencyStop: (...a: unknown[]) => stop(...a), -})); - -beforeEach(() => stop.mockClear()); - -describe('EmergencyStopButton', () => { - it('renders a button with the emergency stop label', () => { - renderWithProviders(); - expect(screen.getByRole('button', { name: /emergency stop/i })).toBeDefined(); - }); - - it('calls emergencyStop with no argument and dispatches halt on click', async () => { - const { store } = renderWithProviders(); - fireEvent.click(screen.getByRole('button', { name: /emergency stop/i })); - await waitFor(() => expect(stop).toHaveBeenCalledWith()); - const safetyState = (store.getState() as { safety: { halted: boolean } }).safety; - expect(safetyState.halted).toBe(true); - }); - - it('does NOT mark halted when emergencyStop throws, and shows a visible error', async () => { - stop.mockRejectedValueOnce(new Error('core unavailable')); - const { store } = renderWithProviders(); - fireEvent.click(screen.getByRole('button', { name: /emergency stop/i })); - await waitFor(() => expect(stop).toHaveBeenCalled()); - // The core did not confirm the halt, so the UI must not claim halted. - const safetyState = (store.getState() as { safety: { halted: boolean } }).safety; - expect(safetyState.halted).toBe(false); - // Button stays visible so the user can retry. - expect(screen.queryByRole('button', { name: /emergency stop/i })).not.toBeNull(); - // A visible, retryable error is surfaced so the operator knows it failed. - await waitFor(() => expect(screen.getByRole('alert')).toBeDefined()); - }); - - it('renders nothing while already halted (banner Resume takes over)', () => { - renderWithProviders(, { - preloadedState: { safety: { halted: true, source: 'user' } }, - }); - expect(screen.queryByRole('button', { name: /emergency stop/i })).toBeNull(); - }); - - it('hides itself when the store transitions to halted', async () => { - const { store } = renderWithProviders(); - expect(screen.getByRole('button', { name: /emergency stop/i })).toBeDefined(); - store.dispatch(setHalt({ source: 'user' })); - await waitFor(() => - expect(screen.queryByRole('button', { name: /emergency stop/i })).toBeNull() - ); - }); -}); diff --git a/app/src/components/safety/EmergencyStopButton.tsx b/app/src/components/safety/EmergencyStopButton.tsx deleted file mode 100644 index 254b38ac70..0000000000 --- a/app/src/components/safety/EmergencyStopButton.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { useCallback, useState } from 'react'; - -import { useT } from '../../lib/i18n/I18nContext'; -import { emergencyStop } from '../../services/api/emergencyApi'; -import { useAppDispatch, useAppSelector } from '../../store/hooks'; -import { selectHalted, setHalt } from '../../store/safetySlice'; - -/** - * Emergency Stop button — always-visible safety control that halts all desktop - * automation immediately. On click it calls the core `emergency_stop` RPC and - * reflects the halt in the Redux safety slice. - * - * On RPC failure it does NOT mark the halt locally (that would falsely signal a - * stop that did not happen) — instead it surfaces a visible, retryable error so - * the operator knows the kill switch did not engage. - * - * Hidden while automation is already halted: the `AutomationHaltedBanner`'s - * Resume control takes over, so Stop and Resume are never shown at once. - */ -export function EmergencyStopButton() { - const { t } = useT(); - const dispatch = useAppDispatch(); - const halted = useAppSelector(selectHalted); - const [failed, setFailed] = useState(false); - - const handleClick = useCallback(async () => { - setFailed(false); - console.debug('[emergency] stop requested (source=user)'); - try { - const state = await emergencyStop(); - console.debug('[emergency] stop confirmed by core', { - engaged: state.engaged, - source: state.source, - }); - dispatch(setHalt({ reason: state.reason, source: state.source, since: state.engaged_at_ms })); - } catch (err) { - // Do NOT mark halted locally on failure: if the RPC did not succeed the - // core is not actually halted, and showing the halted banner would give a - // false sense of safety. Surface a visible, retryable error instead so the - // operator knows the stop did not go through; a confirmed halt only - // appears from a successful response or the `automation_halt` broadcast. - setFailed(true); - console.error('[emergency] stop FAILED — core NOT halted, retry required', err); - } - }, [dispatch]); - - // Already halted → the halt banner (with Resume) is the active control. - if (halted) return null; - - return ( -
- {failed && ( - - {t('safety.stopFailed')} - - )} - -
- ); -} diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 818fb6c56e..86ccd740d5 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -7204,13 +7204,6 @@ const messages: TranslationMap = { 'flows.canvas.sidePanelToggle': 'اللوحة الجانبية', 'flows.canvas.legendTab': 'يدوي', - // Emergency stop (#4255) - 'safety.emergencyStop': 'إيقاف الطوارئ', - 'safety.stopFailed': 'تعذّر إيقاف الأتمتة. أعد المحاولة.', - 'safety.resume': 'استئناف الأتمتة', - 'safety.resumeFailed': 'تعذّر الاستئناف. لا تزال الأتمتة متوقفة. أعد المحاولة.', - 'safety.haltedTitle': 'الأتمتة متوقفة', - 'safety.haltedBody': 'تم إيقاف جميع أتمتة سطح المكتب. استأنف عندما تكون مستعدًا.', // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'حالة الخصوصية', 'privacy.status.external': 'خارج الجهاز', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 9bc096d684..d67bba8a75 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -7372,13 +7372,6 @@ const messages: TranslationMap = { 'flows.canvas.sidePanelToggle': 'সাইড প্যানেল', 'flows.canvas.legendTab': 'ম্যানুয়াল', - // Emergency stop (#4255) - 'safety.emergencyStop': 'জরুরি বন্ধ', - 'safety.stopFailed': 'অটোমেশন থামানো যায়নি। আবার চেষ্টা করুন।', - 'safety.resume': 'অটোমেশন পুনরায় শুরু করুন', - 'safety.resumeFailed': 'পুনরায় শুরু করা যায়নি। অটোমেশন এখনও বন্ধ। আবার চেষ্টা করুন।', - 'safety.haltedTitle': 'অটোমেশন বন্ধ', - 'safety.haltedBody': 'সমস্ত ডেস্কটপ অটোমেশন বন্ধ করা হয়েছে। প্রস্তুত হলে পুনরায় শুরু করুন।', // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'গোপনীয়তা স্থিতি', 'privacy.status.external': 'ডিভাইসের বাইরে', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 01e2dfdc84..0cf85fef8a 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -7592,15 +7592,6 @@ const messages: TranslationMap = { 'flows.canvas.sidePanelToggle': 'Seitenleiste', 'flows.canvas.legendTab': 'Manuell', - // Emergency stop (#4255) - 'safety.emergencyStop': 'Notabschaltung', - 'safety.stopFailed': 'Automatisierung konnte nicht gestoppt werden – bitte erneut versuchen.', - 'safety.resume': 'Automatisierung fortsetzen', - 'safety.resumeFailed': - 'Fortsetzen fehlgeschlagen – Automatisierung ist weiterhin angehalten. Bitte erneut versuchen.', - 'safety.haltedTitle': 'Automatisierung angehalten', - 'safety.haltedBody': - 'Alle Desktop-Automatisierungen sind gestoppt. Fortsetzen, wenn Sie bereit sind.', // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'Datenschutzstatus', 'privacy.status.external': 'Außerhalb des Geräts', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index b776dd18f7..54342cc03d 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -7673,15 +7673,6 @@ const en: TranslationMap = { 'Your AI provider has no API key set. Add one in provider settings to continue.', 'userErrors.scope.chat': 'Chat', 'userErrors.scope.cron': 'Scheduled job', - - // Emergency stop (#4255) - 'safety.emergencyStop': 'Emergency stop', - 'safety.stopFailed': 'Could not stop automation. Try again.', - 'safety.resume': 'Resume automation', - 'safety.resumeFailed': 'Could not resume. Automation is still halted. Try again.', - 'safety.haltedTitle': 'Automation halted', - 'safety.haltedBody': 'All desktop automation is stopped. Resume when you are ready.', - 'memorySources.codingSessions.title': 'Coding-agent sessions', 'memorySources.codingSessions.description': 'Turn your Codex and Claude Code decisions and corrections into private persona memory.', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 86a724d241..d9773b9e72 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -7527,15 +7527,6 @@ const messages: TranslationMap = { 'flows.canvas.sidePanelToggle': 'Panel lateral', 'flows.canvas.legendTab': 'Manual', - // Emergency stop (#4255) - 'safety.emergencyStop': 'Parada de emergencia', - 'safety.stopFailed': 'No se pudo detener la automatización: inténtalo de nuevo.', - 'safety.resume': 'Reanudar automatización', - 'safety.resumeFailed': - 'No se pudo reanudar: la automatización sigue detenida. Inténtalo de nuevo.', - 'safety.haltedTitle': 'Automatización detenida', - 'safety.haltedBody': - 'Toda la automatización de escritorio está detenida. Reanuda cuando estés listo.', // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'Estado de privacidad', 'privacy.status.external': 'Fuera del dispositivo', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index e3b1e8d046..3ad118d95e 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -7561,15 +7561,6 @@ const messages: TranslationMap = { 'flows.canvas.sidePanelToggle': 'Panneau latéral', 'flows.canvas.legendTab': 'Manuel', - // Emergency stop (#4255) - 'safety.emergencyStop': "Arrêt d'urgence", - 'safety.stopFailed': "Impossible d'arrêter l'automatisation. Réessayez.", - 'safety.resume': "Reprendre l'automatisation", - 'safety.resumeFailed': - "Impossible de reprendre. L'automatisation est toujours suspendue. Réessayez.", - 'safety.haltedTitle': 'Automatisation suspendue', - 'safety.haltedBody': - "Toute l'automatisation du bureau est arrêtée. Reprenez quand vous êtes prêt.", // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'État de confidentialité', 'privacy.status.external': 'Hors de l’appareil', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 3c38a98459..12facfb7a7 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -7369,13 +7369,6 @@ const messages: TranslationMap = { 'flows.canvas.sidePanelToggle': 'साइड पैनल', 'flows.canvas.legendTab': 'मैनुअल', - // Emergency stop (#4255) - 'safety.emergencyStop': 'आपातकालीन रोक', - 'safety.stopFailed': 'स्वचालन रोका नहीं जा सका। पुनः प्रयास करें।', - 'safety.resume': 'स्वचालन पुनः प्रारंभ करें', - 'safety.resumeFailed': 'पुनः प्रारंभ नहीं हो सका। स्वचालन अभी भी रुका हुआ है। पुनः प्रयास करें।', - 'safety.haltedTitle': 'स्वचालन रोका गया', - 'safety.haltedBody': 'सभी डेस्कटॉप स्वचालन रोक दिया गया है। तैयार होने पर पुनः प्रारंभ करें।', // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'गोपनीयता स्थिति', 'privacy.status.external': 'डिवाइस के बाहर', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 6ee9bae7a3..a2a7ce85b7 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -7407,13 +7407,6 @@ const messages: TranslationMap = { 'flows.canvas.sidePanelToggle': 'Panel samping', 'flows.canvas.legendTab': 'Manual', - // Emergency stop (#4255) - 'safety.emergencyStop': 'Hentikan darurat', - 'safety.stopFailed': 'Tidak dapat menghentikan otomasi. Coba lagi.', - 'safety.resume': 'Lanjutkan otomasi', - 'safety.resumeFailed': 'Tidak dapat melanjutkan. Otomasi masih dihentikan. Coba lagi.', - 'safety.haltedTitle': 'Otomasi dihentikan', - 'safety.haltedBody': 'Semua otomasi desktop dihentikan. Lanjutkan ketika Anda siap.', // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'Status privasi', 'privacy.status.external': 'Di luar perangkat', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 364d450e91..e92f9c2bed 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -7515,13 +7515,6 @@ const messages: TranslationMap = { 'flows.canvas.sidePanelToggle': 'Pannello laterale', 'flows.canvas.legendTab': 'Manuale', - // Emergency stop (#4255) - 'safety.emergencyStop': 'Arresto di emergenza', - 'safety.stopFailed': "Impossibile fermare l'automazione. Riprova.", - 'safety.resume': "Riprendi l'automazione", - 'safety.resumeFailed': "Impossibile riprendere. L'automazione è ancora sospesa. Riprova.", - 'safety.haltedTitle': 'Automazione sospesa', - 'safety.haltedBody': "Tutta l'automazione del desktop è ferma. Riprendi quando sei pronto.", // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'Stato privacy', 'privacy.status.external': 'Fuori dal dispositivo', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index ebd1a62cb3..6485cc380a 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -7284,13 +7284,6 @@ const messages: TranslationMap = { 'flows.canvas.sidePanelToggle': '사이드 패널', 'flows.canvas.legendTab': '수동', - // Emergency stop (#4255) - 'safety.emergencyStop': '긴급 정지', - 'safety.stopFailed': '자동화를 중지할 수 없습니다. 다시 시도하세요.', - 'safety.resume': '자동화 재개', - 'safety.resumeFailed': '재개하지 못했습니다. 자동화가 여전히 중단된 상태입니다. 다시 시도하세요.', - 'safety.haltedTitle': '자동화 중단됨', - 'safety.haltedBody': '모든 데스크톱 자동화가 중지되었습니다. 준비가 되면 재개하세요.', // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': '개인정보 상태', 'privacy.status.external': '기기 외', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 8cad5c5f63..df2555176b 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -7485,13 +7485,6 @@ const messages: TranslationMap = { 'flows.canvas.sidePanelToggle': 'Panel boczny', 'flows.canvas.legendTab': 'Ręczny', - // Emergency stop (#4255) - 'safety.emergencyStop': 'Awaryjne zatrzymanie', - 'safety.stopFailed': 'Nie udało się zatrzymać automatyzacji. Spróbuj ponownie.', - 'safety.resume': 'Wznów automatyzację', - 'safety.resumeFailed': 'Nie udało się wznowić. Automatyzacja nadal wstrzymana. Spróbuj ponownie.', - 'safety.haltedTitle': 'Automatyzacja wstrzymana', - 'safety.haltedBody': 'Cała automatyzacja pulpitu jest zatrzymana. Wznów, gdy będziesz gotowy.', // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'Stan prywatności', 'privacy.status.external': 'Poza urządzeniem', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index c8cb8a8a48..16707e454d 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -7495,13 +7495,6 @@ const messages: TranslationMap = { 'flows.canvas.sidePanelToggle': 'Painel lateral', 'flows.canvas.legendTab': 'Manual', - // Emergency stop (#4255) - 'safety.emergencyStop': 'Parada de emergência', - 'safety.stopFailed': 'Não foi possível parar a automação. Tente novamente.', - 'safety.resume': 'Retomar automação', - 'safety.resumeFailed': 'Não foi possível retomar. Automação ainda pausada. Tente novamente.', - 'safety.haltedTitle': 'Automação pausada', - 'safety.haltedBody': 'Toda a automação do desktop está parada. Retome quando estiver pronto.', // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'Estado de privacidade', 'privacy.status.external': 'Fora do dispositivo', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index d36d802819..762abc76e0 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -7455,15 +7455,6 @@ const messages: TranslationMap = { 'flows.canvas.sidePanelToggle': 'Боковая панель', 'flows.canvas.legendTab': 'Вручную', - // Emergency stop (#4255) - 'safety.emergencyStop': 'Аварийная остановка', - 'safety.stopFailed': 'Не удалось остановить автоматизацию. Попробуйте ещё раз.', - 'safety.resume': 'Возобновить автоматизацию', - 'safety.resumeFailed': - 'Не удалось возобновить. Автоматизация всё ещё приостановлена. Повторите попытку.', - 'safety.haltedTitle': 'Автоматизация приостановлена', - 'safety.haltedBody': - 'Вся автоматизация рабочего стола остановлена. Возобновите, когда будете готовы.', // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'Состояние конфиденциальности', 'privacy.status.external': 'Вне устройства', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 663783f415..344bd1a704 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -6970,13 +6970,6 @@ const messages: TranslationMap = { 'flows.canvas.sidePanelToggle': '侧边栏', 'flows.canvas.legendTab': '手动', - // Emergency stop (#4255) - 'safety.emergencyStop': '紧急停止', - 'safety.stopFailed': '无法停止自动化,请重试。', - 'safety.resume': '恢复自动化', - 'safety.resumeFailed': '无法恢复,自动化仍处于暂停状态。请重试。', - 'safety.haltedTitle': '自动化已暂停', - 'safety.haltedBody': '所有桌面自动化已停止。准备好后请恢复。', // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': '隐私状态', 'privacy.status.external': '设备外', diff --git a/app/src/services/__tests__/socketService.events.test.ts b/app/src/services/__tests__/socketService.events.test.ts index 9202dd559b..c61553be3f 100644 --- a/app/src/services/__tests__/socketService.events.test.ts +++ b/app/src/services/__tests__/socketService.events.test.ts @@ -452,156 +452,3 @@ describe('socketService — agent_meetings event handlers (lines 428-480)', () = expect(ingestRuntimeErrorSignalMock).not.toHaveBeenCalled(); }); }); - -describe('socketService — automation_halt handler (#4255)', () => { - beforeEach(() => { - vi.resetModules(); - storeMock.dispatch.mockClear(); - storeMock.getState.mockReturnValue({ - thread: { selectedThreadId: null, activeThreadId: null }, - }); - getCoreRpcUrlMock.mockReset(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('dispatches setHalt when automation_halt arrives with engaged=true (WebChannelEvent envelope)', async () => { - const { handlers, mockSocket } = buildMockSocket(); - vi.doMock('socket.io-client', () => ({ io: vi.fn(() => mockSocket) })); - getCoreRpcUrlMock.mockResolvedValue('http://127.0.0.1:7788/rpc'); - - // Mock safetySlice actions - const setHaltMock = vi.fn((x: unknown) => ({ type: 'safety/setHalt', payload: x })); - const clearHaltMock = vi.fn(() => ({ type: 'safety/clearHalt' })); - vi.doMock('../../store/safetySlice', () => ({ - setHalt: (x: unknown) => setHaltMock(x), - clearHalt: () => clearHaltMock(), - })); - - const { socketService } = await import('../socketService'); - socketService.connect('jwt-halt-engaged'); - - await pollUntil(() => expect(handlers['automation_halt']).toBeDefined()); - - // Real wire payload: `emit_web_channel_event` serialises the entire - // `WebChannelEvent` envelope so halt fields ride under `args`. - handlers['automation_halt']!({ - event: 'automation_halt', - client_id: 'system', - thread_id: '', - request_id: '', - args: { engaged: true, reason: 'cli', source: 'cli' }, - }); - - expect(storeMock.dispatch).toHaveBeenCalledWith( - expect.objectContaining({ type: 'safety/setHalt' }) - ); - }); - - it('dispatches clearHalt when automation_halt arrives with engaged=false (WebChannelEvent envelope)', async () => { - const { handlers, mockSocket } = buildMockSocket(); - vi.doMock('socket.io-client', () => ({ io: vi.fn(() => mockSocket) })); - getCoreRpcUrlMock.mockResolvedValue('http://127.0.0.1:7788/rpc'); - - const clearHaltMock = vi.fn(() => ({ type: 'safety/clearHalt' })); - vi.doMock('../../store/safetySlice', () => ({ - setHalt: vi.fn((x: unknown) => ({ type: 'safety/setHalt', payload: x })), - clearHalt: () => clearHaltMock(), - })); - - const { socketService } = await import('../socketService'); - socketService.connect('jwt-halt-cleared'); - - await pollUntil(() => expect(handlers['automation_halt']).toBeDefined()); - - handlers['automation_halt']!({ - event: 'automation_halt', - client_id: 'system', - thread_id: '', - request_id: '', - args: { engaged: false, source: 'cli' }, - }); - - expect(storeMock.dispatch).toHaveBeenCalledWith( - expect.objectContaining({ type: 'safety/clearHalt' }) - ); - }); - - it('also accepts a top-level payload (direct-emit fallback for tests / future direct broadcasts)', async () => { - const { handlers, mockSocket } = buildMockSocket(); - vi.doMock('socket.io-client', () => ({ io: vi.fn(() => mockSocket) })); - getCoreRpcUrlMock.mockResolvedValue('http://127.0.0.1:7788/rpc'); - - vi.doMock('../../store/safetySlice', () => ({ - setHalt: vi.fn((x: unknown) => ({ type: 'safety/setHalt', payload: x })), - clearHalt: vi.fn(() => ({ type: 'safety/clearHalt' })), - })); - - const { socketService } = await import('../socketService'); - socketService.connect('jwt-halt-top-level'); - - await pollUntil(() => expect(handlers['automation_halt']).toBeDefined()); - - handlers['automation_halt']!({ engaged: true, reason: 'user', source: 'user' }); - - expect(storeMock.dispatch).toHaveBeenCalledWith( - expect.objectContaining({ type: 'safety/setHalt' }) - ); - }); - - it('drops a malformed automation_halt payload without dispatching or throwing', async () => { - const { handlers, mockSocket } = buildMockSocket(); - vi.doMock('socket.io-client', () => ({ io: vi.fn(() => mockSocket) })); - getCoreRpcUrlMock.mockResolvedValue('http://127.0.0.1:7788/rpc'); - - vi.doMock('../../store/safetySlice', () => ({ - setHalt: vi.fn((x: unknown) => ({ type: 'safety/setHalt', payload: x })), - clearHalt: vi.fn(() => ({ type: 'safety/clearHalt' })), - })); - - const { socketService } = await import('../socketService'); - socketService.connect('jwt-halt-malformed'); - - await pollUntil(() => expect(handlers['automation_halt']).toBeDefined()); - - storeMock.dispatch.mockClear(); - - // Non-object payloads should be silently dropped. - expect(() => handlers['automation_halt']!('not-an-object')).not.toThrow(); - expect(() => handlers['automation_halt']!(null)).not.toThrow(); - expect(storeMock.dispatch).not.toHaveBeenCalled(); - }); - - it('fails closed: an object without a boolean engaged is dropped (no clearHalt)', async () => { - const { handlers, mockSocket } = buildMockSocket(); - vi.doMock('socket.io-client', () => ({ io: vi.fn(() => mockSocket) })); - getCoreRpcUrlMock.mockResolvedValue('http://127.0.0.1:7788/rpc'); - - vi.doMock('../../store/safetySlice', () => ({ - setHalt: vi.fn((x: unknown) => ({ type: 'safety/setHalt', payload: x })), - clearHalt: vi.fn(() => ({ type: 'safety/clearHalt' })), - })); - - const { socketService } = await import('../socketService'); - socketService.connect('jwt-halt-ambiguous'); - - await pollUntil(() => expect(handlers['automation_halt']).toBeDefined()); - - storeMock.dispatch.mockClear(); - - // Ambiguous payloads (missing/non-boolean `engaged`) must NOT be treated as - // `engaged=false` — that would silently clear an active halt on a kill switch. - // Both the top-level shape and the `WebChannelEvent` `args` envelope shape - // are covered so a real malformed broadcast can never bypass the guard. - expect(() => handlers['automation_halt']!({})).not.toThrow(); - expect(() => handlers['automation_halt']!({ reason: 'x' })).not.toThrow(); - expect(() => handlers['automation_halt']!({ engaged: 'true' })).not.toThrow(); - expect(() => handlers['automation_halt']!({ args: {} })).not.toThrow(); - expect(() => - handlers['automation_halt']!({ args: { engaged: 'true', reason: 'x' } }) - ).not.toThrow(); - expect(storeMock.dispatch).not.toHaveBeenCalled(); - }); -}); diff --git a/app/src/services/api/emergencyApi.test.ts b/app/src/services/api/emergencyApi.test.ts deleted file mode 100644 index 5e80fe8fd8..0000000000 --- a/app/src/services/api/emergencyApi.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { emergencyResume, emergencyStatus, emergencyStop } from './emergencyApi'; - -const call = vi.fn(); -vi.mock('../coreRpcClient', () => ({ callCoreRpc: (arg: unknown) => call(arg) })); - -beforeEach(() => call.mockReset()); - -describe('emergencyApi', () => { - it('emergencyStop calls openhuman.emergency_stop with reason and unwraps envelope', async () => { - call.mockResolvedValue({ result: { engaged: true, reason: 'user' }, logs: ['x'] }); - const r = await emergencyStop('user'); - expect(call).toHaveBeenCalledWith({ - method: 'openhuman.emergency_stop', - params: { reason: 'user' }, - }); - expect(r.engaged).toBe(true); - expect(r.reason).toBe('user'); - }); - it('emergencyStop with no reason sends empty params', async () => { - call.mockResolvedValue({ result: { engaged: true }, logs: [] }); - await emergencyStop(); - expect(call).toHaveBeenCalledWith({ method: 'openhuman.emergency_stop', params: {} }); - }); - it('emergencyResume calls openhuman.emergency_resume', async () => { - call.mockResolvedValue({ result: { engaged: false }, logs: ['x'] }); - const r = await emergencyResume(); - expect(call).toHaveBeenCalledWith({ method: 'openhuman.emergency_resume', params: {} }); - expect(r.engaged).toBe(false); - }); - it('emergencyStatus reads bare value (no envelope)', async () => { - call.mockResolvedValue({ engaged: false }); - const r = await emergencyStatus(); - expect(call).toHaveBeenCalledWith({ method: 'openhuman.emergency_status', params: {} }); - expect(r.engaged).toBe(false); - }); -}); diff --git a/app/src/services/api/emergencyApi.ts b/app/src/services/api/emergencyApi.ts deleted file mode 100644 index 224f485b6b..0000000000 --- a/app/src/services/api/emergencyApi.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { HaltState } from '../../store/safetySlice'; -import { callCoreRpc } from '../coreRpcClient'; - -/** Normalize the CLI envelope `{ result, logs }` and bare-value shapes. */ -const unwrapValue = (raw: unknown): T => { - if (raw && typeof raw === 'object' && 'result' in (raw as Record)) { - return (raw as { result: T }).result; - } - return raw as T; -}; - -export async function emergencyStop(reason?: string): Promise { - console.debug('[emergency] rpc → openhuman.emergency_stop', { reason: reason ?? 'none' }); - const raw = await callCoreRpc({ - method: 'openhuman.emergency_stop', - params: reason ? { reason } : {}, - }); - return unwrapValue(raw); -} - -export async function emergencyResume(): Promise { - console.debug('[emergency] rpc → openhuman.emergency_resume'); - const raw = await callCoreRpc({ method: 'openhuman.emergency_resume', params: {} }); - return unwrapValue(raw); -} - -export async function emergencyStatus(): Promise { - console.debug('[emergency] rpc → openhuman.emergency_status'); - const raw = await callCoreRpc({ method: 'openhuman.emergency_status', params: {} }); - return unwrapValue(raw); -} diff --git a/app/src/services/safety/hydrateEmergencyState.test.ts b/app/src/services/safety/hydrateEmergencyState.test.ts deleted file mode 100644 index 4ad2b6b9c1..0000000000 --- a/app/src/services/safety/hydrateEmergencyState.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { hydrateEmergencyState } from './hydrateEmergencyState'; - -// Mock emergencyApi before importing the module under test -const emergencyStatusMock = vi.fn(); -vi.mock('../api/emergencyApi', () => ({ emergencyStatus: () => emergencyStatusMock() })); - -// Mock hydrateHalt action creator -const hydrateHaltMock = vi.fn((x: unknown) => ({ type: 'safety/hydrateHalt', payload: x })); -vi.mock('../../store/safetySlice', () => ({ hydrateHalt: (x: unknown) => hydrateHaltMock(x) })); - -describe('hydrateEmergencyState', () => { - const dispatch = vi.fn(); - - beforeEach(() => { - dispatch.mockClear(); - emergencyStatusMock.mockReset(); - hydrateHaltMock.mockClear(); - }); - - it('dispatches hydrateHalt with the result of emergencyStatus on success', async () => { - const status = { engaged: true, reason: 'cli', source: 'cli', engaged_at_ms: 12345 }; - emergencyStatusMock.mockResolvedValue(status); - - await hydrateEmergencyState(dispatch); - - expect(hydrateHaltMock).toHaveBeenCalledWith(status); - expect(dispatch).toHaveBeenCalledWith({ type: 'safety/hydrateHalt', payload: status }); - }); - - it('dispatches hydrateHalt when halt is not engaged', async () => { - const status = { engaged: false }; - emergencyStatusMock.mockResolvedValue(status); - - await hydrateEmergencyState(dispatch); - - expect(hydrateHaltMock).toHaveBeenCalledWith(status); - expect(dispatch).toHaveBeenCalledTimes(1); - }); - - it('swallows errors from emergencyStatus and does not dispatch', async () => { - emergencyStatusMock.mockRejectedValue(new Error('core unavailable')); - - // Must not throw - await expect(hydrateEmergencyState(dispatch)).resolves.toBeUndefined(); - expect(dispatch).not.toHaveBeenCalled(); - }); -}); diff --git a/app/src/services/safety/hydrateEmergencyState.ts b/app/src/services/safety/hydrateEmergencyState.ts deleted file mode 100644 index 3429746192..0000000000 --- a/app/src/services/safety/hydrateEmergencyState.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Dispatch } from '@reduxjs/toolkit'; - -import { hydrateHalt } from '../../store/safetySlice'; -import { emergencyStatus } from '../api/emergencyApi'; - -/** - * Fetches the authoritative halt state from the core and dispatches - * `hydrateHalt` into the Redux store. Errors are caught and logged so a - * degraded core never crashes the boot path. - * - * Extracted from AppShellDesktop's boot-hydration effect so it can be - * unit-tested in isolation without rendering the full component tree. - */ -export async function hydrateEmergencyState(dispatch: Dispatch): Promise { - try { - const status = await emergencyStatus(); - dispatch(hydrateHalt(status)); - } catch (err) { - console.warn('[emergency] status hydration failed', err); - } -} diff --git a/app/src/services/socketService.ts b/app/src/services/socketService.ts index f82ec9090f..4097563bae 100644 --- a/app/src/services/socketService.ts +++ b/app/src/services/socketService.ts @@ -17,7 +17,6 @@ import { import { upsertChannelConnection } from '../store/channelConnectionsSlice'; import { type CompanionStateChangedEvent, setCompanionState } from '../store/companionSlice'; import { setBackend } from '../store/connectivitySlice'; -import { clearHalt, setHalt } from '../store/safetySlice'; import { resetForUser, setSocketIdForUser, setStatusForUser } from '../store/socketSlice'; import type { ChannelAuthMode, ChannelConnectionStatus, ChannelType } from '../types/channels'; import { IS_DEV } from '../utils/config'; @@ -464,46 +463,6 @@ class SocketService { }); }); - // Automation halt/resume broadcasts — core publishes this when emergency_stop - // or emergency_resume is called from any client (UI, CLI, cron) so all - // connected surfaces reflect the halt state without polling. - this.socket.on('automation_halt', (data: unknown) => { - const obj = data as Record | null; - if (!obj || typeof obj !== 'object') { - socketWarn('automation_halt dropped — invalid payload shape'); - return; - } - // Halt fields ride under `args` in the `WebChannelEvent` envelope - // (same contract as `approval_request`; see `event_bus.rs` builder and - // `emit_web_channel_event` in `src/core/socketio.rs`, which does - // `serde_json::to_value(event)` on the whole envelope). Fall back to - // the top level so a direct-emit test payload keeps working. - const payload = - obj.args && typeof obj.args === 'object' ? (obj.args as Record) : obj; - // Fail closed: a kill-switch event must carry an explicit boolean - // `engaged`. An ambiguous payload (missing/non-boolean flag, e.g. `{}` or - // `{reason:'x'}`) is dropped rather than treated as `false`, so a - // malformed broadcast can never silently clear an active halt. - if (typeof payload.engaged !== 'boolean') { - socketWarn('automation_halt dropped — missing/invalid engaged flag'); - return; - } - const engaged = payload.engaged; - const reason = typeof payload.reason === 'string' ? payload.reason : undefined; - const source = typeof payload.source === 'string' ? payload.source : undefined; - socketLog( - 'automation_halt engaged=%s reason=%s source=%s', - engaged, - reason ?? 'none', - source ?? 'none' - ); - if (engaged) { - store.dispatch(setHalt({ reason, source })); - } else { - store.dispatch(clearHalt()); - } - }); - // Backend Meet bot events — forwarded from core's DomainEvent bus this.socket.on('agent_meetings:joined', (data: unknown) => { const obj = data as Record | null; diff --git a/app/src/store/index.ts b/app/src/store/index.ts index 1a7b4435a6..b41171943a 100644 --- a/app/src/store/index.ts +++ b/app/src/store/index.ts @@ -34,7 +34,6 @@ import notificationReducer from './notificationSlice'; import personaReducer from './personaSlice'; import providerSurfacesReducer from './providerSurfaceSlice'; import { pttReducer } from './pttSlice'; -import safetyReducer from './safetySlice'; import socketReducer from './socketSlice'; import themeReducer from './themeSlice'; import threadReducer from './threadSlice'; @@ -245,7 +244,6 @@ export const store = configureStore({ // completion, resets on restart + user switch. Durable storage is a #3931 // follow-up. userErrors: userErrorsReducer, - safety: safetyReducer, }, middleware: getDefaultMiddleware => { const middleware = getDefaultMiddleware({ diff --git a/app/src/store/safetySlice.test.ts b/app/src/store/safetySlice.test.ts deleted file mode 100644 index bcf652650e..0000000000 --- a/app/src/store/safetySlice.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import reducer, { clearHalt, hydrateHalt, setHalt } from './safetySlice'; - -describe('safetySlice', () => { - it('starts not halted', () => { - expect(reducer(undefined, { type: '@@init' })).toEqual({ halted: false }); - }); - it('setHalt marks halted with reason/source/since', () => { - const s = reducer(undefined, setHalt({ reason: 'user', source: 'user', since: 42 })); - expect(s).toEqual({ halted: true, reason: 'user', source: 'user', since: 42 }); - }); - it('clearHalt resets', () => { - const halted = reducer(undefined, setHalt({ reason: 'x' })); - expect(reducer(halted, clearHalt())).toEqual({ halted: false }); - }); - it('hydrateHalt maps a HaltState snapshot', () => { - const s = reducer( - undefined, - hydrateHalt({ engaged: true, reason: 'boot', engaged_at_ms: 7, source: 'system' }) - ); - expect(s.halted).toBe(true); - expect(s.reason).toBe('boot'); - expect(s.since).toBe(7); - }); -}); diff --git a/app/src/store/safetySlice.ts b/app/src/store/safetySlice.ts deleted file mode 100644 index 29f8d437dd..0000000000 --- a/app/src/store/safetySlice.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; - -export interface HaltState { - engaged: boolean; - reason?: string; - engaged_at_ms?: number; - source?: string; -} - -export interface SafetyState { - halted: boolean; - reason?: string; - since?: number; - source?: string; -} - -const initialState: SafetyState = { halted: false }; - -const safetySlice = createSlice({ - name: 'safety', - initialState, - reducers: { - setHalt(_state, action: PayloadAction<{ reason?: string; source?: string; since?: number }>) { - return { - halted: true, - reason: action.payload.reason, - source: action.payload.source, - since: action.payload.since, - }; - }, - clearHalt() { - return { halted: false }; - }, - hydrateHalt(_state, action: PayloadAction) { - const h = action.payload; - return h.engaged - ? { halted: true, reason: h.reason, source: h.source, since: h.engaged_at_ms } - : { halted: false }; - }, - }, -}); - -export const { setHalt, clearHalt, hydrateHalt } = safetySlice.actions; -// Defensive reads: some App-shell tests mock the store with a partial state that -// omits the `safety` slice. Optional chaining keeps the kill-switch UI from -// crashing the shell in that case (halted → false, no banner). -export const selectHalted = (state: { safety?: SafetyState }) => state.safety?.halted ?? false; -export const selectHaltReason = (state: { safety?: SafetyState }) => state.safety?.reason; -export default safetySlice.reducer; diff --git a/app/src/test/test-utils.tsx b/app/src/test/test-utils.tsx index b3667becf5..beda189643 100644 --- a/app/src/test/test-utils.tsx +++ b/app/src/test/test-utils.tsx @@ -25,7 +25,6 @@ import mascotReducer from '../store/mascotSlice'; import notificationReducer from '../store/notificationSlice'; import personaReducer from '../store/personaSlice'; import { pttReducer } from '../store/pttSlice'; -import safetyReducer from '../store/safetySlice'; import socketReducer from '../store/socketSlice'; import themeReducer from '../store/themeSlice'; import threadReducer from '../store/threadSlice'; @@ -55,7 +54,6 @@ const testRootReducer = combineReducers({ notifications: notificationReducer, persona: personaReducer, ptt: pttReducer, - safety: safetyReducer, socket: socketReducer, theme: themeReducer, thread: threadReducer, diff --git a/docs/superpowers/plans/2026-07-06-emergency-stop.md b/docs/superpowers/plans/2026-07-06-emergency-stop.md deleted file mode 100644 index ce629ba8d8..0000000000 --- a/docs/superpowers/plans/2026-07-06-emergency-stop.md +++ /dev/null @@ -1,1358 +0,0 @@ -# Emergency Stop Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** A fail-closed "Emergency Stop" kill switch that instantly halts all running/queued desktop automation and blocks any further automated action until the user explicitly resumes. - -**Architecture:** A new process-global `EmergencyStop` singleton in the Rust core (`src/openhuman/emergency_stop/`), mirroring the `ApprovalGate` `OnceLock` pattern. Three RPCs (`openhuman.emergency_stop|resume|status`) engage/clear/read it. Two fail-closed enforcement chokepoints consult it: the tinyagents approval middleware (blocks external-effect tool calls) and `accessibility_input_action` (blocks clicks/typing). Engaging also stops the accessibility session and cascade-denies pending approvals. The React app gets a persistent Emergency Stop button + halted banner backed by a Redux `safetySlice`, driven by the RPC responses and boot-time hydration. - -**Tech Stack:** Rust (tokio, serde, `anyhow`), JSON-RPC controller registry, React 19 + TypeScript + Redux Toolkit + Vitest, i18n via `useT()`. - -**Spec:** `docs/superpowers/specs/2026-07-06-emergency-stop-design.md` - -## Global Constraints - -- **Never write code on `main`.** Work is on branch `feat/desktop-safety-4255` (already created). -- **Fail-closed:** when the switch is installed AND engaged, block. When no switch is installed (`try_global()` → `None`, e.g. CLI/headless), never block. -- **Diff coverage ≥ 80% on changed lines** (merge gate: `frontend-coverage`/`rust-core-coverage`). -- **Rust module shape** (AGENTS.md): `mod.rs` export-only; `types.rs` serde types; `state.rs` state; `ops.rs` logic returning `RpcOutcome`; `schemas.rs` controllers. New functionality → dedicated subdirectory; no new root-level `*.rs`. -- **RPC naming:** `openhuman._` — here namespace `emergency`, functions `stop`/`resume`/`status`. -- **Controller exposure:** register via `src/core/all.rs` registry, not branches in `cli.rs`/`jsonrpc.rs`. -- **i18n:** all UI text through `useT()`; add keys to `app/src/lib/i18n/en.ts` **and** real translations in every sibling locale file (`app/src/lib/i18n/.ts` for `ar, bn, de, es, fr, hi, id, it, ko, pl, pt, ru, zh-CN`). CI enforces parity (`pnpm i18n:check`). -- **Debug logging:** grep-friendly prefixes (`[emergency]`, `[rpc:emergency_*]`); log entry/exit, state transitions, errors; never log secrets/PII. -- **Frontend:** no dynamic imports in `app/src`; use `invoke('core_rpc_relay', …)` via `coreRpcClient`; guard Tauri with `isTauri()`/try-catch. -- **Rust checks:** `cargo check --manifest-path Cargo.toml` (add `GGML_NATIVE=OFF` on macOS Apple Silicon). Tests: `pnpm test:rust` or `bash scripts/test-rust-with-mock.sh --test `; targeted lib tests: `cargo test --manifest-path Cargo.toml `. -- **Frontend checks:** `pnpm typecheck`, `pnpm lint`, `pnpm test`. - ---- - -## File Structure - -**Rust core (new domain `src/openhuman/emergency_stop/`):** -- `mod.rs` — module docstring, `pub mod` decls, `pub use` re-exports, controller-schema pair. -- `types.rs` — `HaltState`, `HaltSource` (serde). -- `state.rs` — `EmergencyStop` global singleton (`OnceLock`), `init_global`/`try_global`/`is_engaged`/`engage`/`clear`/`snapshot`. -- `ops.rs` — `emergency_stop`/`emergency_resume`/`emergency_status` returning `RpcOutcome`; cascade-deny + a11y stop; publishes events. -- `schemas.rs` — controller schemas + `handle_*` fns. - -**Rust core (modified):** -- `src/core/event_bus/events.rs` — add `AutomationHalted`/`AutomationResumed` variants + `domain()` + `name()` arms. -- `src/core/all.rs` — register emergency controllers. -- `src/core/jsonrpc.rs` — install `EmergencyStop::init_global()` at boot; register socket bridge subscriber. -- `src/openhuman/tinyagents/middleware.rs` — halt check in `ApprovalSecurityMiddleware::wrap_tool`. -- `src/openhuman/screen_intelligence/ops.rs` — halt check in `accessibility_input_action`. -- `src/openhuman/channels/providers/web/event_bus.rs` — `AutomationHaltSubscriber` bridging events → `automation_halt` socket event. -- `tests/json_rpc_e2e.rs` — stop→status→resume E2E. - -**Frontend (new):** -- `app/src/store/safetySlice.ts` (+ `safetySlice.test.ts`) — halted state. -- `app/src/services/api/emergencyApi.ts` (+ `emergencyApi.test.ts`) — RPC client. -- `app/src/components/safety/EmergencyStopButton.tsx` (+ test) — button. -- `app/src/components/safety/AutomationHaltedBanner.tsx` (+ test) — banner + Resume. - -**Frontend (modified):** -- `app/src/store/index.ts` (or root reducer) — mount `safety` reducer. -- `app/src/services/socketService.ts` — handle `automation_halt` socket event. -- app shell/header (e.g. `app/src/components/layout/*` or `Conversations` header) — mount button + banner + boot hydration. -- `app/src/lib/i18n/locales/*.ts` — i18n keys. - -**Note on exact neighboring types:** three field-shapes are already confirmed from the codebase — `InputActionResult { accepted: bool, blocked: bool, reason: Option }` (`screen_intelligence/types.rs:144`), `InputActionParams { action: String, .. }` (`types.rs:133`), and the `WebChannelEvent` bridge pattern (`web/event_bus.rs`). Before Task 12's socket bridge, read `src/core/socketio` for the exact `WebChannelEvent` fields (the artifact/approval bridges set `event`, `client_id`, `thread_id`, `args`, `..Default::default()`). - ---- - -## Task 1: Event variants — `AutomationHalted` / `AutomationResumed` - -**Files:** -- Modify: `src/core/event_bus/events.rs` (add variants near the System lifecycle group ~line 1025; extend `domain()` ~1283 and `name()` ~1540) - -**Interfaces:** -- Produces: `DomainEvent::AutomationHalted { reason: Option, source: String }`, `DomainEvent::AutomationResumed { source: String }`. Both map to domain `"system"`. - -- [ ] **Step 1: Add the two variants.** In the `DomainEvent` enum, in the System-lifecycle region, add: - -```rust - /// Emergency stop engaged — all desktop automation is halted and every - /// external-effect / accessibility action is refused until resumed. - /// Published by `emergency_stop::ops::emergency_stop`; bridged to the - /// `automation_halt` web-channel socket event. - AutomationHalted { - /// Optional human-readable reason (redacted of PII by the caller). - reason: Option, - /// Who engaged it: `"user"`, `"hotkey"`, or `"system"`. - source: String, - }, - /// Emergency stop cleared — automation may resume. Published by - /// `emergency_stop::ops::emergency_resume`. - AutomationResumed { - /// Who cleared it: `"user"`, `"hotkey"`, or `"system"`. - source: String, - }, -``` - -- [ ] **Step 2: Extend `domain()`.** In the `pub fn domain(&self)` match, add to the `"system"` arm (alongside `HarnessInitCompleted`): - -```rust - | Self::AutomationHalted { .. } - | Self::AutomationResumed { .. } => "system", -``` - -- [ ] **Step 3: Extend `name()`.** In the `name()` match (near the `ApprovalRequested => "ApprovalRequested"` arms): - -```rust - Self::AutomationHalted { .. } => "AutomationHalted", - Self::AutomationResumed { .. } => "AutomationResumed", -``` - -- [ ] **Step 4: Add a unit test.** Append to the `#[cfg(test)]` module in `events.rs` (or create one if none — match the file's existing test style): - -```rust - #[test] - fn automation_events_map_to_system_domain() { - let halted = DomainEvent::AutomationHalted { reason: Some("user".into()), source: "user".into() }; - let resumed = DomainEvent::AutomationResumed { source: "user".into() }; - assert_eq!(halted.domain(), "system"); - assert_eq!(resumed.domain(), "system"); - assert_eq!(halted.name(), "AutomationHalted"); - assert_eq!(resumed.name(), "AutomationResumed"); - } -``` - -- [ ] **Step 5: Compile + test.** - -Run: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman automation_events_map_to_system_domain` -Expected: PASS (build succeeds, 1 test passes). If `name()`/`domain()` have exhaustive-match compile errors, fix the arms until it builds. - -- [ ] **Step 6: Commit.** - -```bash -git add src/core/event_bus/events.rs -git commit -m "feat(events): add AutomationHalted/AutomationResumed domain events (#4255)" -``` - ---- - -## Task 2: `emergency_stop` types - -**Files:** -- Create: `src/openhuman/emergency_stop/types.rs` - -**Interfaces:** -- Produces: `HaltState { engaged: bool, reason: Option, engaged_at_ms: Option, source: Option }` (serde, `Clone`, `Debug`, `PartialEq`, `Default`). Used by `state.rs`, `ops.rs`, `schemas.rs`. - -- [ ] **Step 1: Write the failing test.** Create `src/openhuman/emergency_stop/types.rs`: - -```rust -//! Serde domain types for the emergency-stop kill switch. - -use serde::{Deserialize, Serialize}; - -/// Snapshot of the emergency-stop switch, returned by every emergency RPC and -/// surfaced in the UI. `engaged == false` is the resting state. -#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] -pub struct HaltState { - /// Whether automation is currently halted. - pub engaged: bool, - /// Human-readable reason for the halt (redacted of PII), when engaged. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reason: Option, - /// Unix-epoch milliseconds when the halt was engaged, when engaged. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub engaged_at_ms: Option, - /// Who engaged it: `"user"`, `"hotkey"`, or `"system"`, when engaged. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source: Option, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_halt_state_is_not_engaged() { - let s = HaltState::default(); - assert!(!s.engaged); - assert!(s.reason.is_none()); - assert!(s.engaged_at_ms.is_none()); - } - - #[test] - fn resting_state_serializes_to_engaged_false_only() { - let json = serde_json::to_string(&HaltState::default()).unwrap(); - assert_eq!(json, r#"{"engaged":false}"#); - } - - #[test] - fn engaged_state_roundtrips() { - let s = HaltState { engaged: true, reason: Some("user".into()), engaged_at_ms: Some(42), source: Some("user".into()) }; - let back: HaltState = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap(); - assert_eq!(s, back); - } -} -``` - -- [ ] **Step 2: Run to verify it fails to build.** (Module not declared yet — see Task 4 wires `mod.rs`; for now this task's test runs once `mod.rs` exists. To keep TDD honest, do Task 3 & the `mod.rs` skeleton, then run.) Run after Task 4: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman emergency_stop::types` -Expected (before impl wired): FAIL to compile ("file not found for module" / unresolved). - -- [ ] **Step 3: (impl already written in Step 1).** - -- [ ] **Step 4: Run after `mod.rs` exists (Task 4).** Expected: 3 tests PASS. - -- [ ] **Step 5: Commit** (batched with Task 3–4, since the module must be wired to compile). - ---- - -## Task 3: `emergency_stop` state (global singleton) - -**Files:** -- Create: `src/openhuman/emergency_stop/state.rs` - -**Interfaces:** -- Consumes: `HaltState` (Task 2). -- Produces: `EmergencyStop` with associated fns `init_global() -> Arc`, `try_global() -> Option>`, and methods `is_engaged(&self) -> bool`, `engage(&self, reason: Option, source: &str, now_ms: u64)`, `clear(&self)`, `snapshot(&self) -> HaltState`. Free fn `is_engaged_global() -> bool` (false when no switch installed). - -- [ ] **Step 1: Write state + tests.** Create `src/openhuman/emergency_stop/state.rs`: - -```rust -//! Process-global emergency-stop switch. Mirrors the `ApprovalGate` -//! `OnceLock` install pattern: `init_global` is idempotent, `try_global` -//! returns `None` when never installed (CLI/headless → never blocks). - -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, OnceLock}; - -use super::types::HaltState; - -static GLOBAL_STOP: OnceLock> = OnceLock::new(); - -#[derive(Debug)] -struct HaltInfo { - reason: Option, - engaged_at_ms: u64, - source: String, -} - -/// Coordinator for the emergency-stop kill switch. -#[derive(Debug)] -pub struct EmergencyStop { - engaged: AtomicBool, - info: Mutex>, -} - -impl EmergencyStop { - /// Install the process-global switch. Idempotent — re-install returns the - /// existing switch so repeated boots in tests don't panic. - pub fn init_global() -> Arc { - if let Some(existing) = GLOBAL_STOP.get() { - return existing.clone(); - } - let stop = Arc::new(EmergencyStop { engaged: AtomicBool::new(false), info: Mutex::new(None) }); - let _ = GLOBAL_STOP.set(stop.clone()); - GLOBAL_STOP.get().cloned().unwrap_or(stop) - } - - /// The global switch when installed; `None` means "no switch" → callers - /// treat as not-engaged (never block). - pub fn try_global() -> Option> { - GLOBAL_STOP.get().cloned() - } - - /// Whether automation is currently halted. - pub fn is_engaged(&self) -> bool { - self.engaged.load(Ordering::SeqCst) - } - - /// Engage the halt. Idempotent — re-engaging refreshes reason/source/time. - pub fn engage(&self, reason: Option, source: &str, now_ms: u64) { - { - let mut guard = self.info.lock().unwrap_or_else(|e| e.into_inner()); - *guard = Some(HaltInfo { reason, engaged_at_ms: now_ms, source: source.to_string() }); - } - self.engaged.store(true, Ordering::SeqCst); - } - - /// Clear the halt. Idempotent. - pub fn clear(&self) { - self.engaged.store(false, Ordering::SeqCst); - let mut guard = self.info.lock().unwrap_or_else(|e| e.into_inner()); - *guard = None; - } - - /// Current snapshot for RPC/UI. - pub fn snapshot(&self) -> HaltState { - if !self.is_engaged() { - return HaltState::default(); - } - let guard = self.info.lock().unwrap_or_else(|e| e.into_inner()); - match guard.as_ref() { - Some(info) => HaltState { - engaged: true, - reason: info.reason.clone(), - engaged_at_ms: Some(info.engaged_at_ms), - source: Some(info.source.clone()), - }, - None => HaltState { engaged: true, ..Default::default() }, - } - } -} - -/// Global convenience: is a switch installed AND engaged? False when no -/// switch is installed (CLI/headless) so those paths are never blocked. -pub fn is_engaged_global() -> bool { - EmergencyStop::try_global().map(|s| s.is_engaged()).unwrap_or(false) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn engage_then_snapshot_reports_engaged() { - let stop = EmergencyStop { engaged: AtomicBool::new(false), info: Mutex::new(None) }; - assert!(!stop.is_engaged()); - stop.engage(Some("user".into()), "user", 1234); - assert!(stop.is_engaged()); - let snap = stop.snapshot(); - assert!(snap.engaged); - assert_eq!(snap.reason.as_deref(), Some("user")); - assert_eq!(snap.engaged_at_ms, Some(1234)); - assert_eq!(snap.source.as_deref(), Some("user")); - } - - #[test] - fn clear_resets_to_default_snapshot() { - let stop = EmergencyStop { engaged: AtomicBool::new(false), info: Mutex::new(None) }; - stop.engage(None, "hotkey", 1); - stop.clear(); - assert!(!stop.is_engaged()); - assert_eq!(stop.snapshot(), HaltState::default()); - } - - #[test] - fn engage_is_idempotent_and_refreshes() { - let stop = EmergencyStop { engaged: AtomicBool::new(false), info: Mutex::new(None) }; - stop.engage(Some("a".into()), "user", 1); - stop.engage(Some("b".into()), "system", 2); - assert!(stop.is_engaged()); - assert_eq!(stop.snapshot().reason.as_deref(), Some("b")); - assert_eq!(stop.snapshot().source.as_deref(), Some("system")); - } -} -``` - -- [ ] **Step 2 & 3:** impl is in Step 1. -- [ ] **Step 4: Run after `mod.rs` (Task 4).** Expected: 3 tests PASS. -- [ ] **Step 5: Commit** (batched with Task 4). - ---- - -## Task 4: `emergency_stop` mod.rs + wire the module tree (makes Tasks 2–3 compile) - -**Files:** -- Create: `src/openhuman/emergency_stop/mod.rs` -- Modify: `src/openhuman/mod.rs` (add `pub mod emergency_stop;` in the domain list, alphabetically near `embeddings`/`encryption`) - -**Interfaces:** -- Produces: `pub use` of `EmergencyStop`, `is_engaged_global`, `HaltState`; and the controller-schema pair `all_emergency_controller_schemas`, `all_emergency_registered_controllers` (defined in Task 6's `schemas.rs`). - -- [ ] **Step 1: Create `mod.rs`** (schemas referenced here are added in Task 6; declare the module now, add the re-exports in Task 6): - -```rust -//! Emergency stop — a fail-closed kill switch for desktop automation. -//! -//! `EmergencyStop` is a process-global switch (mirrors `ApprovalGate`). When -//! engaged, the tinyagents approval middleware refuses external-effect tool -//! calls and `accessibility_input_action` refuses clicks/typing, until the -//! user resumes. Engaging also stops the accessibility session and -//! cascade-denies pending approvals. In-memory only (resets on restart). - -pub mod ops; -pub mod schemas; -pub mod state; -pub mod types; - -pub use schemas::{all_emergency_controller_schemas, all_emergency_registered_controllers}; -pub use state::{is_engaged_global, EmergencyStop}; -pub use types::HaltState; -``` - -- [ ] **Step 2: Register the domain module.** In `src/openhuman/mod.rs`, add (alphabetical): - -```rust -pub mod emergency_stop; -``` - -- [ ] **Step 3: Build.** After Task 5 (`ops.rs`) and Task 6 (`schemas.rs`) exist, run: - -Run: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman emergency_stop::` -Expected: all `types`, `state` tests PASS (ops/schemas tests added in their tasks). - -- [ ] **Step 4: Commit** (batched: types + state + mod once ops/schemas compile). - -```bash -git add src/openhuman/emergency_stop/ src/openhuman/mod.rs -git commit -m "feat(emergency): halt-state types + global switch singleton (#4255)" -``` - ---- - -## Task 5: `emergency_stop` ops (engage/resume/status + side effects + events) - -**Files:** -- Create: `src/openhuman/emergency_stop/ops.rs` - -**Interfaces:** -- Consumes: `EmergencyStop` (Task 3), `HaltState` (Task 2), `DomainEvent::AutomationHalted/Resumed` (Task 1), `ApprovalGate` (`list_pending`, `decide`), `screen_intelligence::global_engine().disable(reason)`. -- Produces: `pub async fn emergency_stop(reason: Option, source: &str) -> RpcOutcome`; `pub async fn emergency_resume(source: &str) -> RpcOutcome`; `pub async fn emergency_status() -> RpcOutcome`. - -- [ ] **Step 1: Write ops + tests.** Create `src/openhuman/emergency_stop/ops.rs`: - -```rust -//! Emergency-stop RPC operations: engage / resume / read the switch, plus the -//! best-effort side effects (stop the a11y session, cascade-deny pending -//! approvals) and event publication. - -use std::time::{SystemTime, UNIX_EPOCH}; - -use crate::core::event_bus::{publish_global, DomainEvent}; -use crate::rpc::RpcOutcome; - -use super::state::EmergencyStop; -use super::types::HaltState; - -fn now_ms() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0) -} - -/// Engage the kill switch: set the flag, then best-effort stop the a11y -/// session and cascade-deny pending approvals, then publish `AutomationHalted`. -/// Idempotent. Side-effect failures are logged but never fail the RPC — the -/// primary invariant (flag set → actions blocked) does not depend on them. -pub async fn emergency_stop(reason: Option, source: &str) -> RpcOutcome { - tracing::warn!(source, reason = ?reason, "[rpc:emergency_stop] entry — engaging kill switch"); - let stop = EmergencyStop::init_global(); - stop.engage(reason.clone(), source, now_ms()); - - // Best-effort: stop the accessibility session so any in-flight click/type loop halts. - // CONFIRMED: `engine.disable(reason: Option) -> SessionStatus` (engine.rs:150); - // `SessionStatus.active: bool` (types.rs:15). - let a11y = crate::openhuman::screen_intelligence::global_engine() - .disable(Some("emergency_stop".to_string())) - .await; - tracing::info!(active = a11y.active, "[emergency] accessibility session stopped"); - - // Best-effort: cascade-deny every pending approval so parked tool calls fail closed. - let denied = cascade_deny_pending(); - tracing::info!(denied, "[emergency] cascade-denied pending approvals"); - - publish_global(DomainEvent::AutomationHalted { reason, source: source.to_string() }); - - let snap = stop.snapshot(); - RpcOutcome::single_log(snap, format!("[emergency] halted (source={source}, denied={denied})")) -} - -/// Deny all pending approvals. Returns how many were denied. Best-effort: -/// a per-row error is logged and skipped. -fn cascade_deny_pending() -> usize { - use crate::openhuman::approval::{ApprovalDecision, ApprovalGate}; - let Some(gate) = ApprovalGate::try_global() else { return 0 }; - let rows = match gate.list_pending() { - Ok(rows) => rows, - Err(err) => { - tracing::warn!(error = %err, "[emergency] list_pending failed during cascade-deny"); - return 0; - } - }; - let mut denied = 0; - for row in rows { - match gate.decide(&row.request_id, ApprovalDecision::Deny) { - Ok(_) => denied += 1, - Err(err) => tracing::warn!(request_id = %row.request_id, error = %err, "[emergency] deny failed"), - } - } - denied -} - -/// Clear the kill switch and publish `AutomationResumed`. Idempotent. -pub async fn emergency_resume(source: &str) -> RpcOutcome { - tracing::info!(source, "[rpc:emergency_resume] entry — clearing kill switch"); - let stop = EmergencyStop::init_global(); - stop.clear(); - publish_global(DomainEvent::AutomationResumed { source: source.to_string() }); - RpcOutcome::single_log(stop.snapshot(), format!("[emergency] resumed (source={source})")) -} - -/// Read the current switch state. -pub async fn emergency_status() -> RpcOutcome { - let snap = EmergencyStop::try_global().map(|s| s.snapshot()).unwrap_or_default(); - tracing::debug!(engaged = snap.engaged, "[rpc:emergency_status] exit"); - RpcOutcome::new(snap, vec![]) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn stop_sets_flag_and_status_reports_engaged() { - let out = emergency_stop(Some("user".into()), "user").await; - assert!(out.value.engaged); - let status = emergency_status().await; - assert!(status.value.engaged); - assert_eq!(status.value.source.as_deref(), Some("user")); - // reset for other tests sharing the process-global switch - let _ = emergency_resume("user").await; - } - - #[tokio::test] - async fn resume_clears_flag() { - let _ = emergency_stop(None, "user").await; - let out = emergency_resume("user").await; - assert!(!out.value.engaged); - assert!(!emergency_status().await.value.engaged); - } - - #[tokio::test] - async fn stop_is_idempotent() { - let _ = emergency_stop(Some("a".into()), "user").await; - let out = emergency_stop(Some("b".into()), "system").await; - assert!(out.value.engaged); - assert_eq!(out.value.reason.as_deref(), Some("b")); - let _ = emergency_resume("user").await; - } -} -``` - -Notes for the implementer: -- Confirm `RpcOutcome` exposes `.value` (the tests read `out.value`). If the field is named differently, read `src/rpc/*` for `RpcOutcome`'s public shape and adjust the test accessors (the `ops.rs` code uses only the constructors `RpcOutcome::new` / `RpcOutcome::single_log`, already used across the codebase). -- Confirm `ApprovalGate`, `ApprovalDecision` are re-exported from `crate::openhuman::approval` (README lists both under "Public surface"). `ApprovalDecision::Deny` is the deny variant. -- Confirm `global_engine().disable(reason)` returns a `SessionStatus` with an `active` field (seen in `ops.rs:121` `accessibility_stop_session`). - -- [ ] **Step 2: Run to verify it fails first (before ops wired into mod).** After `mod.rs` includes `pub mod ops;` (Task 4), run: - -Run: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman emergency_stop::ops` -Expected: FAIL first if any signature mismatch; iterate until it compiles. - -- [ ] **Step 3: Fix compile issues** (RpcOutcome field/accessor names, imports) until green. - -- [ ] **Step 4: Run tests.** Expected: 3 ops tests PASS. - -- [ ] **Step 5: Commit.** - -```bash -git add src/openhuman/emergency_stop/ops.rs -git commit -m "feat(emergency): engage/resume/status ops with cascade-deny + a11y stop (#4255)" -``` - ---- - -## Task 6: `emergency_stop` schemas + registry wiring + boot install - -**Files:** -- Create: `src/openhuman/emergency_stop/schemas.rs` -- Modify: `src/openhuman/emergency_stop/mod.rs` (re-export already added in Task 4) -- Modify: `src/core/all.rs` (register controllers, near approval ~line 160) -- Modify: `src/core/jsonrpc.rs` (install `EmergencyStop::init_global()` next to `ApprovalGate::init_global` ~line 2672) - -**Interfaces:** -- Consumes: `ops` (Task 5), `HaltState` (Task 2). -- Produces: RPCs `emergency.stop`, `emergency.resume`, `emergency.status` (dispatched as `openhuman.emergency_stop|resume|status`); `all_emergency_controller_schemas()`, `all_emergency_registered_controllers()`. - -- [ ] **Step 1: Write `schemas.rs`** (mirrors `approval/schemas.rs`): - -```rust -//! Controller schemas + handlers for the `emergency` namespace. -//! Wires `emergency_stop`, `emergency_resume`, `emergency_status` into the -//! global registry consumed by `src/core/all.rs`. - -use serde_json::{Map, Value}; - -use crate::core::all::{ControllerFuture, RegisteredController}; -use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; - -use super::ops; - -pub fn all_emergency_controller_schemas() -> Vec { - vec![schemas("stop"), schemas("resume"), schemas("status")] -} - -pub fn all_emergency_registered_controllers() -> Vec { - vec![ - RegisteredController { schema: schemas("stop"), handler: handle_stop }, - RegisteredController { schema: schemas("resume"), handler: handle_resume }, - RegisteredController { schema: schemas("status"), handler: handle_status }, - ] -} - -pub fn schemas(function: &str) -> ControllerSchema { - match function { - "stop" => ControllerSchema { - namespace: "emergency", - function: "stop", - description: "Engage the emergency stop: halt all desktop automation and block further actions until resumed.", - inputs: vec![FieldSchema { - name: "reason", - ty: TypeSchema::Option(Box::new(TypeSchema::String)), - comment: "Optional human-readable reason for the halt.", - required: false, - }], - outputs: vec![FieldSchema { - name: "state", - ty: TypeSchema::Ref("HaltState"), - comment: "Switch snapshot after engaging.", - required: true, - }], - }, - "resume" => ControllerSchema { - namespace: "emergency", - function: "resume", - description: "Clear the emergency stop so automation may resume.", - inputs: vec![], - outputs: vec![FieldSchema { - name: "state", - ty: TypeSchema::Ref("HaltState"), - comment: "Switch snapshot after clearing.", - required: true, - }], - }, - "status" => ControllerSchema { - namespace: "emergency", - function: "status", - description: "Read the current emergency-stop switch state.", - inputs: vec![], - outputs: vec![FieldSchema { - name: "state", - ty: TypeSchema::Ref("HaltState"), - comment: "Current switch snapshot.", - required: true, - }], - }, - _ => ControllerSchema { - namespace: "emergency", - function: "unknown", - description: "Unknown emergency function.", - inputs: vec![], - outputs: vec![FieldSchema { name: "error", ty: TypeSchema::String, comment: "Schema not defined.", required: true }], - }, - } -} - -fn handle_stop(params: Map) -> ControllerFuture { - Box::pin(async move { - let reason = match params.get("reason") { - Some(Value::String(s)) => Some(s.clone()), - _ => None, - }; - Ok(serde_json::to_value(ops::emergency_stop(reason, "user").await.value).map_err(|e| e.to_string())?) - }) -} - -fn handle_resume(_params: Map) -> ControllerFuture { - Box::pin(async move { - Ok(serde_json::to_value(ops::emergency_resume("user").await.value).map_err(|e| e.to_string())?) - }) -} - -fn handle_status(_params: Map) -> ControllerFuture { - Box::pin(async move { - Ok(serde_json::to_value(ops::emergency_status().await.value).map_err(|e| e.to_string())?) - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn registered_controllers_match_schemas() { - let c = all_emergency_registered_controllers(); - assert_eq!(c.len(), 3); - let names: Vec<_> = c.iter().map(|c| c.schema.function).collect(); - assert_eq!(names, vec!["stop", "resume", "status"]); - } - - #[test] - fn stop_schema_has_optional_reason() { - let s = schemas("stop"); - assert_eq!(s.namespace, "emergency"); - assert_eq!(s.inputs[0].name, "reason"); - assert!(!s.inputs[0].required); - } -} -``` - -Notes for the implementer: -- The handler `to_json` pattern in `approval/schemas.rs` uses `outcome.into_cli_compatible_json()`. Prefer that exact helper for consistency: replace the `serde_json::to_value(...await.value)` lines with the approval pattern — call the op, then `outcome.into_cli_compatible_json()`. Read `approval/schemas.rs:201` (`to_json`) and copy it verbatim into this file, then `handle_* = to_json(ops::…().await)`. Adjust to whichever `RpcOutcome` serialization the registry expects (match approval exactly). -- `ControllerSchema`/`FieldSchema`/`TypeSchema`/`RegisteredController`/`ControllerFuture` imports mirror `approval/schemas.rs` lines 6–13. - -- [ ] **Step 2: Register in `src/core/all.rs`.** Next to the approval registration (`controllers.extend(crate::openhuman::approval::all_approval_registered_controllers());`), add: - -```rust - controllers.extend(crate::openhuman::emergency_stop::all_emergency_registered_controllers()); -``` - -Also add the schema list wherever approval's `all_controller_schemas` is aggregated (search `all_approval_registered_controllers`/`all_controller_schemas` usage in `all.rs` and mirror both). - -- [ ] **Step 3: Install at boot in `src/core/jsonrpc.rs`.** Next to `ApprovalGate::init_global(cfg.clone(), session_id.clone());` (~line 2672), add: - -```rust - crate::openhuman::emergency_stop::EmergencyStop::init_global(); -``` - -- [ ] **Step 4: Build + run schema tests.** - -Run: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman emergency_stop::schemas` -Expected: 2 tests PASS; whole crate compiles. - -- [ ] **Step 5: Commit.** - -```bash -git add src/openhuman/emergency_stop/schemas.rs src/openhuman/emergency_stop/mod.rs src/core/all.rs src/core/jsonrpc.rs -git commit -m "feat(emergency): RPC controllers (stop/resume/status) + boot install (#4255)" -``` - ---- - -## Task 7: Enforcement chokepoint 1 — approval middleware blocks while halted - -**Files:** -- Modify: `src/openhuman/tinyagents/middleware.rs` (`ApprovalSecurityMiddleware::wrap_tool`, ~line 934, inside the `if self.has_external_effect(...)` block, before `gate.intercept_audited`) - -**Interfaces:** -- Consumes: `crate::openhuman::emergency_stop::is_engaged_global`. - -- [ ] **Step 1: Add the halt check.** In `wrap_tool`, immediately inside `if self.has_external_effect(&call.name, &call.arguments) {`, before the `if let Some(gate) = ApprovalGate::try_global()` line, insert: - -```rust - // Emergency stop: refuse every external-effect tool while halted, - // before touching the approval gate. Fail-closed. - if crate::openhuman::emergency_stop::is_engaged_global() { - let reason = "Emergency stop is engaged — this action is blocked until you resume automation.".to_string(); - tracing::warn!(tool = %call.name, "[tinyagents::mw] emergency stop engaged — refusing tool call"); - return Ok(MiddlewareToolOutcome::Result(TaToolResult { - call_id: call.id, - name: call.name, - content: reason.clone(), - raw: None, - error: Some(reason), - elapsed_ms: 0, - })); - } -``` - -> **Audit-trail note (deferred):** the halt short-circuit returns immediately, so a refused call is NOT recorded through `ApprovalGate::intercept_audited` (which is what writes the "aborted" row for a denied external-effect call). This is a conscious scope choice for this slice — writing an `aborted` audit row from the middleware needs a new gate API (there is no such surface today), and the halted refusal is already surfaced via the `tracing::warn!` above plus the `AutomationHalted` domain event / `automation_halt` socket broadcast. Recording halted refusals in the approval audit trail is tracked as a follow-up; adjust either this step or the design spec's "audit trail" requirement once that follow-up lands. - -- [ ] **Step 2: Write a unit test.** In the `#[cfg(test)]` module of `middleware.rs` (or a sibling `middleware_tests.rs` if one exists — match the file's convention), add a test that engages the global switch and asserts a halted external-effect call short-circuits. If constructing a full `RunContext`/`ToolHandler` is heavy, instead add a focused test in `emergency_stop` that exercises `is_engaged_global()` transitions and document the middleware behavior via an integration assertion in Task 10's E2E. Minimum viable unit test (pure guard behavior): - -```rust - #[test] - fn emergency_guard_blocks_when_engaged() { - use crate::openhuman::emergency_stop::EmergencyStop; - let stop = EmergencyStop::init_global(); - stop.clear(); - assert!(!crate::openhuman::emergency_stop::is_engaged_global()); - stop.engage(Some("test".into()), "user", 0); - assert!(crate::openhuman::emergency_stop::is_engaged_global()); - stop.clear(); - } -``` - -- [ ] **Step 3: Build + test.** - -Run: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman emergency_guard_blocks_when_engaged` -Expected: PASS; `middleware.rs` compiles with the new guard. - -- [ ] **Step 4: Commit.** - -```bash -git add src/openhuman/tinyagents/middleware.rs -git commit -m "feat(emergency): approval middleware refuses external-effect tools while halted (#4255)" -``` - ---- - -## Task 8: Enforcement chokepoint 2 — accessibility input blocked while halted - -**Files:** -- Modify: `src/openhuman/screen_intelligence/ops.rs` (`accessibility_input_action`, ~line 152) - -**Interfaces:** -- Consumes: `is_engaged_global`; `InputActionResult { accepted, blocked, reason }`; `InputActionParams { action, .. }`. - -- [ ] **Step 1: Add the halt check.** Replace the body of `accessibility_input_action` with a guard that blocks while halted, except the `panic_stop` action (a stop must never be blocked by a stop): - -```rust -pub async fn accessibility_input_action( - payload: InputActionParams, -) -> Result, String> { - // Emergency stop: refuse desktop input while halted. `panic_stop` is - // exempt so a stop is never blocked by a stop. - if payload.action != "panic_stop" && crate::openhuman::emergency_stop::is_engaged_global() { - tracing::warn!(action = %payload.action, "[emergency] accessibility_input_action blocked — kill switch engaged"); - return Ok(RpcOutcome::single_log( - InputActionResult { accepted: false, blocked: true, reason: Some("emergency_stop".to_string()) }, - "screen intelligence input blocked by emergency stop", - )); - } - let result = screen_intelligence::global_engine() - .input_action(payload) - .await?; - Ok(RpcOutcome::single_log( - result, - "screen intelligence input action processed", - )) -} -``` - -- [ ] **Step 2: Write a unit test.** Add to the `#[cfg(test)] mod tests` in `screen_intelligence/ops.rs` (the file already has tests like `accessibility_stop_session_is_tolerant_of_no_reason`): - -```rust - #[tokio::test] - async fn input_action_blocked_while_emergency_engaged() { - use crate::openhuman::emergency_stop::EmergencyStop; - let stop = EmergencyStop::init_global(); - stop.engage(Some("test".into()), "user", 0); - let params = InputActionParams { action: "click".into(), x: Some(1), y: Some(1), button: None, text: None, key: None, modifiers: None }; - let out = accessibility_input_action(params).await.unwrap(); - assert!(!out.value.accepted); - assert!(out.value.blocked); - assert_eq!(out.value.reason.as_deref(), Some("emergency_stop")); - stop.clear(); - } - - #[tokio::test] - async fn panic_stop_passes_even_while_emergency_engaged() { - use crate::openhuman::emergency_stop::EmergencyStop; - let stop = EmergencyStop::init_global(); - stop.engage(None, "user", 0); - let params = InputActionParams { action: "panic_stop".into(), x: None, y: None, button: None, text: None, key: None, modifiers: None }; - // Should not be short-circuited by the emergency guard (reaches the engine). - let _ = accessibility_input_action(params).await; - stop.clear(); - } -``` - -(Confirm `out.value` accessor matches `RpcOutcome`'s public field, as in Task 5.) - -- [ ] **Step 3: Build + test.** - -Run: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman input_action_blocked_while_emergency_engaged` -Expected: PASS. - -- [ ] **Step 4: Commit.** - -```bash -git add src/openhuman/screen_intelligence/ops.rs -git commit -m "feat(emergency): accessibility_input_action refuses input while halted (#4255)" -``` - ---- - -## Task 9: JSON-RPC E2E — stop → status → resume - -**Files:** -- Modify: `tests/json_rpc_e2e.rs` (add a test mirroring existing approval E2E tests) - -**Interfaces:** -- Consumes: the JSON-RPC dispatcher via the existing E2E harness in `tests/json_rpc_e2e.rs`. - -- [ ] **Step 1: Read an existing E2E test** in `tests/json_rpc_e2e.rs` (e.g. an approval one) to copy the harness setup (how it boots the core, obtains a client, and calls `openhuman.`). - -- [ ] **Step 2: Write the E2E test** following that harness's exact helper signatures: - -```rust -// Emergency stop: status(not halted) → stop → status(halted) → resume → status(not halted). -#[tokio::test] -async fn emergency_stop_roundtrip_over_rpc() { - let harness = /* boot core per existing pattern in this file */; - let s0 = harness.call("openhuman.emergency_status", serde_json::json!({})).await; - assert_eq!(s0["engaged"], serde_json::json!(false)); - - let stopped = harness.call("openhuman.emergency_stop", serde_json::json!({ "reason": "e2e" })).await; - assert_eq!(stopped["engaged"], serde_json::json!(true)); - - let s1 = harness.call("openhuman.emergency_status", serde_json::json!({})).await; - assert_eq!(s1["engaged"], serde_json::json!(true)); - - let resumed = harness.call("openhuman.emergency_resume", serde_json::json!({})).await; - assert_eq!(resumed["engaged"], serde_json::json!(false)); -} -``` - -Adapt `harness`/`call` to the file's real helpers (method name mapping `emergency.stop` → `openhuman.emergency_stop` is handled by the dispatcher; verify against how approval methods are invoked in this file). - -- [ ] **Step 3: Run.** - -Run: `bash scripts/test-rust-with-mock.sh --test json_rpc_e2e emergency_stop_roundtrip_over_rpc` -Expected: PASS. - -- [ ] **Step 4: Commit.** - -```bash -git add tests/json_rpc_e2e.rs -git commit -m "test(emergency): json-rpc e2e for stop/status/resume (#4255)" -``` - ---- - -## Task 10: Web socket bridge — `AutomationHalted`/`Resumed` → `automation_halt` event - -**Files:** -- Modify: `src/openhuman/channels/providers/web/event_bus.rs` (add `AutomationHaltSubscriber`, `register_automation_halt_subscriber`) -- Modify: `src/openhuman/channels/runtime/startup.rs` (call the register fn where `register_approval_surface_subscriber` is called) - -**Interfaces:** -- Consumes: `DomainEvent::AutomationHalted/Resumed`; `WebChannelEvent` (read `src/core/socketio` for its fields — the approval bridge sets `event`, `client_id`, `thread_id`, `args`). - -- [ ] **Step 1: Broadcast mechanism — CONFIRMED.** Emergency halt is global (not thread-scoped). `emit_web_channel_event` (src/core/socketio.rs:1409) delivers each event to the Socket.IO room named `event.client_id`, and **every** connected client auto-joins the `"system"` room (socketio.rs:438). So to broadcast to all clients, set `client_id: "system".to_string()` and leave `thread_id` empty — the emit code special-cases `client_id == "system"` for single-room delivery. **Do NOT use `..Default::default()` for `client_id`** (empty string → room `""` → reaches nobody). The frontend (Task 14) listens for the `automation_halt` event name globally. - -- [ ] **Step 2: Add the subscriber** in `event_bus.rs` (mirror `ApprovalSurfaceSubscriber`), emitting to the `"system"` broadcast room: - -```rust -static AUTOMATION_HALT_HANDLE: OnceLock = OnceLock::new(); - -pub fn register_automation_halt_subscriber() { - if AUTOMATION_HALT_HANDLE.get().is_some() { - return; - } - match crate::core::event_bus::subscribe_global(Arc::new(AutomationHaltSubscriber)) { - Some(handle) => { let _ = AUTOMATION_HALT_HANDLE.set(handle); - log::info!("[web-channel] automation-halt subscriber registered — bridges AutomationHalted/Resumed → automation_halt socket event"); } - None => log::warn!("[web-channel] failed to register automation-halt subscriber — bus not initialized"), - } -} - -struct AutomationHaltSubscriber; - -#[async_trait] -impl EventHandler for AutomationHaltSubscriber { - fn name(&self) -> &str { "channels::web::automation_halt" } - fn domains(&self) -> Option<&[&str]> { Some(&["system"]) } - async fn handle(&self, event: &DomainEvent) { - match event { - DomainEvent::AutomationHalted { reason, source } => { - publish_web_channel_event(WebChannelEvent { - event: "automation_halt".to_string(), - client_id: "system".to_string(), // broadcast room — all clients auto-join it - args: Some(serde_json::json!({ "engaged": true, "reason": reason, "source": source })), - ..Default::default() - }); - } - DomainEvent::AutomationResumed { source } => { - publish_web_channel_event(WebChannelEvent { - event: "automation_halt".to_string(), - client_id: "system".to_string(), // broadcast room — all clients auto-join it - args: Some(serde_json::json!({ "engaged": false, "source": source })), - ..Default::default() - }); - } - _ => {} - } - } -} -``` - -- [ ] **Step 3: Register at startup.** In `src/openhuman/channels/runtime/startup.rs`, next to `register_approval_surface_subscriber()`, add `register_automation_halt_subscriber();`. - -- [ ] **Step 4: Add a unit test** mirroring `fresh_approval_surface_subscription_returns_some_when_bus_is_ready` if a `fresh_*` helper is warranted; otherwise a minimal test asserting `register_automation_halt_subscriber()` is idempotent (second call is a no-op) after `init_global`. - -- [ ] **Step 5: Build + test.** - -Run: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman automation_halt` -Expected: PASS. - -- [ ] **Step 6: Commit.** - -```bash -git add src/openhuman/channels/providers/web/event_bus.rs src/openhuman/channels/runtime/startup.rs -git commit -m "feat(emergency): bridge halt/resume events to automation_halt socket event (#4255)" -``` - ---- - -## Task 11: Frontend Redux `safetySlice` - -**Files:** -- Create: `app/src/store/safetySlice.ts` -- Create: `app/src/store/safetySlice.test.ts` -- Modify: root store (`app/src/store/index.ts` or wherever `configureStore`/`combineReducers` lives) — mount `safety` reducer. - -**Interfaces:** -- Produces: `safetyReducer`, actions `setHalt({reason?, since?, source?})`, `clearHalt()`, `hydrateHalt(HaltState)`; selector `selectHalted(state)`, `selectHaltReason(state)`. - -- [ ] **Step 1: Write the failing test.** Create `app/src/store/safetySlice.test.ts`: - -```ts -import { describe, it, expect } from 'vitest'; -import reducer, { setHalt, clearHalt, hydrateHalt } from './safetySlice'; - -describe('safetySlice', () => { - it('starts not halted', () => { - expect(reducer(undefined, { type: '@@init' })).toEqual({ halted: false }); - }); - it('setHalt marks halted with reason/source/since', () => { - const s = reducer(undefined, setHalt({ reason: 'user', source: 'user', since: 42 })); - expect(s).toEqual({ halted: true, reason: 'user', source: 'user', since: 42 }); - }); - it('clearHalt resets', () => { - const halted = reducer(undefined, setHalt({ reason: 'x' })); - expect(reducer(halted, clearHalt())).toEqual({ halted: false }); - }); - it('hydrateHalt maps a HaltState snapshot', () => { - const s = reducer(undefined, hydrateHalt({ engaged: true, reason: 'boot', engaged_at_ms: 7, source: 'system' })); - expect(s.halted).toBe(true); - expect(s.reason).toBe('boot'); - expect(s.since).toBe(7); - }); -}); -``` - -- [ ] **Step 2: Run — verify fail.** `pnpm test app/src/store/safetySlice.test.ts` → FAIL (module not found). - -- [ ] **Step 3: Implement `safetySlice.ts`:** - -```ts -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; - -export interface HaltState { - engaged: boolean; - reason?: string; - engaged_at_ms?: number; - source?: string; -} - -export interface SafetyState { - halted: boolean; - reason?: string; - since?: number; - source?: string; -} - -const initialState: SafetyState = { halted: false }; - -const safetySlice = createSlice({ - name: 'safety', - initialState, - reducers: { - setHalt(_state, action: PayloadAction<{ reason?: string; source?: string; since?: number }>) { - return { halted: true, reason: action.payload.reason, source: action.payload.source, since: action.payload.since }; - }, - clearHalt() { - return { halted: false }; - }, - hydrateHalt(_state, action: PayloadAction) { - const h = action.payload; - return h.engaged - ? { halted: true, reason: h.reason, source: h.source, since: h.engaged_at_ms } - : { halted: false }; - }, - }, -}); - -export const { setHalt, clearHalt, hydrateHalt } = safetySlice.actions; -export const selectHalted = (state: { safety: SafetyState }) => state.safety.halted; -export const selectHaltReason = (state: { safety: SafetyState }) => state.safety.reason; -export default safetySlice.reducer; -``` - -- [ ] **Step 4: Mount reducer** in the root store under key `safety` (follow the existing slice-registration pattern — the store already registers `chatRuntime`, `thread`, etc.). - -- [ ] **Step 5: Run tests.** `pnpm test app/src/store/safetySlice.test.ts` → PASS. Also `pnpm typecheck`. - -- [ ] **Step 6: Commit.** - -```bash -git add app/src/store/safetySlice.ts app/src/store/safetySlice.test.ts app/src/store/index.ts -git commit -m "feat(emergency): safetySlice tracks automation-halt state (#4255)" -``` - ---- - -## Task 12: Frontend `emergencyApi` client - -**Files:** -- Create: `app/src/services/api/emergencyApi.ts` -- Create: `app/src/services/api/emergencyApi.test.ts` - -**Interfaces:** -- Consumes: `callCoreRpc` from `coreRpcClient`. **CONFIRMED signature (do not use positional args):** `callCoreRpc({ method, params }): Promise` — it takes a single **object** `{ method: string, params?: object }`. Mirror `app/src/services/api/approvalApi.ts`. -- **CONFIRMED wire-shape:** RPCs that emit a diagnostic log return the CLI envelope `{ result, logs }`; log-less RPCs return a bare value. `emergency_stop`/`emergency_resume` use `RpcOutcome::single_log` (enveloped); `emergency_status` uses `RpcOutcome::new(_, vec![])` (bare). So the client MUST normalize both shapes with an `unwrapValue` helper — copy the one in `approvalApi.ts` (lines ~109-114) verbatim. -- Produces: `emergencyStop(reason?: string): Promise`, `emergencyResume(): Promise`, `emergencyStatus(): Promise`. - -- [ ] **Step 1: Read `app/src/services/api/approvalApi.ts`** to copy the exact RPC-call idiom: the object-form `callCoreRpc({ method, params })`, the `unwrapValue` helper, and method-name convention `openhuman._`. - -- [ ] **Step 2: Write the failing test.** Create `emergencyApi.test.ts`. `callCoreRpc` is a **named export** of `../coreRpcClient` and is called with an object: - -```ts -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -const call = vi.fn(); -vi.mock('../coreRpcClient', () => ({ callCoreRpc: (arg: unknown) => call(arg) })); - -import { emergencyStop, emergencyResume, emergencyStatus } from './emergencyApi'; - -beforeEach(() => call.mockReset()); - -describe('emergencyApi', () => { - it('emergencyStop calls openhuman.emergency_stop with reason and unwraps envelope', async () => { - call.mockResolvedValue({ result: { engaged: true, reason: 'user' }, logs: ['x'] }); - const r = await emergencyStop('user'); - expect(call).toHaveBeenCalledWith({ method: 'openhuman.emergency_stop', params: { reason: 'user' } }); - expect(r.engaged).toBe(true); - expect(r.reason).toBe('user'); - }); - it('emergencyStop with no reason sends empty params', async () => { - call.mockResolvedValue({ result: { engaged: true }, logs: [] }); - await emergencyStop(); - expect(call).toHaveBeenCalledWith({ method: 'openhuman.emergency_stop', params: {} }); - }); - it('emergencyResume calls openhuman.emergency_resume', async () => { - call.mockResolvedValue({ result: { engaged: false }, logs: ['x'] }); - const r = await emergencyResume(); - expect(call).toHaveBeenCalledWith({ method: 'openhuman.emergency_resume', params: {} }); - expect(r.engaged).toBe(false); - }); - it('emergencyStatus reads bare value (no envelope)', async () => { - call.mockResolvedValue({ engaged: false }); - const r = await emergencyStatus(); - expect(call).toHaveBeenCalledWith({ method: 'openhuman.emergency_status', params: {} }); - expect(r.engaged).toBe(false); - }); -}); -``` - -- [ ] **Step 3: Run — verify fail.** `pnpm test app/src/services/api/emergencyApi.test.ts` → FAIL. - -- [ ] **Step 4: Implement `emergencyApi.ts`** mirroring `approvalApi.ts` (object-form call + `unwrapValue`): - -```ts -import { callCoreRpc } from '../coreRpcClient'; -import type { HaltState } from '../../store/safetySlice'; - -/** Normalize the CLI envelope `{ result, logs }` and bare-value shapes. */ -const unwrapValue = (raw: unknown): T => { - if (raw && typeof raw === 'object' && 'result' in (raw as Record)) { - return (raw as { result: T }).result; - } - return raw as T; -}; - -export async function emergencyStop(reason?: string): Promise { - const raw = await callCoreRpc({ method: 'openhuman.emergency_stop', params: reason ? { reason } : {} }); - return unwrapValue(raw); -} - -export async function emergencyResume(): Promise { - const raw = await callCoreRpc({ method: 'openhuman.emergency_resume', params: {} }); - return unwrapValue(raw); -} - -export async function emergencyStatus(): Promise { - const raw = await callCoreRpc({ method: 'openhuman.emergency_status', params: {} }); - return unwrapValue(raw); -} -``` - -- [ ] **Step 5: Run tests.** PASS + `pnpm typecheck`. - -- [ ] **Step 6: Commit.** - -```bash -git add app/src/services/api/emergencyApi.ts app/src/services/api/emergencyApi.test.ts -git commit -m "feat(emergency): emergencyApi RPC client (#4255)" -``` - ---- - -## Task 13: i18n keys - -**Files:** -- Modify: `app/src/lib/i18n/en.ts` and every other locale file at `app/src/lib/i18n/.ts` (`ar, bn, de, es, fr, hi, id, it, ko, pl, pt, ru, zh-CN`) -- Check: `app/src/lib/i18n/types.ts` — if the translation key type is explicitly enumerated there, add the new keys to it (otherwise `pnpm typecheck` fails). The parity/coverage guard lives at `app/src/lib/i18n/__tests__/coverage.test.ts`. - -**Interfaces:** -- Produces: keys `safety.emergencyStop`, `safety.resume`, `safety.haltedTitle`, `safety.haltedBody`, `safety.stopConfirm` (used by Task 14 components). - -- [ ] **Step 1: Read `app/src/lib/i18n/en.ts` and `types.ts`** to learn the nesting/key style (flat dotted keys vs nested objects) and whether keys are type-enumerated. Add keys to `en.ts` matching that exact style: - -```ts - safety: { - emergencyStop: 'Emergency stop', - resume: 'Resume automation', - haltedTitle: 'Automation halted', - haltedBody: 'All desktop automation is stopped. Resume when you are ready.', - stopConfirm: 'Stop all automation now?', - }, -``` - -- [ ] **Step 2: Add real translations** to every other locale file (not English placeholders — CI `pnpm i18n:english:check` fails on English left in non-English files). Translate the five strings per locale. - -- [ ] **Step 3: Verify parity.** - -Run: `pnpm i18n:check && pnpm i18n:english:check` -Expected: PASS (all locales have the keys; no English placeholders). - -- [ ] **Step 4: Commit.** - -```bash -git add app/src/lib/i18n/locales -git commit -m "i18n(emergency): add safety.* keys across locales (#4255)" -``` - ---- - -## Task 14: Emergency Stop button + halted banner + wiring - -**Files:** -- Create: `app/src/components/safety/EmergencyStopButton.tsx` (+ `.test.tsx`) -- Create: `app/src/components/safety/AutomationHaltedBanner.tsx` (+ `.test.tsx`) -- Modify: app shell/header to mount both (pick the always-visible chrome — e.g. the Conversations header near the `chat-cancel-generation` control, or `AppShell`). -- Modify: `app/src/services/socketService.ts` — handle `automation_halt` socket event → dispatch `setHalt`/`clearHalt`. -- Modify: boot path (e.g. `CoreStateProvider` or an effect in the shell) — call `emergencyStatus()` once and dispatch `hydrateHalt`. - -**Interfaces:** -- Consumes: `emergencyStop`/`emergencyResume`/`emergencyStatus` (Task 12); `setHalt`/`clearHalt`/`hydrateHalt`/`selectHalted`/`selectHaltReason` (Task 11); `useT()`. - -- [ ] **Step 1: Write the button test.** `EmergencyStopButton.test.tsx` (mirror an existing component test that wraps a Redux `Provider` + i18n — find one such test to copy providers): - -```tsx -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { EmergencyStopButton } from './EmergencyStopButton'; -import { renderWithProviders } from '../../test-utils'; // use the repo's existing helper; else inline a store+I18n wrapper - -const stop = vi.fn().mockResolvedValue({ engaged: true }); -vi.mock('../../services/api/emergencyApi', () => ({ emergencyStop: (...a: unknown[]) => stop(...a) })); - -beforeEach(() => stop.mockClear()); - -describe('EmergencyStopButton', () => { - it('calls emergencyStop and dispatches halt on click', async () => { - renderWithProviders(); - fireEvent.click(screen.getByRole('button', { name: /emergency stop/i })); - await waitFor(() => expect(stop).toHaveBeenCalled()); - }); -}); -``` - -- [ ] **Step 2: Verify fail.** `pnpm test EmergencyStopButton` → FAIL. - -- [ ] **Step 3: Implement `EmergencyStopButton.tsx`:** - -```tsx -import { useCallback } from 'react'; -import { useDispatch } from 'react-redux'; -import { useT } from '../../lib/i18n/I18nContext'; -import { emergencyStop } from '../../services/api/emergencyApi'; -import { setHalt } from '../../store/safetySlice'; - -export function EmergencyStopButton() { - const t = useT(); - const dispatch = useDispatch(); - const onClick = useCallback(async () => { - try { - const state = await emergencyStop('user'); - dispatch(setHalt({ reason: state.reason, source: state.source, since: state.engaged_at_ms })); - } catch (err) { - // Fail-visible: still reflect intent locally so the user sees the halt. - dispatch(setHalt({ reason: 'user', source: 'user' })); - console.error('[emergency] stop failed', err); - } - }, [dispatch]); - return ( - - ); -} -``` - -- [ ] **Step 4: Implement `AutomationHaltedBanner.tsx`** (renders only when halted; Resume clears): - -```tsx -import { useCallback } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { useT } from '../../lib/i18n/I18nContext'; -import { emergencyResume } from '../../services/api/emergencyApi'; -import { clearHalt, selectHalted, selectHaltReason } from '../../store/safetySlice'; - -export function AutomationHaltedBanner() { - const t = useT(); - const dispatch = useDispatch(); - const halted = useSelector(selectHalted); - const reason = useSelector(selectHaltReason); - const onResume = useCallback(async () => { - try { await emergencyResume(); } finally { dispatch(clearHalt()); } - }, [dispatch]); - if (!halted) return null; - return ( -
- {t('safety.haltedTitle')} - {reason ?? t('safety.haltedBody')} - -
- ); -} -``` - -- [ ] **Step 5: Write the banner test** (`AutomationHaltedBanner.test.tsx`): renders nothing when not halted; renders + Resume calls `emergencyResume` and clears when halted (preload the store with `setHalt`). - -- [ ] **Step 6: Socket handler.** In `socketService.ts`, register a handler for the `automation_halt` event (mirror how `approval_request` is handled): on `{engaged:true}` dispatch `setHalt`, on `{engaged:false}` dispatch `clearHalt`. - -- [ ] **Step 7: Boot hydration.** In the shell/boot effect, call `emergencyStatus()` once and dispatch `hydrateHalt(result)` (guard with `isTauri()`/try-catch). - -- [ ] **Step 8: Mount** `` in the always-visible chrome and `` near the top of the main content. - -- [ ] **Step 9: Run all frontend checks.** - -Run: `pnpm test app/src/components/safety && pnpm typecheck && pnpm lint` -Expected: PASS. - -- [ ] **Step 10: Commit.** - -```bash -git add app/src/components/safety app/src/services/socketService.ts app/src/store app/src/**/*Shell* 2>/dev/null -git commit -m "feat(emergency): stop button + halted banner + socket/boot wiring (#4255)" -``` - ---- - -## Task 15: Full verification + coverage gate - -- [ ] **Step 1: Rust suite (changed domains).** - -Run: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman emergency_stop:: && bash scripts/test-rust-with-mock.sh --test json_rpc_e2e emergency_stop_roundtrip_over_rpc` -Expected: all PASS. - -- [ ] **Step 2: Rust format + check.** - -Run: `cargo fmt --manifest-path Cargo.toml && GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml` -Expected: no diffs, clean check. - -- [ ] **Step 3: Frontend suite + quality.** - -Run: `pnpm test && pnpm typecheck && pnpm lint && pnpm i18n:check && pnpm i18n:english:check` -Expected: all PASS. - -- [ ] **Step 4: Diff coverage sanity.** Ensure the changed Rust lines (ops.rs guards, chokepoints) and changed TS lines (slice, api, components) are exercised by the tests above. Add targeted tests for any uncovered branch (e.g. `emergency_status` when no switch installed → `HaltState::default`). Target ≥80% on changed lines. - -- [ ] **Step 5: Update feature docs.** Per AGENTS.md, if this adds a user-facing feature, update `src/openhuman/about_app/` with the Emergency Stop control. Commit. - -```bash -git commit -am "docs(about): register emergency stop as a user-facing control (#4255)" -``` - -- [ ] **Step 6: Manual smoke (optional but recommended).** `pnpm dev:app`, engage Emergency Stop, confirm the banner appears and Resume clears it; confirm an accessibility input while halted returns blocked. - ---- - -## Self-Review (completed by plan author) - -- **Spec coverage:** AC "emergency stop cancels pending actions" → Task 5 cascade-deny + a11y stop; "prevents further queued actions until resume" → Tasks 7–8 chokepoints + Task 5 flag; UI control + resume → Tasks 11–14; tests/≥80% coverage → every task is TDD + Task 15. ✔ -- **Placeholder scan:** No TBDs. The few "read neighboring file to confirm exact accessor" notes are explicit verification steps (RpcOutcome `.value`, `callCoreRpc` export, `WebChannelEvent` fields, test-provider helper) with the exact file to read — not deferred work. ✔ -- **Type consistency:** `HaltState` fields (`engaged`, `reason`, `engaged_at_ms`, `source`) identical across Rust (Task 2) and TS (Task 11/12); RPC method names `openhuman.emergency_{stop,resume,status}` consistent Tasks 6/9/12; event names `AutomationHalted`/`AutomationResumed` consistent Tasks 1/10; socket event `automation_halt` consistent Tasks 10/14. ✔ -- **Ordering:** Task 1 (events) precedes Task 5 (publishes them); Tasks 2–4 (module compiles) precede Task 5–6; chokepoints (7–8) after the switch exists; frontend (11–14) independent of Rust after RPC names are fixed. ✔ diff --git a/docs/superpowers/specs/2026-07-06-emergency-stop-design.md b/docs/superpowers/specs/2026-07-06-emergency-stop-design.md deleted file mode 100644 index 45b3f60939..0000000000 --- a/docs/superpowers/specs/2026-07-06-emergency-stop-design.md +++ /dev/null @@ -1,102 +0,0 @@ -# Emergency Stop for desktop automation — design - -Issue: [tinyhumansai/openhuman#4255](https://github.com/tinyhumansai/openhuman/issues/4255) — "Desktop automation safety previews, confirmations, history, and emergency stop". - -This spec covers **slice 1: Emergency Stop** only. The issue is an epic (previews, confirmations, history, emergency stop, backups, Windows support, reusable workflows); its acceptance criteria call for one slice landed end-to-end first. Emergency Stop is the safety-critical control and is self-contained. - -## Goal - -A prominent, always-available control that: - -1. **Immediately halts** all running/queued desktop automation, and -2. **Blocks any further automated actions** until the user explicitly resumes. - -It is a **fail-closed kill switch**: while engaged, every automated real-world action is refused. - -## Scope decisions (approved) - -- **Stop behavior:** set a global halt flag (blocks all further external-effect / accessibility actions fail-closed), stop the accessibility session, and cascade-deny all pending approvals. The running agent turn can take no further real-world actions. (We do **not** hard-abort in-flight chat turns in this slice — blocking every action chokepoint achieves the safety goal without touching the turn/cancel machinery.) -- **Persistence:** in-memory only; a restart clears the halt (reset on boot). Persisting a halt across restarts is a follow-up. -- **Backend (`backend-alphahuman`):** no changes. Desktop automation executes in the Tauri Rust core; the backend's execution-session flow is a separate (email/Telegram) subsystem. A server-side execution-session cancel is a follow-up, not this slice. - -## Existing infrastructure this builds on - -- `src/openhuman/approval/` — `ApprovalGate` parks/denies external-effect tool calls, keeps a SQLite audit trail, fail-closed 10-min TTL. We reuse its pending-list + deny path for cascade-deny. -- `src/openhuman/tinyagents/middleware.rs::wrap_tool` — every external-effect/dangerous tool call is already intercepted here (`has_external_effect`, `gate.intercept_audited`). This is our primary enforcement chokepoint. -- `src/openhuman/screen_intelligence/` — `ops.rs::accessibility_input_action` dispatches clicks/typing to `input.rs`, which already has a per-session `panic_stop` action and session stop. This is our second chokepoint + the session-stop reuse. -- `src/core/all.rs` controller registry + `src/openhuman/channels/providers/web/event_bus.rs` `ApprovalSurfaceSubscriber` — the pattern for RPC registration and bridging domain events to a web-channel socket event. - -## Architecture - -### New core domain — `src/openhuman/emergency_stop/` - -Follows the canonical module shape (`mod.rs` export-only; `types.rs`; `state.rs`; `ops.rs`; `schemas.rs`). - -- **`state.rs`** — process-global `EmergencyStop` in a `OnceCell`, holding `AtomicBool engaged` + `Mutex>` (`reason: String`, `engaged_at_ms: u64`, `source: HaltSource`). Public: `global()` / `try_global()`, `is_engaged()`, `engage(info)`, `clear()`, `snapshot() -> HaltState`. Mirrors `ApprovalGate` global-singleton ergonomics. `try_global()` → `None` means "no switch installed" → treated as not-engaged (never blocks) so headless/CLI paths are unaffected. -- **`types.rs`** — `HaltState { engaged: bool, reason: Option, engaged_at_ms: Option, source: Option }` (serde); `HaltSource` enum (`User`, `Hotkey`, `System`). -- **`ops.rs`** — handlers returning `RpcOutcome`: - - `emergency_stop(reason, source)` — engage flag; then best-effort: stop the accessibility session (reuse existing stop path) and cascade-deny all `ApprovalGate` pending rows (`list_pending` → `decide(deny)`); publish `AutomationHalted`; return snapshot. Idempotent (already-engaged is a no-op success). - - `emergency_resume()` — clear flag; publish `AutomationResumed`; return snapshot. Idempotent. - - `emergency_status()` — return snapshot. -- **`schemas.rs` + `mod.rs`** — controllers → RPC `openhuman.emergency_stop`, `openhuman.emergency_resume`, `openhuman.emergency_status`; registered in `src/core/all.rs`. -- **Events** — `DomainEvent::AutomationHalted { reason, source }` / `AutomationResumed` (add to `src/core/event_bus/events.rs`, extend `domain()` match → `system`). A subscriber in `web.rs` (or extending `ApprovalSurfaceSubscriber`) bridges them to a web-channel socket event (`automation_halt`) so all UI surfaces update live. -- **Install** — `EmergencyStop::init_global()` at core startup next to `ApprovalGate::init_global()` in `src/core/jsonrpc.rs`. - -### Enforcement (the "block further actions" invariant) — fail-closed at two chokepoints - -1. **`tinyagents/middleware.rs::wrap_tool`** — at the top of the external-effect/dangerous path, if `EmergencyStop::is_engaged()`, refuse the call before `execute()` with a clear `POLICY_DENIED_MARKER`-style "emergency stop engaged" reason. This stops the agent loop from taking further real-world actions. (**Scope note for this slice:** the refusal is surfaced via a `tracing::warn!` and the `AutomationHalted` domain event / `automation_halt` socket broadcast, but is **not** recorded through `ApprovalGate::intercept_audited` as an `Aborted` audit row. Writing halted refusals into the approval audit trail needs a new gate API and is tracked as a follow-up.) -2. **`screen_intelligence/ops.rs::accessibility_input_action`** — if engaged, short-circuit to `{ accepted: false, blocked: true, reason: "emergency_stop" }` (except the existing `panic_stop` action, which must still pass so a stop is never blocked by a stop). - -Both checks are cheap (`AtomicBool` load) and fail-open only when no switch is installed. - -### Frontend (`app/src/`) - -- **Redux `safetySlice`** — `{ halted: bool, reason?: string, since?: number, source?: string }`; actions `setHalt`, `clearHalt`, `hydrateHalt`. -- **`services/api/emergencyApi.ts`** — `emergencyStop()`, `emergencyResume()`, `emergencyStatus()` via `core_rpc_relay` (`coreRpcClient`). -- **Socket handler** — subscribe to `automation_halt`; dispatch `setHalt`/`clearHalt`. Hydrate via `emergencyStatus()` on boot. -- **UI** - - A persistent **Emergency Stop** button in the app shell / chat header (always visible), `data-analytics-id` for analytics. - - When halted, a **banner** ("Automation halted — {reason}") with a **Resume** action. - - All copy through `useT()`; keys added to `en.ts` and every locale file (CI enforces parity). - -## Data flow - -``` -User clicks Emergency Stop - → emergencyApi.emergencyStop() (core_rpc_relay → openhuman.emergency_stop) - → ops::emergency_stop: engage flag; stop a11y session; cascade-deny pending approvals - → publish AutomationHalted → web subscriber → socket 'automation_halt' - → all clients: safetySlice.setHalt → button shows halted state + banner - -Agent tries another tool while halted - → middleware.wrap_tool sees is_engaged() → deny (tracing warn + halt event; audit-row write deferred) → agent cannot act -Agent/vision tries accessibility_input_action while halted - → ops sees is_engaged() → { accepted:false, blocked:true, reason:'emergency_stop' } - -User clicks Resume - → openhuman.emergency_resume → clear flag → AutomationResumed → socket → clearHalt -``` - -## Error handling - -- **Fail-closed:** any uncertainty (switch installed and engaged) blocks. No installed switch (CLI/headless) never blocks. -- **Best-effort side effects on engage:** if stopping the a11y session or cascade-denying an approval errors, the halt flag is still set and the error is logged — the primary invariant (flag set → actions blocked) never depends on a side effect succeeding. -- **Idempotent** stop/resume so double-clicks and repeated socket events are safe. - -## Testing (≥80% diff coverage — merge gate) - -**Rust unit tests** (inline `#[cfg(test)]` / sibling `*_tests.rs`): -- `state`: engage/clear/snapshot; `is_engaged` transitions; `try_global` None → not engaged. -- `ops`: stop sets flag + emits `AutomationHalted`; resume clears + emits `AutomationResumed`; stop is idempotent; cascade-deny denies pending rows; best-effort side-effect failure still sets the flag. -- middleware chokepoint: external-effect tool refused while halted, allowed after resume. -- `accessibility_input_action`: blocked while halted; `panic_stop` still passes while halted. - -**JSON-RPC E2E** (`tests/json_rpc_e2e.rs`): `emergency_status` (not halted) → `emergency_stop` → `emergency_status` (halted, reason) → `emergency_resume` → `emergency_status` (not halted). - -**Vitest** (`app/src/**`): `safetySlice` reducers; `emergencyApi` calls correct RPC methods; Emergency Stop button dispatches stop and reflects halted state; banner renders + Resume dispatches resume; socket handler maps events to store. - -## Out of scope (follow-ups tracked against #4255) - -- Action previews, backup-before-overwrite, activity-history UI, reusable app workflows, Windows-specific assessment. -- Persisting halt across restarts; hard-aborting in-flight chat turns; server-side (`backend-alphahuman`) execution-session cancel. -- A global OS panic **hotkey** binding for emergency stop (the per-session `panic_stop` exists; a global hotkey is a follow-up). diff --git a/src/core/all.rs b/src/core/all.rs index 8ead22c1df..b5821c13da 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -336,12 +336,6 @@ fn build_registered_controllers() -> Vec { DomainGroup::Security, crate::openhuman::approval::all_approval_registered_controllers(), ); - // Emergency stop kill switch (#4255 — fail-closed halt for desktop automation) - push( - &mut controllers, - DomainGroup::Security, - crate::openhuman::emergency_stop::all_emergency_registered_controllers(), - ); // Interactive plan-review gate — parks a live turn on a thread-scoped plan push( &mut controllers, diff --git a/src/core/event_bus/events.rs b/src/core/event_bus/events.rs index bc9a7af811..c73172e5b7 100644 --- a/src/core/event_bus/events.rs +++ b/src/core/event_bus/events.rs @@ -1127,22 +1127,6 @@ pub enum DomainEvent { overall: String, failed_required: bool, }, - /// Emergency stop engaged — all desktop automation is halted and every - /// external-effect / accessibility action is refused until resumed. - /// Published by `emergency_stop::ops::emergency_stop`; bridged to the - /// `automation_halt` web-channel socket event. - AutomationHalted { - /// Optional human-readable reason (redacted of PII by the caller). - reason: Option, - /// Who engaged it: `"user"`, `"hotkey"`, or `"system"`. - source: String, - }, - /// Emergency stop cleared — automation may resume. Published by - /// `emergency_stop::ops::emergency_resume`. - AutomationResumed { - /// Who cleared it: `"user"`, `"hotkey"`, or `"system"`. - source: String, - }, // ── Keyring ───────────────────────────────────────────────────────── /// The OS keyring is unavailable and no user consent for local fallback @@ -1470,9 +1454,7 @@ impl DomainEvent { | Self::HealthChanged { .. } | Self::HealthRestarted { .. } | Self::HarnessInitProgress { .. } - | Self::HarnessInitCompleted { .. } - | Self::AutomationHalted { .. } - | Self::AutomationResumed { .. } => "system", + | Self::HarnessInitCompleted { .. } => "system", Self::KeyringConsentRequired | Self::KeyringDecryptFailed { .. } => "keyring", @@ -1628,8 +1610,6 @@ impl DomainEvent { Self::HealthRestarted { .. } => "HealthRestarted", Self::HarnessInitProgress { .. } => "HarnessInitProgress", Self::HarnessInitCompleted { .. } => "HarnessInitCompleted", - Self::AutomationHalted { .. } => "AutomationHalted", - Self::AutomationResumed { .. } => "AutomationResumed", Self::KeyringConsentRequired => "KeyringConsentRequired", Self::KeyringDecryptFailed { .. } => "KeyringDecryptFailed", Self::SessionExpired { .. } => "SessionExpired", diff --git a/src/core/event_bus/events_tests.rs b/src/core/event_bus/events_tests.rs index 551c664743..c977286852 100644 --- a/src/core/event_bus/events_tests.rs +++ b/src/core/event_bus/events_tests.rs @@ -594,18 +594,3 @@ fn workflows_changed_domain_and_name() { assert_eq!(event.domain(), "workflow"); assert_eq!(event.variant_name(), "WorkflowsChanged"); } - -#[test] -fn automation_events_map_to_system_domain() { - let halted = DomainEvent::AutomationHalted { - reason: Some("user".into()), - source: "user".into(), - }; - let resumed = DomainEvent::AutomationResumed { - source: "user".into(), - }; - assert_eq!(halted.domain(), "system"); - assert_eq!(resumed.domain(), "system"); - assert_eq!(halted.variant_name(), "AutomationHalted"); - assert_eq!(resumed.variant_name(), "AutomationResumed"); -} diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index 2b0b10ef29..2b87bdcb2d 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -2360,12 +2360,6 @@ pub async fn bootstrap_core_runtime( // unguarded standalone/CLI/Docker core would park a plan review that never // reaches the UI and dies at the gate TTL. Idempotent (Once-guarded). crate::openhuman::web_chat::register_approval_surface_subscriber(); - // Bridge emergency-stop halt/resume → the `automation_halt` broadcast on the - // same always-run boot path. `start_channels` (which also registers this) - // is skipped on a web-chat-only desktop with no listening integrations, so - // without this a halt/resume initiated from the CLI or another client would - // never reach the UI. Idempotent (Once-guarded). (#4255) - crate::openhuman::web_chat::register_automation_halt_subscriber(); // Egress-surface bridge (privacy epic S2, #4436) — registered // unconditionally alongside the approval surface so external-transfer // disclosures reach the UI even on cores that skip `start_channels` or run @@ -2384,7 +2378,6 @@ pub async fn bootstrap_core_runtime( let session_id = format!("session-{}", uuid::Uuid::new_v4()); let _ = crate::openhuman::approval::ApprovalGate::init_global(cfg.clone(), session_id.clone()); - crate::openhuman::emergency_stop::EmergencyStop::init_global(); log::info!( "[runtime] approval gate installed (on by default; set OPENHUMAN_APPROVAL_GATE=0 to disable, session_id={session_id}) — \ Prompt-class external-effect tool calls park for approval in interactive chat turns" diff --git a/src/openhuman/channels/runtime/startup.rs b/src/openhuman/channels/runtime/startup.rs index bc777e88a2..8cd8ecfbfc 100644 --- a/src/openhuman/channels/runtime/startup.rs +++ b/src/openhuman/channels/runtime/startup.rs @@ -171,11 +171,6 @@ pub async fn start_channels(mut config: Config) -> Result<()> { // ArtifactFailed) as `artifact_ready` / `artifact_failed` web-channel // events so the frontend ArtifactCard can render in chat (#2779). crate::openhuman::web_chat::register_artifact_surface_subscriber(); - // Bridge emergency-stop halt/resume (AutomationHalted / AutomationResumed) - // to the `automation_halt` web-channel socket event, broadcast to every - // client via the "system" room, so the frontend kill-switch UI updates - // globally (#4255). - crate::openhuman::web_chat::register_automation_halt_subscriber(); // Surface external-egress disclosure events (ExternalTransferPending) as // `external_transfer_pending` web-channel events so the frontend can show a // per-action "what leaves, to where, why" card (privacy epic S2, #4436). diff --git a/src/openhuman/cron/scheduler.rs b/src/openhuman/cron/scheduler.rs index 115e28fdc5..623bbf09c6 100644 --- a/src/openhuman/cron/scheduler.rs +++ b/src/openhuman/cron/scheduler.rs @@ -483,27 +483,6 @@ async fn execute_job_with_retry( security: &SecurityPolicy, job: &CronJob, ) -> (bool, String) { - // Emergency stop: refuse every scheduled job while the kill switch is - // engaged. The tinyagents middleware already fails-closed on external-effect - // tools inside `JobType::Agent`, but `JobType::Shell` spawns `sh -lc` and - // `JobType::Flow` publishes a flow-trigger event — neither goes through the - // middleware, so without this check a due or Run Now shell/flow job could - // still perform external actions while automation is halted. Fail-closed at - // the outermost dispatch is the safest place: it applies to every job type - // and to every retry attempt, and never spawns the underlying process. See - // #4255. - if crate::openhuman::emergency_stop::is_engaged_global() { - log::warn!( - "[cron] action=refused_while_halted job_id={} job_type={:?} — emergency stop engaged", - job.id.as_str(), - job.job_type - ); - return ( - false, - "blocked by emergency stop: automation is halted — resume to run this job".to_string(), - ); - } - let mut last_output = String::new(); let mut last_agent_error: Option = None; let retries = config.reliability.scheduler_retries; @@ -515,23 +494,6 @@ async fn execute_job_with_retry( let mut local_unreachable = false; for attempt in 0..=retries { - // Re-check the kill switch before each RETRY (attempt 0 is already - // covered by the pre-loop guard above): a user who engages Emergency - // Stop during the backoff sleep must not have the next attempt execute. - // Same fail-closed denial as the pre-loop guard (#4255). - if attempt > 0 && crate::openhuman::emergency_stop::is_engaged_global() { - log::warn!( - "[cron] action=refused_retry_while_halted job_id={} job_type={:?} attempt={} — emergency stop engaged", - job.id.as_str(), - job.job_type, - attempt - ); - return ( - false, - "blocked by emergency stop: automation is halted — resume to run this job" - .to_string(), - ); - } let (success, output, agent_error) = match job.job_type { JobType::Shell => { let (success, output) = run_job_command(config, security, job).await; diff --git a/src/openhuman/cron/scheduler_tests.rs b/src/openhuman/cron/scheduler_tests.rs index f14212282f..dff55e92ed 100644 --- a/src/openhuman/cron/scheduler_tests.rs +++ b/src/openhuman/cron/scheduler_tests.rs @@ -439,43 +439,6 @@ async fn execute_job_with_retry_exhausts_attempts() { assert!(output.contains("always_missing_for_retry_test")); } -/// Emergency stop must refuse every scheduled shell/flow/agent job before it -/// launches, so a `sh -lc` or flow-trigger event can never fire while the kill -/// switch is engaged. Covers the codex-review gap for cron paths that don't -/// route through the tinyagents middleware (#4255). -#[cfg(not(windows))] -#[tokio::test] -async fn execute_job_with_retry_refuses_shell_job_while_halted() { - let _test_guard = crate::openhuman::emergency_stop::state::EMERGENCY_TEST_GUARD - .lock() - .unwrap_or_else(|e| e.into_inner()); - let stop = crate::openhuman::emergency_stop::EmergencyStop::init_global(); - stop.clear(); // start clean regardless of parallel-suite state - let _resume_on_drop = crate::openhuman::emergency_stop::state::ClearEmergencyOnDrop; - - let tmp = TempDir::new().unwrap(); - let mut config = test_config(&tmp).await; - config.reliability.scheduler_retries = 3; - config.reliability.provider_backoff_ms = 1; - let security = SecurityPolicy::from_config( - &config.autonomy, - &config.workspace_dir, - &config.workspace_dir, - ); - let job = test_job("/bin/echo should-never-run"); - - // Engage AFTER building the job/config to isolate the halt check. - stop.engage(Some("test".into()), "test", 0); - - let (success, output) = execute_job_with_retry(&config, &security, &job).await; - - assert!(!success, "halted scheduler must not report success"); - assert!( - output.starts_with("blocked by emergency stop:"), - "output must be the emergency-stop refusal, got: {output}" - ); -} - // TAURI-RUST-N — backend 401 ("Invalid token") leaks from a cron-fired agent // job through `last_agent_error` and the existing classifier in // `core::observability::is_session_expired_message` matches it (the diff --git a/src/openhuman/emergency_stop/mod.rs b/src/openhuman/emergency_stop/mod.rs deleted file mode 100644 index b4d3c5e9fe..0000000000 --- a/src/openhuman/emergency_stop/mod.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! Emergency stop — a fail-closed kill switch for desktop automation. -//! -//! `EmergencyStop` is a process-global switch (mirrors `ApprovalGate`). When -//! engaged, the tinyagents approval middleware refuses external-effect tool -//! calls and `accessibility_input_action` refuses clicks/typing, until the -//! user resumes. Engaging also stops the accessibility session and -//! cascade-denies pending approvals. In-memory only (resets on restart). - -pub mod ops; -pub mod schemas; -pub mod state; -pub mod types; - -pub use schemas::{all_emergency_controller_schemas, all_emergency_registered_controllers}; -pub use state::{is_engaged_global, EmergencyStop}; -pub use types::HaltState; diff --git a/src/openhuman/emergency_stop/ops.rs b/src/openhuman/emergency_stop/ops.rs deleted file mode 100644 index b1b3bc67eb..0000000000 --- a/src/openhuman/emergency_stop/ops.rs +++ /dev/null @@ -1,165 +0,0 @@ -//! Emergency-stop RPC operations: engage / resume / read the switch, plus the -//! best-effort side effects (stop the a11y session, cascade-deny pending -//! approvals) and event publication. - -use std::time::{SystemTime, UNIX_EPOCH}; - -use crate::core::event_bus::{publish_global, DomainEvent}; -use crate::rpc::RpcOutcome; - -use super::state::EmergencyStop; -use super::types::HaltState; - -fn now_ms() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0) -} - -/// Engage the kill switch: set the flag, then best-effort stop the a11y -/// session and cascade-deny pending approvals, then publish `AutomationHalted`. -/// Idempotent. Side-effect failures are logged but never fail the RPC — the -/// primary invariant (flag set → actions blocked) does not depend on them. -pub async fn emergency_stop(reason: Option, source: &str) -> RpcOutcome { - tracing::warn!(source, reason = ?reason, "[rpc:emergency_stop] entry — engaging kill switch"); - let stop = EmergencyStop::init_global(); - stop.engage(reason.clone(), source, now_ms()); - - // Best-effort: stop the accessibility session so any in-flight click/type loop halts. - let a11y = crate::openhuman::screen_intelligence::global_engine() - .disable(Some("emergency_stop".to_string())) - .await; - tracing::info!( - active = a11y.active, - "[emergency] accessibility session stopped" - ); - - // Best-effort: cascade-deny every pending approval so parked tool calls fail - // closed. `list_pending`/`decide` do synchronous SQLite I/O, so run them on a - // blocking thread rather than stalling a tokio worker. - let denied = tokio::task::spawn_blocking(cascade_deny_pending) - .await - .unwrap_or_else(|err| { - tracing::warn!(error = %err, "[emergency] cascade-deny task join failed"); - 0 - }); - tracing::info!(denied, "[emergency] cascade-denied pending approvals"); - - publish_global(DomainEvent::AutomationHalted { - reason, - source: source.to_string(), - }); - - let snap = stop.snapshot(); - RpcOutcome::single_log( - snap, - format!("[emergency] halted (source={source}, denied={denied})"), - ) -} - -/// Deny all pending approvals. Returns how many were denied. Best-effort: -/// a per-row error is logged and skipped. -fn cascade_deny_pending() -> usize { - use crate::openhuman::approval::{ApprovalDecision, ApprovalGate}; - let Some(gate) = ApprovalGate::try_global() else { - return 0; - }; - let rows = match gate.list_pending() { - Ok(rows) => rows, - Err(err) => { - tracing::warn!(error = %err, "[emergency] list_pending failed during cascade-deny"); - return 0; - } - }; - let mut denied = 0; - for row in rows { - match gate.decide(&row.request_id, ApprovalDecision::Deny) { - Ok(_) => denied += 1, - Err(err) => { - tracing::warn!(request_id = %row.request_id, error = %err, "[emergency] deny failed") - } - } - } - denied -} - -/// Clear the kill switch and publish `AutomationResumed`. Idempotent. -pub async fn emergency_resume(source: &str) -> RpcOutcome { - tracing::info!( - source, - "[rpc:emergency_resume] entry — clearing kill switch" - ); - let stop = EmergencyStop::init_global(); - stop.clear(); - publish_global(DomainEvent::AutomationResumed { - source: source.to_string(), - }); - RpcOutcome::single_log( - stop.snapshot(), - format!("[emergency] resumed (source={source})"), - ) -} - -/// Read the current switch state. -pub async fn emergency_status() -> RpcOutcome { - let snap = EmergencyStop::try_global() - .map(|s| s.snapshot()) - .unwrap_or_default(); - tracing::debug!(engaged = snap.engaged, "[rpc:emergency_status] exit"); - RpcOutcome::new(snap, vec![]) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::openhuman::emergency_stop::state::EMERGENCY_TEST_GUARD; - - #[tokio::test] - async fn stop_sets_flag_and_status_reports_engaged() { - let _g = EMERGENCY_TEST_GUARD - .lock() - .unwrap_or_else(|e| e.into_inner()); - // Panic-safe cleanup: clear the process-global switch on drop — even if - // an assertion panics between engage and the end of the test — so an - // engaged state can't leak into a later test sharing the binary (#4600 - // review). Supersedes the manual `emergency_resume` reset below. - let _reset = crate::openhuman::emergency_stop::state::ClearEmergencyOnDrop; - let out = emergency_stop(Some("user".into()), "user").await; - assert!(out.value.engaged); - let status = emergency_status().await; - assert!(status.value.engaged); - assert_eq!(status.value.source.as_deref(), Some("user")); - // reset for other tests sharing the process-global switch - let _ = emergency_resume("user").await; - } - - #[tokio::test] - async fn resume_clears_flag() { - let _g = EMERGENCY_TEST_GUARD - .lock() - .unwrap_or_else(|e| e.into_inner()); - // Panic-safe cleanup (see the note in the first test) — clears the - // process-global switch on drop so a mid-test panic can't leak state. - let _reset = crate::openhuman::emergency_stop::state::ClearEmergencyOnDrop; - let _ = emergency_stop(None, "user").await; - let out = emergency_resume("user").await; - assert!(!out.value.engaged); - assert!(!emergency_status().await.value.engaged); - } - - #[tokio::test] - async fn stop_is_idempotent() { - let _g = EMERGENCY_TEST_GUARD - .lock() - .unwrap_or_else(|e| e.into_inner()); - // Panic-safe cleanup (see the note in the first test) — clears the - // process-global switch on drop so a mid-test panic can't leak state. - let _reset = crate::openhuman::emergency_stop::state::ClearEmergencyOnDrop; - let _ = emergency_stop(Some("a".into()), "user").await; - let out = emergency_stop(Some("b".into()), "system").await; - assert!(out.value.engaged); - assert_eq!(out.value.reason.as_deref(), Some("b")); - let _ = emergency_resume("user").await; - } -} diff --git a/src/openhuman/emergency_stop/schemas.rs b/src/openhuman/emergency_stop/schemas.rs deleted file mode 100644 index 171a0fcd55..0000000000 --- a/src/openhuman/emergency_stop/schemas.rs +++ /dev/null @@ -1,166 +0,0 @@ -//! Controller schemas + handlers for the `emergency` namespace. -//! Wires `emergency_stop`, `emergency_resume`, `emergency_status` into the -//! global registry consumed by `src/core/all.rs`. - -use serde_json::{Map, Value}; - -use crate::core::all::{ControllerFuture, RegisteredController}; -use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; - -use super::ops; - -pub fn all_emergency_controller_schemas() -> Vec { - vec![schemas("stop"), schemas("resume"), schemas("status")] -} - -pub fn all_emergency_registered_controllers() -> Vec { - vec![ - RegisteredController { - schema: schemas("stop"), - handler: handle_stop, - }, - RegisteredController { - schema: schemas("resume"), - handler: handle_resume, - }, - RegisteredController { - schema: schemas("status"), - handler: handle_status, - }, - ] -} - -pub fn schemas(function: &str) -> ControllerSchema { - match function { - "stop" => ControllerSchema { - namespace: "emergency", - function: "stop", - description: "Engage the emergency stop: halt all desktop automation and block further actions until resumed.", - inputs: vec![FieldSchema { - name: "reason", - ty: TypeSchema::Option(Box::new(TypeSchema::String)), - comment: "Optional human-readable reason for the halt.", - required: false, - }], - outputs: vec![FieldSchema { - name: "state", - ty: TypeSchema::Ref("HaltState"), - comment: "Switch snapshot after engaging.", - required: true, - }], - }, - "resume" => ControllerSchema { - namespace: "emergency", - function: "resume", - description: "Clear the emergency stop so automation may resume.", - inputs: vec![], - outputs: vec![FieldSchema { - name: "state", - ty: TypeSchema::Ref("HaltState"), - comment: "Switch snapshot after clearing.", - required: true, - }], - }, - "status" => ControllerSchema { - namespace: "emergency", - function: "status", - description: "Read the current emergency-stop switch state.", - inputs: vec![], - outputs: vec![FieldSchema { - name: "state", - ty: TypeSchema::Ref("HaltState"), - comment: "Current switch snapshot.", - required: true, - }], - }, - _ => ControllerSchema { - namespace: "emergency", - function: "unknown", - description: "Unknown emergency function.", - inputs: vec![], - outputs: vec![FieldSchema { name: "error", ty: TypeSchema::String, comment: "Schema not defined.", required: true }], - }, - } -} - -fn handle_stop(params: Map) -> ControllerFuture { - Box::pin(async move { - let reason = match params.get("reason") { - Some(Value::String(s)) => Some(s.clone()), - _ => None, - }; - to_json(ops::emergency_stop(reason, "user").await) - }) -} - -fn handle_resume(_params: Map) -> ControllerFuture { - Box::pin(async move { to_json(ops::emergency_resume("user").await) }) -} - -fn handle_status(_params: Map) -> ControllerFuture { - Box::pin(async move { to_json(ops::emergency_status().await) }) -} - -fn to_json(outcome: crate::rpc::RpcOutcome) -> Result { - outcome.into_cli_compatible_json() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn registered_controllers_match_schemas() { - let c = all_emergency_registered_controllers(); - assert_eq!(c.len(), 3); - let names: Vec<_> = c.iter().map(|c| c.schema.function).collect(); - assert_eq!(names, vec!["stop", "resume", "status"]); - } - - #[test] - fn stop_schema_has_optional_reason() { - let s = schemas("stop"); - assert_eq!(s.namespace, "emergency"); - assert_eq!(s.inputs[0].name, "reason"); - assert!(!s.inputs[0].required); - } - - #[test] - fn resume_status_and_unknown_schema_arms() { - assert_eq!(schemas("resume").function, "resume"); - assert!(schemas("resume").inputs.is_empty()); - assert_eq!(schemas("status").function, "status"); - assert!(schemas("status").inputs.is_empty()); - // The catch-all arm renders a placeholder rather than panicking. - assert_eq!(schemas("nope").function, "unknown"); - assert_eq!(schemas("nope").outputs[0].name, "error"); - } - - fn json_engaged(v: &Value) -> bool { - // stop/resume emit a diagnostic log → enveloped `{result, logs}`; - // status has no log → bare value. Normalize both. - let obj = v.get("result").unwrap_or(v); - obj.get("engaged") - .and_then(|e| e.as_bool()) - .unwrap_or(false) - } - - #[tokio::test] - async fn handlers_drive_stop_status_resume() { - let _g = crate::openhuman::emergency_stop::state::EMERGENCY_TEST_GUARD - .lock() - .unwrap_or_else(|e| e.into_inner()); - let _reset = crate::openhuman::emergency_stop::state::ClearEmergencyOnDrop; - - let mut params = Map::new(); - params.insert("reason".into(), Value::String("verify".into())); - let stopped = handle_stop(params).await.expect("handle_stop ok"); - assert!(json_engaged(&stopped)); - - let status = handle_status(Map::new()).await.expect("handle_status ok"); - assert!(json_engaged(&status)); - - let resumed = handle_resume(Map::new()).await.expect("handle_resume ok"); - assert!(!json_engaged(&resumed)); - } -} diff --git a/src/openhuman/emergency_stop/state.rs b/src/openhuman/emergency_stop/state.rs deleted file mode 100644 index 91a910b3a1..0000000000 --- a/src/openhuman/emergency_stop/state.rs +++ /dev/null @@ -1,174 +0,0 @@ -//! Process-global emergency-stop switch. Mirrors the `ApprovalGate` -//! `OnceLock` install pattern: `init_global` is idempotent, `try_global` -//! returns `None` when never installed (CLI/headless → never blocks). - -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, OnceLock}; - -use super::types::HaltState; - -static GLOBAL_STOP: OnceLock> = OnceLock::new(); - -#[derive(Debug)] -struct HaltInfo { - reason: Option, - engaged_at_ms: u64, - source: String, -} - -/// Coordinator for the emergency-stop kill switch. -#[derive(Debug)] -pub struct EmergencyStop { - engaged: AtomicBool, - info: Mutex>, -} - -impl EmergencyStop { - /// Install the process-global switch. Idempotent — re-install returns the - /// existing switch so repeated boots in tests don't panic. - pub fn init_global() -> Arc { - if let Some(existing) = GLOBAL_STOP.get() { - return existing.clone(); - } - let stop = Arc::new(EmergencyStop { - engaged: AtomicBool::new(false), - info: Mutex::new(None), - }); - let _ = GLOBAL_STOP.set(stop.clone()); - GLOBAL_STOP.get().cloned().unwrap_or(stop) - } - - /// The global switch when installed; `None` means "no switch" → callers - /// treat as not-engaged (never block). - pub fn try_global() -> Option> { - GLOBAL_STOP.get().cloned() - } - - /// Whether automation is currently halted. - pub fn is_engaged(&self) -> bool { - self.engaged.load(Ordering::SeqCst) - } - - /// Engage the halt. Idempotent — re-engaging refreshes reason/source/time. - /// - /// The `engaged` flag is written **inside** the `info` lock so the - /// (flag, info) pair transitions atomically for any reader that takes the - /// lock (`snapshot`). The lock-free `is_engaged()` fast path used by the - /// enforcement chokepoints reads the flag directly and is eventually - /// consistent, which is all a fail-closed guard needs. - pub fn engage(&self, reason: Option, source: &str, now_ms: u64) { - let mut guard = self.info.lock().unwrap_or_else(|e| e.into_inner()); - *guard = Some(HaltInfo { - reason, - engaged_at_ms: now_ms, - source: source.to_string(), - }); - self.engaged.store(true, Ordering::SeqCst); - } - - /// Clear the halt. Idempotent. Flag + info are cleared under one lock so - /// a concurrent `snapshot` never observes an inconsistent pair. - pub fn clear(&self) { - let mut guard = self.info.lock().unwrap_or_else(|e| e.into_inner()); - *guard = None; - self.engaged.store(false, Ordering::SeqCst); - } - - /// Current snapshot for RPC/UI. Reads the flag under the `info` lock so the - /// returned (engaged, info) pair is always consistent with `engage`/`clear`. - pub fn snapshot(&self) -> HaltState { - let guard = self.info.lock().unwrap_or_else(|e| e.into_inner()); - if !self.engaged.load(Ordering::SeqCst) { - return HaltState::default(); - } - match guard.as_ref() { - Some(info) => HaltState { - engaged: true, - reason: info.reason.clone(), - engaged_at_ms: Some(info.engaged_at_ms), - source: Some(info.source.clone()), - }, - None => HaltState { - engaged: true, - ..Default::default() - }, - } - } -} - -/// Shared, crate-visible serialization guard for tests that touch the -/// process-global `EmergencyStop`. Rust runs unit tests in parallel within a -/// single test binary, so tests in `ops.rs`, the tinyagents middleware, and -/// `screen_intelligence::ops` all mutate the SAME global and would race. Every -/// global-touching test must lock this before engaging/clearing the switch. -#[cfg(test)] -pub(crate) static EMERGENCY_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(()); - -/// RAII guard that clears the process-global switch on drop, so a test that -/// panics mid-way (assertion failure / `unwrap`) can't leak an engaged state -/// into a later test. Construct it right after `EmergencyStop::init_global()`. -#[cfg(test)] -pub(crate) struct ClearEmergencyOnDrop; - -#[cfg(test)] -impl Drop for ClearEmergencyOnDrop { - fn drop(&mut self) { - if let Some(stop) = EmergencyStop::try_global() { - stop.clear(); - } - } -} - -/// Global convenience: is a switch installed AND engaged? False when no -/// switch is installed (CLI/headless) so those paths are never blocked. -pub fn is_engaged_global() -> bool { - EmergencyStop::try_global() - .map(|s| s.is_engaged()) - .unwrap_or(false) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn engage_then_snapshot_reports_engaged() { - let stop = EmergencyStop { - engaged: AtomicBool::new(false), - info: Mutex::new(None), - }; - assert!(!stop.is_engaged()); - stop.engage(Some("user".into()), "user", 1234); - assert!(stop.is_engaged()); - let snap = stop.snapshot(); - assert!(snap.engaged); - assert_eq!(snap.reason.as_deref(), Some("user")); - assert_eq!(snap.engaged_at_ms, Some(1234)); - assert_eq!(snap.source.as_deref(), Some("user")); - } - - #[test] - fn clear_resets_to_default_snapshot() { - let stop = EmergencyStop { - engaged: AtomicBool::new(false), - info: Mutex::new(None), - }; - stop.engage(None, "hotkey", 1); - stop.clear(); - assert!(!stop.is_engaged()); - assert_eq!(stop.snapshot(), HaltState::default()); - } - - #[test] - fn engage_is_idempotent_and_refreshes() { - let stop = EmergencyStop { - engaged: AtomicBool::new(false), - info: Mutex::new(None), - }; - stop.engage(Some("a".into()), "user", 1); - stop.engage(Some("b".into()), "system", 2); - assert!(stop.is_engaged()); - assert_eq!(stop.snapshot().reason.as_deref(), Some("b")); - assert_eq!(stop.snapshot().source.as_deref(), Some("system")); - } -} diff --git a/src/openhuman/emergency_stop/types.rs b/src/openhuman/emergency_stop/types.rs deleted file mode 100644 index f83d179355..0000000000 --- a/src/openhuman/emergency_stop/types.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! Serde domain types for the emergency-stop kill switch. - -use serde::{Deserialize, Serialize}; - -/// Snapshot of the emergency-stop switch, returned by every emergency RPC and -/// surfaced in the UI. `engaged == false` is the resting state. -#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] -pub struct HaltState { - /// Whether automation is currently halted. - pub engaged: bool, - /// Human-readable reason for the halt (redacted of PII), when engaged. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reason: Option, - /// Unix-epoch milliseconds when the halt was engaged, when engaged. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub engaged_at_ms: Option, - /// Who engaged it: `"user"`, `"hotkey"`, or `"system"`, when engaged. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source: Option, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_halt_state_is_not_engaged() { - let s = HaltState::default(); - assert!(!s.engaged); - assert!(s.reason.is_none()); - assert!(s.engaged_at_ms.is_none()); - } - - #[test] - fn resting_state_serializes_to_engaged_false_only() { - let json = serde_json::to_string(&HaltState::default()).unwrap(); - assert_eq!(json, r#"{"engaged":false}"#); - } - - #[test] - fn engaged_state_roundtrips() { - let s = HaltState { - engaged: true, - reason: Some("user".into()), - engaged_at_ms: Some(42), - source: Some("user".into()), - }; - let back: HaltState = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap(); - assert_eq!(s, back); - } -} diff --git a/src/openhuman/learning/startup.rs b/src/openhuman/learning/startup.rs index 2da5a5de62..fbde7c8f87 100644 --- a/src/openhuman/learning/startup.rs +++ b/src/openhuman/learning/startup.rs @@ -286,12 +286,32 @@ mod tests { "signature body must yield at least one identity candidate" ); - publish_email_doc(&source_id, &body); - let got = wait_for_candidates(&source_id, expected).await; - assert_eq!( - got, expected, + // The email-signature subscriber lives on the process-wide *global* + // event bus and pushes into the shared, bounded `candidate::global()` + // ring. Under the full-suite coverage run (thousands of tests in one + // process — the module filter widened when this tree stopped touching + // only `learning/`), that global bus is under heavy concurrent load: a + // single published event can be dropped to tokio broadcast lag, or its + // candidates evicted from the 1024-entry ring before we read them. That + // made the original single-publish assertion flaky (it passes in + // isolation but fails deterministically in busy CI). Re-publish on every + // poll tick until *this* source's candidates land. The subscriber is + // idempotent per event and we filter by our unique `source_id`, so this + // only ever proves the subscriber *fires* — it can never mask a missing + // one (a never-registered subscriber yields 0 forever and still fails). + let mut got = 0; + for _ in 0..200 { + publish_email_doc(&source_id, &body); + tokio::time::sleep(Duration::from_millis(20)).await; + got = candidates_for(&source_id); + if got >= expected { + break; + } + } + assert!( + got >= expected, "email-signature subscriber must push the parsed identity candidates \ - with no channel configured anywhere (#5003)" + with no channel configured anywhere (#5003); got {got}, expected >= {expected}" ); } diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index b890b5bcaa..8509fcabba 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -49,7 +49,6 @@ pub mod dev_paths; pub mod devices; pub mod doctor; pub mod embeddings; -pub mod emergency_stop; pub mod encryption; pub mod file_state; pub mod file_storage; diff --git a/src/openhuman/screen_intelligence/ops.rs b/src/openhuman/screen_intelligence/ops.rs index 76ad746736..4608e8b00b 100644 --- a/src/openhuman/screen_intelligence/ops.rs +++ b/src/openhuman/screen_intelligence/ops.rs @@ -152,19 +152,6 @@ pub async fn accessibility_capture_image_ref() -> Result Result, String> { - // Emergency stop: refuse desktop input while halted. `panic_stop` is - // exempt so a stop is never blocked by a stop. - if payload.action != "panic_stop" && crate::openhuman::emergency_stop::is_engaged_global() { - tracing::warn!(action = %payload.action, "[emergency] accessibility_input_action blocked — kill switch engaged"); - return Ok(RpcOutcome::single_log( - InputActionResult { - accepted: false, - blocked: true, - reason: Some("emergency_stop".to_string()), - }, - "screen intelligence input blocked by emergency stop", - )); - } let result = screen_intelligence::global_engine() .input_action(payload) .await?; @@ -320,59 +307,4 @@ mod tests { // Either Ok or Err — just ensure the call doesn't panic. let _ = outcome; } - - #[tokio::test] - async fn input_action_blocked_while_emergency_engaged() { - use crate::openhuman::emergency_stop::state::{ClearEmergencyOnDrop, EMERGENCY_TEST_GUARD}; - use crate::openhuman::emergency_stop::EmergencyStop; - let _g = EMERGENCY_TEST_GUARD - .lock() - .unwrap_or_else(|e| e.into_inner()); - let stop = EmergencyStop::init_global(); - // Panic-safe cleanup: resets the process-global even if an assertion - // below panics, so a leaked engaged state can't poison later tests. - let _reset = ClearEmergencyOnDrop; - stop.engage(Some("test".into()), "user", 0); - let params = InputActionParams { - action: "click".into(), - x: Some(1), - y: Some(1), - button: None, - text: None, - key: None, - modifiers: None, - }; - let out = accessibility_input_action(params).await.unwrap(); - assert!(!out.value.accepted); - assert!(out.value.blocked); - assert_eq!(out.value.reason.as_deref(), Some("emergency_stop")); - } - - #[tokio::test] - async fn panic_stop_passes_even_while_emergency_engaged() { - use crate::openhuman::emergency_stop::state::{ClearEmergencyOnDrop, EMERGENCY_TEST_GUARD}; - use crate::openhuman::emergency_stop::EmergencyStop; - let _g = EMERGENCY_TEST_GUARD - .lock() - .unwrap_or_else(|e| e.into_inner()); - let stop = EmergencyStop::init_global(); - let _reset = ClearEmergencyOnDrop; - stop.engage(None, "user", 0); - let params = InputActionParams { - action: "panic_stop".into(), - x: None, - y: None, - button: None, - text: None, - key: None, - modifiers: None, - }; - // `panic_stop` must NOT be short-circuited by the emergency guard: the - // call reaches the engine rather than returning the guard's blocked - // outcome. Whatever the engine reports, it is never the emergency block. - let out = accessibility_input_action(params).await; - if let Ok(outcome) = out { - assert_ne!(outcome.value.reason.as_deref(), Some("emergency_stop")); - } - } } diff --git a/src/openhuman/tinyagents/middleware.rs b/src/openhuman/tinyagents/middleware.rs index 6ca9ef1f75..e87b528e14 100644 --- a/src/openhuman/tinyagents/middleware.rs +++ b/src/openhuman/tinyagents/middleware.rs @@ -1029,26 +1029,6 @@ impl ApprovalSecurityMiddleware { } } -/// The fail-closed denial a halted external-effect tool call resolves to. -/// Returns `Some(result)` iff the emergency stop is engaged, otherwise `None`. -/// Extracted from `wrap_tool` so the deny path is unit-testable without -/// constructing a full `RunContext`/`ToolHandler` runtime. -fn emergency_halt_denial(call_id: String, name: String) -> Option { - if !crate::openhuman::emergency_stop::is_engaged_global() { - return None; - } - let reason = "Emergency stop is engaged — this action is blocked until you resume automation." - .to_string(); - Some(TaToolResult { - call_id, - name, - content: reason.clone(), - raw: None, - error: Some(reason), - elapsed_ms: 0, - }) -} - #[async_trait] impl ToolMiddleware<()> for ApprovalSecurityMiddleware { fn name(&self) -> &str { @@ -1066,12 +1046,6 @@ impl ToolMiddleware<()> for ApprovalSecurityMiddleware { // approval await. let mut audit_id: Option = None; if self.has_external_effect(&call.name, &call.arguments) { - // Emergency stop: refuse every external-effect tool while halted, - // before touching the approval gate. Fail-closed. - if let Some(denial) = emergency_halt_denial(call.id.clone(), call.name.clone()) { - tracing::warn!(tool = %call.name, "[tinyagents::mw] emergency stop engaged — refusing tool call"); - return Ok(MiddlewareToolOutcome::Result(denial)); - } if let Some(gate) = ApprovalGate::try_global() { let summary = summarize_action(&call.name, &call.arguments); let redacted = redact_args(&call.arguments); @@ -4562,39 +4536,6 @@ mod tests { assert!(!mw.has_external_effect("missing", &json!({}))); } - /// Exercises the emergency-stop guard the middleware consults before the - /// approval gate. Constructing a full `RunContext`/`ToolHandler` to drive - /// `wrap_tool` end-to-end is heavy, so this asserts the exact global - /// predicate the guard branches on flips as the switch engages/clears. - #[test] - fn emergency_guard_blocks_when_engaged() { - let _g = crate::openhuman::emergency_stop::state::EMERGENCY_TEST_GUARD - .lock() - .unwrap_or_else(|e| e.into_inner()); - use crate::openhuman::emergency_stop::state::ClearEmergencyOnDrop; - use crate::openhuman::emergency_stop::EmergencyStop; - let stop = EmergencyStop::init_global(); - // Panic-safe: always resets the process-global on drop, even on an - // assertion failure below, so a leaked engaged state can't poison - // later tests. - let _reset = ClearEmergencyOnDrop; - stop.clear(); - - // Not halted → no denial, and the guard predicate is false. - assert!(!crate::openhuman::emergency_stop::is_engaged_global()); - assert!(emergency_halt_denial("c1".into(), "send".into()).is_none()); - - // Halted → the deny result is produced with the call's id/name and an - // error payload (this is the exact branch `wrap_tool` returns). - stop.engage(Some("test".into()), "user", 0); - assert!(crate::openhuman::emergency_stop::is_engaged_global()); - let denial = - emergency_halt_denial("c1".into(), "send".into()).expect("halted → denial produced"); - assert_eq!(denial.call_id, "c1"); - assert_eq!(denial.name, "send"); - assert!(denial.error.is_some()); - } - // ── MemoryProtocolMiddleware (issue #4116) ────────────────────────────── use crate::openhuman::agent::harness::memory_protocol::MEMORY_PROTOCOL_MARKER; diff --git a/src/openhuman/web_chat/event_bus.rs b/src/openhuman/web_chat/event_bus.rs index 0afdbe7646..63902b509c 100644 --- a/src/openhuman/web_chat/event_bus.rs +++ b/src/openhuman/web_chat/event_bus.rs @@ -40,30 +40,6 @@ pub fn register_approval_surface_subscriber() { } } -static AUTOMATION_HALT_HANDLE: OnceLock = OnceLock::new(); - -/// Register the emergency-stop bridge: `AutomationHalted`/`AutomationResumed` -/// (domain `system`) → the `automation_halt` socket event, broadcast to every -/// client via the `"system"` room. Idempotent (OnceLock-guarded). -pub fn register_automation_halt_subscriber() { - if AUTOMATION_HALT_HANDLE.get().is_some() { - return; - } - match crate::core::event_bus::subscribe_global(Arc::new(AutomationHaltSubscriber)) { - Some(handle) => { - let _ = AUTOMATION_HALT_HANDLE.set(handle); - log::info!( - "[web-channel] automation-halt subscriber registered (domain=system) — bridges AutomationHalted/AutomationResumed → automation_halt socket event" - ); - } - None => { - log::warn!( - "[web-channel] failed to register automation-halt subscriber — bus not initialized" - ); - } - } -} - static ARTIFACT_SURFACE_HANDLE: OnceLock = OnceLock::new(); pub fn register_artifact_surface_subscriber() { @@ -406,60 +382,9 @@ impl EventHandler for ApprovalSurfaceSubscriber { } } -struct AutomationHaltSubscriber; - -#[async_trait] -impl EventHandler for AutomationHaltSubscriber { - fn name(&self) -> &str { - "web_chat::automation_halt" - } - - fn domains(&self) -> Option<&[&str]> { - Some(&["system"]) - } - - async fn handle(&self, event: &DomainEvent) { - match event { - DomainEvent::AutomationHalted { reason, source } => { - log::info!( - "[web-channel] automation-halt emitting automation_halt engaged=true source={source}" - ); - publish_web_channel_event(WebChannelEvent { - event: "automation_halt".to_string(), - // Broadcast room: every connected client auto-joins "system". - client_id: "system".to_string(), - args: Some(serde_json::json!({ - "engaged": true, - "reason": reason, - "source": source, - })), - ..Default::default() - }); - } - DomainEvent::AutomationResumed { source } => { - log::info!( - "[web-channel] automation-halt emitting automation_halt engaged=false source={source}" - ); - publish_web_channel_event(WebChannelEvent { - event: "automation_halt".to_string(), - // Broadcast room: every connected client auto-joins "system". - client_id: "system".to_string(), - args: Some(serde_json::json!({ - "engaged": false, - "source": source, - })), - ..Default::default() - }); - } - _ => {} - } - } -} - #[cfg(test)] mod tests { use super::*; - use crate::core::event_bus::{DomainEvent, EventHandler}; /// `fresh_approval_surface_subscription` returns `Some` when the global event bus has /// been initialised and `None` otherwise (bus not started). It must never return `None` @@ -492,108 +417,6 @@ mod tests { drop(h2); } - /// `AutomationHaltSubscriber::handle` publishes a correctly-shaped - /// `WebChannelEvent` to the web-channel broadcast bus for both - /// `AutomationHalted` and `AutomationResumed` domain events. - /// - /// This test exercises the payload contract directly by calling `handle` - /// on a freshly-constructed `AutomationHaltSubscriber` and asserting the - /// event fields the socket bridge relies on: - /// - `event == "automation_halt"` (the wire event name) - /// - `client_id == "system"` (the broadcast room every client auto-joins) - /// - `args.engaged` toggled correctly - /// - `args.reason` and `args.source` echoed from the domain event - #[tokio::test] - async fn automation_halt_subscriber_handle_publishes_correct_payload() { - // Subscribe BEFORE calling handle so the broadcast receiver is created - // before any event is sent (broadcast channels only buffer messages - // sent AFTER the receiver subscribed). - let mut rx = subscribe_web_channel_events(); - - let sub = AutomationHaltSubscriber; - - // --- AutomationHalted --- - sub.handle(&DomainEvent::AutomationHalted { - reason: Some("test".into()), - source: "user".into(), - }) - .await; - - let halted = rx - .try_recv() - .expect("AutomationHalted must publish a WebChannelEvent"); - assert_eq!( - halted.event, "automation_halt", - "event name mismatch for halted" - ); - assert_eq!( - halted.client_id, "system", - "automation_halt must broadcast via the 'system' room (critical: every client auto-joins this room)" - ); - let args = halted - .args - .as_ref() - .expect("AutomationHalted args must be Some"); - assert_eq!( - args["engaged"], true, - "AutomationHalted must set engaged=true" - ); - assert_eq!( - args["reason"], "test", - "AutomationHalted must echo the reason" - ); - assert_eq!( - args["source"], "user", - "AutomationHalted must echo the source" - ); - - // --- AutomationResumed --- - sub.handle(&DomainEvent::AutomationResumed { - source: "user".into(), - }) - .await; - - let resumed = rx - .try_recv() - .expect("AutomationResumed must publish a WebChannelEvent"); - assert_eq!( - resumed.event, "automation_halt", - "event name mismatch for resumed" - ); - assert_eq!( - resumed.client_id, "system", - "automation_halt (resumed) must broadcast via the 'system' room" - ); - let args = resumed - .args - .as_ref() - .expect("AutomationResumed args must be Some"); - assert_eq!( - args["engaged"], false, - "AutomationResumed must set engaged=false" - ); - assert_eq!( - args["source"], "user", - "AutomationResumed must echo the source" - ); - } - - /// `register_automation_halt_subscriber` is OnceLock-guarded: after the bus - /// is initialised the first call installs the subscriber and subsequent - /// calls are no-ops (they must not panic or re-subscribe). - #[tokio::test] - async fn register_automation_halt_subscriber_is_idempotent() { - crate::core::event_bus::init_global(crate::core::event_bus::DEFAULT_CAPACITY); - register_automation_halt_subscriber(); - assert!( - AUTOMATION_HALT_HANDLE.get().is_some(), - "first registration must install the subscriber handle" - ); - // Second call is a no-op — must not panic. - register_automation_halt_subscriber(); - assert!(AUTOMATION_HALT_HANDLE.get().is_some()); - } - /// Drain the web-channel receiver until an `external_transfer_pending` event /// whose `args.service` matches `marker` arrives (the bus is process-wide). async fn find_egress_web_event( diff --git a/src/openhuman/web_chat/mod.rs b/src/openhuman/web_chat/mod.rs index 2e8527413d..764acd3ab9 100644 --- a/src/openhuman/web_chat/mod.rs +++ b/src/openhuman/web_chat/mod.rs @@ -22,8 +22,8 @@ pub(crate) use web_errors::{ // Public API — event bus pub use event_bus::{ publish_web_channel_event, register_approval_surface_subscriber, - register_artifact_surface_subscriber, register_automation_halt_subscriber, - register_egress_surface_subscriber, subscribe_web_channel_events, + register_artifact_surface_subscriber, register_egress_surface_subscriber, + subscribe_web_channel_events, }; // Test-only: OnceLock-bypassing approval bridge for per-runtime integration tests. diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index a1463bb2e6..eba600df18 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -1228,90 +1228,6 @@ async fn json_rpc_config_update_browser_settings_persists_backend() { rpc_join.abort(); } -/// Emergency-stop kill switch over JSON-RPC: status(not halted) → stop → -/// status(halted) → resume → status(not halted). Asserts `engaged` flips -/// across the full round-trip (#4255). -#[tokio::test] -async fn json_rpc_emergency_stop_roundtrip_over_rpc() { - let _env_lock = json_rpc_e2e_env_lock(); - - // Panic-safe cleanup: the switch is a process-global, so guarantee it is - // cleared even if an assertion below panics before the resume call — a - // leaked engaged state would fail-close unrelated tests in this binary. - struct ResumeOnDrop; - impl Drop for ResumeOnDrop { - fn drop(&mut self) { - if let Some(stop) = - openhuman_core::openhuman::emergency_stop::EmergencyStop::try_global() - { - stop.clear(); - } - } - } - let _reset = ResumeOnDrop; - - let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; - let rpc_base = format!("http://{rpc_addr}"); - - // status: not halted (no logs → bare HaltState). - let s0 = post_json_rpc(&rpc_base, 4255_1, "openhuman.emergency_status", json!({})).await; - let s0_result = assert_no_jsonrpc_error(&s0, "emergency_status initial"); - let s0_state = peel_logs_envelope(s0_result); - assert_eq!( - s0_state.get("engaged").and_then(Value::as_bool), - Some(false), - "switch must start not engaged: {s0_state}" - ); - - // stop: engage the switch. - let stopped = post_json_rpc( - &rpc_base, - 4255_2, - "openhuman.emergency_stop", - json!({ "reason": "e2e" }), - ) - .await; - let stopped_result = assert_no_jsonrpc_error(&stopped, "emergency_stop"); - let stopped_state = peel_logs_envelope(stopped_result); - assert_eq!( - stopped_state.get("engaged").and_then(Value::as_bool), - Some(true), - "stop response must report engaged: {stopped_state}" - ); - - // status: halted. - let s1 = post_json_rpc(&rpc_base, 4255_3, "openhuman.emergency_status", json!({})).await; - let s1_result = assert_no_jsonrpc_error(&s1, "emergency_status halted"); - let s1_state = peel_logs_envelope(s1_result); - assert_eq!( - s1_state.get("engaged").and_then(Value::as_bool), - Some(true), - "status must report engaged after stop: {s1_state}" - ); - - // resume: clear the switch. - let resumed = post_json_rpc(&rpc_base, 4255_4, "openhuman.emergency_resume", json!({})).await; - let resumed_result = assert_no_jsonrpc_error(&resumed, "emergency_resume"); - let resumed_state = peel_logs_envelope(resumed_result); - assert_eq!( - resumed_state.get("engaged").and_then(Value::as_bool), - Some(false), - "resume response must report not engaged: {resumed_state}" - ); - - // status: not halted again. - let s2 = post_json_rpc(&rpc_base, 4255_5, "openhuman.emergency_status", json!({})).await; - let s2_result = assert_no_jsonrpc_error(&s2, "emergency_status resumed"); - let s2_state = peel_logs_envelope(s2_result); - assert_eq!( - s2_state.get("engaged").and_then(Value::as_bool), - Some(false), - "status must report not engaged after resume: {s2_state}" - ); - - rpc_join.abort(); -} - #[tokio::test] async fn json_rpc_tokenjuice_detect_and_cache_stats() { let _env_lock = json_rpc_e2e_env_lock(); From 09f552ce40d77fb8d02f757ada9837abac82976f Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:39:34 +0300 Subject: [PATCH 04/56] =?UTF-8?q?fix(transcript):=20lossless=20transcript?= =?UTF-8?q?=20restore=20=E2=80=94=20full-fidelity=20cold-boot=20resume,=20?= =?UTF-8?q?per-turn=20thinking=20hydration,=20seq=20envelope=20(#5077)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/conversations/Conversations.tsx | 78 +- .../components/InterruptedAnswer.test.tsx | 30 + .../components/InterruptedAnswer.tsx | 59 ++ .../components/PastTurnInsights.test.tsx | 70 ++ .../components/PastTurnInsights.tsx | 60 ++ .../timeline/ConversationTimeline.tsx | 4 - .../features/conversations/timeline/types.ts | 7 +- app/src/services/chatService.ts | 20 + .../__tests__/chatRuntimeSlice.thunk.test.ts | 37 +- app/src/store/chatRuntimeSlice.test.ts | 147 ++++ app/src/store/chatRuntimeSlice.ts | 134 +++- app/src/types/turnState.ts | 5 + src/core/socketio.rs | 9 + .../agent/harness/session/runtime.rs | 99 +++ src/openhuman/agent/harness/session/tests.rs | 165 +++++ src/openhuman/channels/proactive.rs | 3 + src/openhuman/threads/turn_state/mirror.rs | 94 ++- .../threads/turn_state/mirror_tests.rs | 154 ++++ .../threads/turn_state/store_tests.rs | 105 ++- src/openhuman/threads/turn_state/types.rs | 7 + src/openhuman/web_chat/presentation.rs | 7 + src/openhuman/web_chat/progress_bridge.rs | 688 +++++++++++------- src/openhuman/web_chat/run_task.rs | 70 +- 23 files changed, 1699 insertions(+), 353 deletions(-) create mode 100644 app/src/features/conversations/components/InterruptedAnswer.test.tsx create mode 100644 app/src/features/conversations/components/InterruptedAnswer.tsx create mode 100644 app/src/features/conversations/components/PastTurnInsights.test.tsx create mode 100644 app/src/features/conversations/components/PastTurnInsights.tsx diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index 2f42ed9427..243f234dc9 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -46,6 +46,8 @@ import { } from '../../features/conversations/components/ThreadGoalChip'; import { ThreadTodoStrip } from '../../features/conversations/components/ThreadTodoStrip'; import { ToolTimelineBlock } from '../../features/conversations/components/ToolTimelineBlock'; +import { InterruptedAnswer } from '../../features/conversations/components/InterruptedAnswer'; +import { PastTurnInsights } from '../../features/conversations/components/PastTurnInsights'; import { evaluateComposerSend, getComposerBlockedSendFeedback, @@ -106,6 +108,7 @@ import { hydrateThreadUsage, markSubagentCancelled, markThreadSendPending, + type ProcessingTranscriptItem, type QueuedFollowup, registerParallelRequest, setTaskBoardForThread, @@ -239,6 +242,16 @@ const EMPTY_QUEUED_FOLLOWUPS: Record = {}; // Stable empty reference for the per-thread past-turn timelines map, so the // derived value keeps the same identity when the slice field is absent. const EMPTY_TURN_TIMELINES: Record = {}; +// Sibling stable empty for the per-thread past-turn processing transcripts map +// (restore-fidelity fix 1). +const EMPTY_TURN_TRANSCRIPTS: Record = {}; +// Stable empty transcript for a past turn that has tool rows but no persisted +// reasoning/narration trail (legacy snapshot), so `PastTurnInsights` falls back +// to the tool-only view without allocating a fresh array each render. +const EMPTY_TRANSCRIPT: ProcessingTranscriptItem[] = []; +// Stable empty tool-row list for a transcript-only past turn (agent thought / +// narrated but ran no tools). +const EMPTY_TRANSCRIPT_ENTRIES: ToolTimelineEntry[] = []; export function isComposerInteractionBlocked(args: { /** Whether the *currently selected* thread has an in-flight inference turn. */ @@ -398,6 +411,12 @@ const Conversations = ({ const uiLocale = useAppSelector(state => state.locale?.current ?? 'en'); const toolTimelineByThread = useAppSelector(state => state.chatRuntime.toolTimelineByThread); const turnTimelinesByThread = useAppSelector(state => state.chatRuntime.turnTimelinesByThread); + const turnTranscriptsByThread = useAppSelector( + state => state.chatRuntime.turnTranscriptsByThread + ); + const interruptedAssistantByThread = useAppSelector( + state => state.chatRuntime.interruptedAssistantByThread + ); const processingByThread = useAppSelector(state => state.chatRuntime.processingByThread); const taskBoardByThread = useAppSelector(state => state.chatRuntime.taskBoardByThread); const inferenceStatusByThread = useAppSelector( @@ -1834,21 +1853,33 @@ const Conversations = ({ const selectedThreadTurnTimelines = selectedThreadId ? (turnTimelinesByThread[selectedThreadId] ?? EMPTY_TURN_TIMELINES) : EMPTY_TURN_TIMELINES; + // Sibling map: each past turn's persisted reasoning/narration trail, so a + // reopened turn replays its thoughts, not just its tool cards (fix 1). + const selectedThreadTurnTranscripts = selectedThreadId + ? (turnTranscriptsByThread[selectedThreadId] ?? EMPTY_TURN_TRANSCRIPTS) + : EMPTY_TURN_TRANSCRIPTS; const pastTurnAnchors = useMemo(() => { - const anchors: Record = {}; + const anchors: Record< + string, + { entries: ToolTimelineEntry[]; transcript: ProcessingTranscriptItem[] } + > = {}; const seen = new Set(); for (const msg of timelineMessages) { if (msg.sender !== 'agent') continue; const requestId = msg.extraMetadata?.requestId; if (typeof requestId !== 'string' || seen.has(requestId)) continue; - const entries = selectedThreadTurnTimelines[requestId]; - if (entries && entries.length > 0) { - anchors[msg.id] = entries; + const entries = selectedThreadTurnTimelines[requestId] ?? EMPTY_TRANSCRIPT_ENTRIES; + const transcript = selectedThreadTurnTranscripts[requestId] ?? EMPTY_TRANSCRIPT; + // Anchor the turn when it has EITHER tool rows OR a reasoning/narration + // trail — a tool-less turn (agent only thought/narrated) must still + // render its restored thoughts above its answer (fix 1). + if (entries.length > 0 || transcript.length > 0) { + anchors[msg.id] = { entries, transcript }; seen.add(requestId); } } return anchors; - }, [timelineMessages, selectedThreadTurnTimelines]); + }, [timelineMessages, selectedThreadTurnTimelines, selectedThreadTurnTranscripts]); const activeSubagentTimelineEntry = selectedThreadToolTimeline.find( entry => entry.status === 'running' && entry.name.startsWith('subagent:') ); @@ -1861,6 +1892,12 @@ const Conversations = ({ const selectedStreamingAssistant = selectedThreadId ? (streamingAssistantByThread[selectedThreadId] ?? null) : null; + // The partial reply an interrupted turn left behind (restore-fidelity fix 2): + // surfaced as a settled, marked-interrupted bubble on restore so a turn that + // crashed mid-answer keeps its visible work instead of rendering blank. + const selectedInterruptedAssistant = selectedThreadId + ? (interruptedAssistantByThread[selectedThreadId] ?? null) + : null; // Live streams for concurrent parallel (forked) turns on the selected thread, // rendered as separate interleaved branch bubbles. const selectedParallelStreams = selectedThreadId @@ -1928,7 +1965,10 @@ const Conversations = ({ isSending || selectedThreadToolTimeline.length > 0 || selectedThreadProcessing.length > 0 || - Boolean(selectedStreamingAssistant); + Boolean(selectedStreamingAssistant) || + // An interrupted turn's restored partial answer must surface too, even + // before the durable message history loads (restore-fidelity fix 2). + Boolean(selectedInterruptedAssistant); // Anchor the "Agentic task insights" panel right after the latest turn's user // message — processing happens *before* the answer, so it reads above the @@ -2259,14 +2299,20 @@ const Conversations = ({ // what keeps the marker text out of both the rendered bubble and // the copy-to-clipboard action. const parsedContent = parseMessageImages(msg.content ?? ''); - const pastTurnEntries = pastTurnAnchors[msg.id]; + const pastTurn = pastTurnAnchors[msg.id]; return ( - {/* Past-turn process trail (Phase 5): each older settled turn's - tool timeline, collapsed, above the answer it produced. */} - {pastTurnEntries ? ( + {/* Past-turn process trail (Phase 5 + restore-fidelity fix 1): + each older settled turn's interleaved reasoning/narration + + tool steps (and restored sub-agent transcripts), collapsed, + above the answer it produced. Falls back to tool-cards-only + for legacy snapshots with no persisted transcript. */} + {pastTurn ? (
- +
) : null}
@@ -2661,6 +2707,16 @@ const Conversations = ({
)} + {/* Interrupted turn's partial answer (restore-fidelity fix 2): + a settled, marked-interrupted bubble surfaced on restore. Only + when NOT streaming live (the buffer is cleared by any live turn + in the slice; this guard is belt-and-braces). */} + {!isSending && selectedInterruptedAssistant ? ( + + ) : null} {/* Parallel (forked) branch streams — concurrent turns on this thread, each its own labeled bubble so they don't collide with the primary stream above. */} diff --git a/app/src/features/conversations/components/InterruptedAnswer.test.tsx b/app/src/features/conversations/components/InterruptedAnswer.test.tsx new file mode 100644 index 0000000000..ccd450197a --- /dev/null +++ b/app/src/features/conversations/components/InterruptedAnswer.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { describe, expect, it } from 'vitest'; + +import { store } from '../../../store'; +import { InterruptedAnswer } from './InterruptedAnswer'; + +function renderInStore(ui: React.ReactNode) { + return render({ui}); +} + +describe('InterruptedAnswer', () => { + it('surfaces the partial reply with an interrupted marker', () => { + renderInStore( + + ); + + const block = screen.getByTestId('interrupted-answer'); + expect(block.textContent).toContain('Here is the partial answer'); + // The reasoning it had streamed is kept in a collapsed block. + expect(block.textContent).toContain('was reasoning about it'); + // Marked interrupted rather than presented as a finished answer. + expect(screen.getByTestId('interrupted-answer-marker')).toBeTruthy(); + }); + + it('renders nothing when neither content nor thinking has any text', () => { + const { container } = renderInStore(); + expect(container.querySelector('[data-testid="interrupted-answer"]')).toBeNull(); + }); +}); diff --git a/app/src/features/conversations/components/InterruptedAnswer.tsx b/app/src/features/conversations/components/InterruptedAnswer.tsx new file mode 100644 index 0000000000..5989bf81b2 --- /dev/null +++ b/app/src/features/conversations/components/InterruptedAnswer.tsx @@ -0,0 +1,59 @@ +import { useT } from '../../../lib/i18n/I18nContext'; +import { BubbleMarkdown } from './AgentMessageBubble'; + +/** + * The partial assistant reply left behind by an INTERRUPTED turn — the core + * process that was streaming it exited before `chat_done`, so no final message + * was ever committed to the durable thread log (restore-fidelity fix 2). + * + * Unlike the live streaming preview, this is a SETTLED buffer: it renders as a + * static agent bubble (no pulsing cursor, full Markdown like a finished answer) + * carrying an "Interrupted" marker, plus the hidden reasoning it had streamed in + * a collapsed block. It is deliberately NOT written into the durable message + * list — it is a restore-time surfacing of what the agent had produced, so the + * user sees the partial work instead of a blank turn. + */ +export function InterruptedAnswer({ + content, + thinking, +}: { + content: string; + thinking: string; +}) { + const { t } = useT(); + const trimmedContent = content.trim(); + const trimmedThinking = thinking.trim(); + // Nothing persisted to show — render nothing rather than an empty marked bubble. + if (!trimmedContent && !trimmedThinking) return null; + + return ( +
+
+ {trimmedThinking ? ( +
+ + + 💭 + + {t('chat.thinking')} + +
+              {trimmedThinking}
+            
+
+ ) : null} +
+
+ + ⚠ + + {t('intelligence.agentWork.status.interrupted')} +
+ {trimmedContent ? : null} +
+
+
+ ); +} diff --git a/app/src/features/conversations/components/PastTurnInsights.test.tsx b/app/src/features/conversations/components/PastTurnInsights.test.tsx new file mode 100644 index 0000000000..ef0314b83b --- /dev/null +++ b/app/src/features/conversations/components/PastTurnInsights.test.tsx @@ -0,0 +1,70 @@ +import { render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { describe, expect, it } from 'vitest'; + +import { store } from '../../../store'; +import type { ProcessingTranscriptItem, ToolTimelineEntry } from '../../../store/chatRuntimeSlice'; +import { PastTurnInsights } from './PastTurnInsights'; + +function renderInStore(ui: React.ReactNode) { + return render({ui}); +} + +describe('PastTurnInsights', () => { + it('replays a restored turn as interleaved thoughts + tool rows, not just tool cards', () => { + // A reopened past turn carries both its reasoning trail and its tools. + const entries: ToolTimelineEntry[] = [ + { id: 'c1', name: 'read_file', round: 0, seq: 0, status: 'success' }, + ]; + const transcript: ProcessingTranscriptItem[] = [ + { kind: 'thinking', round: 0, seq: 1, text: 'planning the search' }, + { kind: 'toolCall', round: 0, seq: 2, callId: 'c1' }, + ]; + + renderInStore(); + + // The hidden reasoning replays (fix 1) — a restored turn is no longer + // tool-cards-only. + expect(screen.getByTestId('processing-thinking').textContent).toContain('planning the search'); + // And its tool step still renders in the interleaved view. + expect(screen.getAllByTestId('processing-tool-row').length).toBeGreaterThan(0); + }); + + it('renders restored sub-agent transcripts beneath the trail', () => { + const entries: ToolTimelineEntry[] = [ + { + id: 'subagent:task-y', + name: 'subagent:researcher', + round: 0, + seq: 0, + status: 'success', + subagent: { + taskId: 'task-y', + agentId: 'researcher', + toolCalls: [], + transcript: [{ kind: 'thinking', iteration: 1, text: 'child reasoning trail' }], + }, + }, + ]; + const transcript: ProcessingTranscriptItem[] = [ + { kind: 'narration', round: 0, seq: 0, text: 'delegating to a researcher' }, + ]; + + renderInStore(); + + const subagents = screen.getByTestId('past-turn-subagents'); + expect(subagents.textContent).toContain('child reasoning trail'); + }); + + it('falls back to the tool-only timeline for a legacy turn with no transcript', () => { + const entries: ToolTimelineEntry[] = [ + { id: 'c1', name: 'read_file', round: 0, seq: 0, status: 'success' }, + ]; + + renderInStore(); + + // The interleaved transcript view is absent; the tool-only block renders. + expect(screen.queryByTestId('processing-transcript')).toBeNull(); + expect(screen.getByTestId('agent-task-insights')).toBeTruthy(); + }); +}); diff --git a/app/src/features/conversations/components/PastTurnInsights.tsx b/app/src/features/conversations/components/PastTurnInsights.tsx new file mode 100644 index 0000000000..28d2e5c6a2 --- /dev/null +++ b/app/src/features/conversations/components/PastTurnInsights.tsx @@ -0,0 +1,60 @@ +import type { ProcessingTranscriptItem, ToolTimelineEntry } from '../../../store/chatRuntimeSlice'; +import { formatTimelineEntry } from '../../../utils/toolTimelineFormatting'; +import { ProcessingTranscriptView } from './ProcessingTranscriptView'; +import { SubagentActivityBlock, ToolTimelineBlock } from './ToolTimelineBlock'; + +/** + * The collapsed process trail rendered above a PAST (settled) turn's answer on a + * reopened thread — restore-fidelity fix 1. + * + * A restored turn must show the *same* interleaved thoughts + tools view a live + * turn does, not just its tool cards: the persisted `transcript` + * (narration/thinking/tool pointers, hydrated into + * `turnTranscriptsByThread`) drives {@link ProcessingTranscriptView}, so the + * reasoning and narration replay inline exactly where they streamed. Restored + * sub-agent transcripts (fix 4) render beneath as their own activity blocks, + * mirroring the whole-run {@link AgentProcessSourcePanel} body. + * + * Legacy turns persisted before the transcript field existed have no + * `transcript` — they fall back to the tool-only {@link ToolTimelineBlock}, so + * older threads keep rendering unchanged. + */ +export function PastTurnInsights({ + entries, + transcript, +}: { + entries: ToolTimelineEntry[]; + transcript: ProcessingTranscriptItem[]; +}) { + // No reasoning/narration trail persisted (legacy snapshot): render the + // tool-only timeline, which already nests each sub-agent's activity inline. + if (transcript.length === 0) { + return ; + } + + const subagentEntries = entries.filter(entry => entry.subagent); + + return ( +
+ {/* Interleaved narration + hidden reasoning + grouped tool steps. */} + + + {/* Sub-agents — each delegated agent's restored transcript (thoughts + + tool rows), so a reopened turn keeps its sub-agent reasoning, not just + a flat tool row. `ProcessingTranscriptView` shows the spawn as a tool + row but does not nest the child's activity, so surface it here. */} + {subagentEntries.length > 0 ? ( +
+ {subagentEntries.map(entry => ( +
+

+ {formatTimelineEntry(entry).title} +

+ +
+ ))} +
+ ) : null} +
+ ); +} diff --git a/app/src/features/conversations/timeline/ConversationTimeline.tsx b/app/src/features/conversations/timeline/ConversationTimeline.tsx index b83b3b3c1a..1014172f1a 100644 --- a/app/src/features/conversations/timeline/ConversationTimeline.tsx +++ b/app/src/features/conversations/timeline/ConversationTimeline.tsx @@ -85,10 +85,6 @@ export function ConversationTimeline({ /> ); break; - case 'reasoning': - // Reasoning is rendered inside the process/streaming affordances today; - // no standalone element yet. - break; default: break; } diff --git a/app/src/features/conversations/timeline/types.ts b/app/src/features/conversations/timeline/types.ts index c2c71aaf4e..c495b55859 100644 --- a/app/src/features/conversations/timeline/types.ts +++ b/app/src/features/conversations/timeline/types.ts @@ -50,7 +50,6 @@ export type TimelineItem = TimelineItemBase & /** True for a `parallelStreamsByThread` forked branch. */ branch: boolean; } - | { kind: 'reasoning'; text: string; settled: boolean } | { kind: 'toolCall'; /** The underlying runtime row (rendered via `ToolTimelineBlock`). */ @@ -75,11 +74,7 @@ export type TimelineItem = TimelineItemBase & export type TimelineItemKind = TimelineItem['kind']; /** The "agent process" kinds that `hideAgentInsights` suppresses. */ -export const AGENT_INSIGHT_KINDS: readonly TimelineItemKind[] = [ - 'toolCall', - 'subagentActivity', - 'reasoning', -]; +export const AGENT_INSIGHT_KINDS: readonly TimelineItemKind[] = ['toolCall', 'subagentActivity']; /** A contiguous group of items sharing a `turnId`, in render order. */ export interface TimelineTurn { diff --git a/app/src/services/chatService.ts b/app/src/services/chatService.ts index f6b25e34d6..dc3571a94a 100644 --- a/app/src/services/chatService.ts +++ b/app/src/services/chatService.ts @@ -17,6 +17,8 @@ const chatLog = debug('realtime:chat'); export interface ChatToolCallEvent { thread_id: string; request_id?: string; + /** Per-request monotonic ordering key stamped by the core progress bridge. */ + seq?: number; tool_name: string; skill_id: string; args: Record; @@ -42,6 +44,8 @@ export interface ChatToolCallEvent { export interface ChatToolResultEvent { thread_id: string; request_id?: string; + /** Per-request monotonic ordering key stamped by the core progress bridge. */ + seq?: number; tool_name: string; skill_id: string; output: string; @@ -85,6 +89,8 @@ export interface TurnUsageWire { export interface ChatDoneEvent { thread_id: string; request_id?: string; + /** Per-request monotonic ordering key stamped by the core progress bridge. */ + seq?: number; full_response: string; rounds_used: number; /** @@ -126,6 +132,8 @@ export interface ChatSegmentEvent { */ full_response: string; request_id: string; + /** Per-request monotonic ordering key stamped by the core progress bridge. */ + seq?: number; segment_index: number; segment_total: number; reaction_emoji?: string | null; @@ -147,6 +155,8 @@ export function segmentText(event: ChatSegmentEvent): string { export interface ChatInterimEvent { thread_id: string; request_id: string; + /** Per-request monotonic ordering key stamped by the core progress bridge. */ + seq?: number; /** Wire name is `full_response`; carries only this round's narration text. */ full_response: string; round: number; @@ -463,6 +473,8 @@ export interface ChatSubagentToolResultEvent { export interface ChatSubagentTextDeltaEvent { thread_id: string; request_id: string; + /** Per-request monotonic ordering key stamped by the core progress bridge. */ + seq?: number; /** Parent iteration index (inherited from the parent context). */ round: number; /** Text fragment from the sub-agent. */ @@ -478,6 +490,8 @@ export interface ChatSubagentTextDeltaEvent { export interface ChatSubagentThinkingDeltaEvent { thread_id: string; request_id: string; + /** Per-request monotonic ordering key stamped by the core progress bridge. */ + seq?: number; round: number; delta: string; subagent?: SubagentProgressDetail; @@ -491,6 +505,8 @@ export interface ChatSubagentThinkingDeltaEvent { export interface ChatTextDeltaEvent { thread_id: string; request_id: string; + /** Per-request monotonic ordering key stamped by the core progress bridge. */ + seq?: number; /** 1-based iteration index the chunk belongs to. */ round: number; /** Text fragment; may be a single token or a few characters. */ @@ -506,6 +522,8 @@ export interface ChatTextDeltaEvent { export interface ChatThinkingDeltaEvent { thread_id: string; request_id: string; + /** Per-request monotonic ordering key stamped by the core progress bridge. */ + seq?: number; round: number; delta: string; } @@ -519,6 +537,8 @@ export interface ChatThinkingDeltaEvent { export interface ChatToolArgsDeltaEvent { thread_id: string; request_id: string; + /** Per-request monotonic ordering key stamped by the core progress bridge. */ + seq?: number; round: number; tool_call_id: string; tool_name: string; diff --git a/app/src/store/__tests__/chatRuntimeSlice.thunk.test.ts b/app/src/store/__tests__/chatRuntimeSlice.thunk.test.ts index 4db54661ca..fb1cbc4495 100644 --- a/app/src/store/__tests__/chatRuntimeSlice.thunk.test.ts +++ b/app/src/store/__tests__/chatRuntimeSlice.thunk.test.ts @@ -47,7 +47,8 @@ describe('fetchAndHydrateTurnHistory', () => { requestId: string, lifecycle: string, startedAt: string, - tools: number + tools: number, + transcript?: Array> ) => ({ threadId: 'thread-hist', requestId, @@ -62,6 +63,7 @@ describe('fetchAndHydrateTurnHistory', () => { round: 0, status: 'success' as const, })), + ...(transcript ? { transcript } : {}), startedAt, updatedAt: startedAt, }); @@ -87,10 +89,43 @@ describe('fetchAndHydrateTurnHistory', () => { expect(timelines['req-0']).toBeUndefined(); }); + it('keeps each past turn\'s reasoning/narration transcript (fix 1), ordered by seq (fix 5)', async () => { + const store = configureStore({ reducer }); + mockThreadApi.getTurnStateHistory.mockResolvedValueOnce([ + // req-latest is skipped (index 0). + persistedTurn('req-latest', 'completed', '2026-06-04T13:00:00Z', 1), + // req-a: has tools AND a transcript, deliberately out of seq order. + persistedTurn('req-a', 'completed', '2026-06-04T12:00:00Z', 1, [ + { kind: 'narration', round: 0, seq: 1, text: 'second' }, + { kind: 'thinking', round: 0, seq: 0, text: 'first' }, + ]), + // req-b: a tool-LESS turn that only thought/narrated — must still be kept + // for its transcript rather than dropped. + persistedTurn('req-b', 'completed', '2026-06-04T11:00:00Z', 0, [ + { kind: 'thinking', round: 0, seq: 0, text: 'only thinking, no tools' }, + ]), + ]); + + await store.dispatch(fetchAndHydrateTurnHistory('thread-hist')); + + const transcripts = store.getState().turnTranscriptsByThread['thread-hist']; + expect(Object.keys(transcripts).sort()).toEqual(['req-a', 'req-b']); + // Ordered by persisted `seq`, not wire order. + expect(transcripts['req-a'].map(i => ('text' in i ? i.text : undefined))).toEqual([ + 'first', + 'second', + ]); + // The tool-less turn is retained purely for its transcript. + const timelines = store.getState().turnTimelinesByThread['thread-hist']; + expect(timelines['req-b']).toBeUndefined(); + expect(transcripts['req-b']).toHaveLength(1); + }); + it('swallows transport failures without throwing', async () => { const store = configureStore({ reducer }); mockThreadApi.getTurnStateHistory.mockRejectedValueOnce(new Error('boom')); await expect(store.dispatch(fetchAndHydrateTurnHistory('t'))).resolves.toBeDefined(); expect(store.getState().turnTimelinesByThread['t']).toBeUndefined(); + expect(store.getState().turnTranscriptsByThread['t']).toBeUndefined(); }); }); diff --git a/app/src/store/chatRuntimeSlice.test.ts b/app/src/store/chatRuntimeSlice.test.ts index 316c7da7fd..396ab8d67f 100644 --- a/app/src/store/chatRuntimeSlice.test.ts +++ b/app/src/store/chatRuntimeSlice.test.ts @@ -909,3 +909,150 @@ describe('hydrateRuntimeFromSnapshot — persisted tool result output', () => { expect(timeline[1].subagent?.toolCalls[0]?.result).toBe('file body'); }); }); + +describe('hydrateRuntimeFromSnapshot — interrupted partial answer (fix 2)', () => { + function makeInterruptedPartialSnapshot( + threadId: string, + over: Partial = {} + ): PersistedTurnState { + return { + threadId, + requestId: 'req-int', + lifecycle: 'interrupted', + iteration: 2, + maxIterations: 10, + streamingText: 'Here is the partial ans', + thinking: 'was still reasoning', + toolTimeline: [], + startedAt: '2026-06-23T00:00:00Z', + updatedAt: '2026-06-23T00:00:00Z', + ...over, + }; + } + + it('surfaces the persisted partial reply + thinking as a settled buffer', () => { + const store = makeStore(); + store.dispatch(hydrateRuntimeFromSnapshot({ snapshot: makeInterruptedPartialSnapshot('t-int') })); + + const state = store.getState().chatRuntime; + expect(state.interruptedAssistantByThread['t-int']).toEqual({ + requestId: 'req-int', + content: 'Here is the partial ans', + thinking: 'was still reasoning', + }); + // It is NOT resurrected as a live streaming buffer (would pulse). + expect(state.streamingAssistantByThread['t-int']).toBeUndefined(); + // The lifecycle is recorded as interrupted, not a fake in-flight status. + expect(state.inferenceTurnLifecycleByThread['t-int']).toBe('interrupted'); + }); + + it('keeps an interrupted turn that only produced thinking', () => { + const store = makeStore(); + store.dispatch( + hydrateRuntimeFromSnapshot({ + snapshot: makeInterruptedPartialSnapshot('t-think', { streamingText: '' }), + }) + ); + expect(store.getState().chatRuntime.interruptedAssistantByThread['t-think']).toMatchObject({ + content: '', + thinking: 'was still reasoning', + }); + }); + + it('does not surface a partial for an interrupted turn with no persisted text', () => { + const store = makeStore(); + store.dispatch( + hydrateRuntimeFromSnapshot({ + snapshot: makeInterruptedPartialSnapshot('t-empty', { streamingText: '', thinking: '' }), + }) + ); + expect(store.getState().chatRuntime.interruptedAssistantByThread['t-empty']).toBeUndefined(); + }); + + it('clears a stale interrupted partial when a completed snapshot lands', () => { + const store = makeStore(); + store.dispatch(hydrateRuntimeFromSnapshot({ snapshot: makeInterruptedPartialSnapshot('t-c') })); + expect(store.getState().chatRuntime.interruptedAssistantByThread['t-c']).toBeDefined(); + + store.dispatch( + hydrateRuntimeFromSnapshot({ + snapshot: makeInterruptedPartialSnapshot('t-c', { + lifecycle: 'completed', + streamingText: '', + thinking: '', + }), + }) + ); + expect(store.getState().chatRuntime.interruptedAssistantByThread['t-c']).toBeUndefined(); + }); +}); + +describe('hydrateRuntimeFromSnapshot — transcript seq ordering (fix 5)', () => { + it('orders the processing transcript by persisted seq, not array order', () => { + const store = makeStore(); + const snapshot: PersistedTurnState = { + threadId: 't-seq', + requestId: 'req-1', + lifecycle: 'completed', + iteration: 1, + maxIterations: 10, + streamingText: '', + thinking: '', + toolTimeline: [], + // Deliberately out of order on the wire. + transcript: [ + { kind: 'narration', round: 0, seq: 2, text: 'second' }, + { kind: 'thinking', round: 0, seq: 0, text: 'first' }, + { kind: 'narration', round: 0, seq: 1, text: 'middle' }, + ], + startedAt: '2026-06-23T00:00:00Z', + updatedAt: '2026-06-23T00:00:00Z', + }; + + store.dispatch(hydrateRuntimeFromSnapshot({ snapshot })); + + const items = store.getState().chatRuntime.processingByThread['t-seq']; + expect(items.map(i => ('text' in i ? i.text : i.kind))).toEqual(['first', 'middle', 'second']); + }); +}); + +describe('hydrateRuntimeFromSnapshot — sub-agent transcript fallback (fix 4)', () => { + it('rebuilds a tool-only transcript when no persisted prose field is present', () => { + const store = makeStore(); + const snapshot: PersistedTurnState = { + threadId: 't-fallback', + requestId: 'req-1', + lifecycle: 'completed', + iteration: 1, + maxIterations: 10, + streamingText: '', + thinking: '', + toolTimeline: [ + { + id: 'subagent:task-old', + name: 'subagent:researcher', + round: 1, + status: 'success', + subagent: { + taskId: 'task-old', + agentId: 'researcher', + // No `transcript` field (old snapshot) — only tool calls. + toolCalls: [{ callId: 'c1', toolName: 'web_search', status: 'success' }], + }, + }, + ], + startedAt: '2026-06-23T00:00:00Z', + updatedAt: '2026-06-23T00:00:00Z', + }; + + store.dispatch(hydrateRuntimeFromSnapshot({ snapshot })); + + const row = store + .getState() + .chatRuntime.toolTimelineByThread['t-fallback'].find(e => e.subagent?.taskId === 'task-old'); + const transcript = row?.subagent?.transcript ?? []; + // Falls back to tool-only items so an old snapshot still shows the sequence. + expect(transcript).toHaveLength(1); + expect(transcript[0].kind).toBe('tool'); + }); +}); diff --git a/app/src/store/chatRuntimeSlice.ts b/app/src/store/chatRuntimeSlice.ts index c06fb27336..40122ac0a3 100644 --- a/app/src/store/chatRuntimeSlice.ts +++ b/app/src/store/chatRuntimeSlice.ts @@ -647,6 +647,31 @@ interface ChatRuntimeState { * rows live in `toolTimelineByThread` and are driven by the socket stream). */ turnTimelinesByThread: Record>; + /** + * Per-turn processing transcripts (narration / thinking / tool pointers) for + * *past* (settled) turns of a thread, keyed `threadId -> requestId -> items`. + * Sibling of {@link turnTimelinesByThread}: that map holds the past turn's + * tool rows, this one its interleaved reasoning/narration trail so a reopened + * thread replays each past answer's thoughts — not just its tool cards + * (restore-fidelity fix 1). Hydrated from `turn_state_history`; the live turn + * is excluded (its transcript lives in {@link processingByThread}). Absent for + * legacy snapshots written before the transcript field existed. + */ + turnTranscriptsByThread: Record>; + /** + * The partial assistant answer left behind by an INTERRUPTED turn (the core + * process that was streaming it is gone), keyed by thread. Surfaced on restore + * so a turn that crashed mid-answer keeps its visible partial reply + hidden + * reasoning instead of dropping them (restore-fidelity fix 2). Unlike + * {@link streamingAssistantByThread} this is a SETTLED, non-live buffer: it is + * rendered statically (no pulsing cursor) and marked interrupted. Populated + * only when an interrupted snapshot carries `streamingText`/`thinking`; + * cleared on any live turn, a completed snapshot, or a thread reset. + */ + interruptedAssistantByThread: Record< + string, + { requestId: string; content: string; thinking: string } + >; /** * Ordered narration/thinking/tool transcript per thread for the * "View processing" panel — the interleaved Hermes-style record. Hydrated @@ -733,6 +758,8 @@ const initialState: ChatRuntimeState = { toolTimelineByThread: {}, toolTimelineSeqByThread: {}, turnTimelinesByThread: {}, + turnTranscriptsByThread: {}, + interruptedAssistantByThread: {}, processingByThread: {}, taskBoardByThread: {}, inferenceTurnLifecycleByThread: {}, @@ -868,6 +895,26 @@ function subagentActivityFromPersisted(activity: PersistedSubagentActivity): Sub }; } +/** + * Order a persisted processing transcript by its per-item `seq` when every item + * carries one, falling back to the array (arrival) order otherwise + * (restore-fidelity fix 5: prefer `seq` for replay ordering when present). The + * core already writes items in `seq` order, so this is a defensive stable sort + * that also tolerates a snapshot whose items were reordered in transit. A stable + * sort preserves arrival order for any items that happen to share a `seq`. + */ +function orderTranscriptBySeq(items: ProcessingTranscriptItem[]): ProcessingTranscriptItem[] { + if (items.length < 2) return items; + const allHaveSeq = items.every(item => typeof item.seq === 'number'); + if (!allHaveSeq) return items; + // `.sort` is stable in modern engines; map to (item, index) to make the + // tie-break on equal `seq` explicit rather than engine-dependent. + return items + .map((item, index) => ({ item, index })) + .sort((a, b) => a.item.seq - b.item.seq || a.index - b.index) + .map(({ item }) => item); +} + /** * `seq` defaults to the array index the caller maps over — persisted * `toolTimeline` order IS issue order (the core appends rows as it issues @@ -1085,9 +1132,22 @@ const chatRuntimeSlice = createSlice({ */ setTurnTimelinesForThread: ( state, - action: PayloadAction<{ threadId: string; timelines: Record }> + action: PayloadAction<{ + threadId: string; + timelines: Record; + transcripts?: Record; + }> ) => { - state.turnTimelinesByThread[action.payload.threadId] = action.payload.timelines; + const { threadId, timelines, transcripts } = action.payload; + state.turnTimelinesByThread[threadId] = timelines; + if (transcripts) { + state.turnTranscriptsByThread[threadId] = transcripts; + turnStateLog( + 'past-turn transcripts set thread=%s turns=%d', + threadId, + Object.keys(transcripts).length + ); + } }, /** Reset the live processing transcript at the start of a fresh turn so a * new turn's narration/steps don't append onto the previous turn's. */ @@ -1864,6 +1924,7 @@ const chatRuntimeSlice = createSlice({ clearRuntimeForThread: (state, action: PayloadAction<{ threadId: string }>) => { delete state.inferenceStatusByThread[action.payload.threadId]; delete state.streamingAssistantByThread[action.payload.threadId]; + delete state.interruptedAssistantByThread[action.payload.threadId]; delete state.inferenceHeartbeatByThread[action.payload.threadId]; // Drop any parallel (forked) streams for this thread and their // request→thread mappings — a hard per-thread reset covers every branch. @@ -1900,6 +1961,8 @@ const chatRuntimeSlice = createSlice({ state.toolTimelineByThread = {}; state.toolTimelineSeqByThread = {}; state.turnTimelinesByThread = {}; + state.turnTranscriptsByThread = {}; + state.interruptedAssistantByThread = {}; state.processingByThread = {}; state.taskBoardByThread = {}; state.inferenceTurnLifecycleByThread = {}; @@ -2009,6 +2072,9 @@ const chatRuntimeSlice = createSlice({ if (snapshot.taskBoard) { state.taskBoardByThread[threadId] = snapshot.taskBoard; } + // A live turn is driving the thread — any interrupted partial from a + // prior crashed turn is superseded and must not linger under it. + delete state.interruptedAssistantByThread[threadId]; return; } @@ -2077,7 +2143,32 @@ const chatRuntimeSlice = createSlice({ // up rather than restarting at 0 and colliding with existing seqs. state.toolTimelineSeqByThread[threadId] = snapshot.toolTimeline.length; } - state.processingByThread[threadId] = snapshot.transcript ?? []; + // An interrupted turn was killed mid-answer (its core process is gone, + // so no `chat_done` will ever complete it). The partial reply + + // reasoning it had already streamed are persisted — surface them as a + // SETTLED buffer (rendered static + marked interrupted, not as a live + // pulsing stream) instead of dropping them (restore-fidelity fix 2). A + // `completed` turn's answer is the durable message, so it has no partial + // to keep — clear any stale interrupted buffer for the thread instead. + if ( + snapshot.lifecycle === 'interrupted' && + (snapshot.streamingText.length > 0 || snapshot.thinking.length > 0) + ) { + state.interruptedAssistantByThread[threadId] = { + requestId: snapshot.requestId, + content: snapshot.streamingText, + thinking: snapshot.thinking, + }; + turnStateLog( + 'interrupted partial kept thread=%s chars=%d thinkingChars=%d', + threadId, + snapshot.streamingText.length, + snapshot.thinking.length + ); + } else { + delete state.interruptedAssistantByThread[threadId]; + } + state.processingByThread[threadId] = orderTranscriptBySeq(snapshot.transcript ?? []); return; } @@ -2102,6 +2193,9 @@ const chatRuntimeSlice = createSlice({ } else { delete state.streamingAssistantByThread[threadId]; } + // This snapshot is in-flight (a live driver may be resuming it), not a + // settled interruption — drop any stale interrupted partial for the thread. + delete state.interruptedAssistantByThread[threadId]; state.toolTimelineByThread[threadId] = preserveLiveSubagentProse( state.toolTimelineByThread[threadId], @@ -2110,7 +2204,7 @@ const chatRuntimeSlice = createSlice({ // Persisted order is issue order — seed the live counter with the row // count so events arriving after this hydration keep counting up. state.toolTimelineSeqByThread[threadId] = snapshot.toolTimeline.length; - state.processingByThread[threadId] = snapshot.transcript ?? []; + state.processingByThread[threadId] = orderTranscriptBySeq(snapshot.transcript ?? []); }, /** * Rebuild durable historical subagent rows from the run ledger. This is @@ -2266,23 +2360,41 @@ export const fetchAndHydrateTurnHistory = createAsyncThunk( try { const history = await threadApi.getTurnStateHistory(threadId); const timelines: Record = {}; + const transcripts: Record = {}; // History is newest-first; the newest turn is the one `getTurnState` // hydrates into `toolTimelineByThread` (rendered as the live/anchored // "agent insights"), so skip it here to avoid rendering it twice — this // field holds only the *older* settled turns. for (const turn of history.slice(1)) { if (turn.lifecycle !== 'completed' && turn.lifecycle !== 'interrupted') continue; - if (!turn.requestId || turn.toolTimeline.length === 0) continue; - timelines[turn.requestId] = turn.toolTimeline.map((e, seq) => - toolTimelineFromPersisted(e, seq) - ); + if (!turn.requestId) continue; + // A past turn can have a reasoning/narration trail with NO tool calls + // (the agent only thought/narrated). Keep the turn whenever it has + // either a tool timeline OR a transcript so a tool-less answer still + // replays its thoughts (restore-fidelity fix 1) — the old + // `toolTimeline.length === 0` skip dropped those turns entirely. + const hasTools = turn.toolTimeline.length > 0; + const persistedTranscript = turn.transcript ?? []; + const hasTranscript = persistedTranscript.length > 0; + if (!hasTools && !hasTranscript) continue; + if (hasTools) { + timelines[turn.requestId] = turn.toolTimeline.map((e, seq) => + toolTimelineFromPersisted(e, seq) + ); + } + if (hasTranscript) { + // Prefer persisted `seq` for replay order, falling back to array + // order (restore-fidelity fix 5). + transcripts[turn.requestId] = orderTranscriptBySeq(persistedTranscript); + } } turnStateLog( - 'hydrated turn history thread=%s turns=%d', + 'hydrated turn history thread=%s timelines=%d transcripts=%d', threadId, - Object.keys(timelines).length + Object.keys(timelines).length, + Object.keys(transcripts).length ); - dispatch(setTurnTimelinesForThread({ threadId, timelines })); + dispatch(setTurnTimelinesForThread({ threadId, timelines, transcripts })); return timelines; } catch (error) { turnStateLog('history fetch failed thread=%s err=%O', threadId, error); diff --git a/app/src/types/turnState.ts b/app/src/types/turnState.ts index 69c81d34a1..92ea84bfa9 100644 --- a/app/src/types/turnState.ts +++ b/app/src/types/turnState.ts @@ -156,6 +156,11 @@ export interface PersistedToolTimelineEntry { /** Size-capped tool result text. Absent while running and on snapshots * written before this field. */ output?: string; + /** Per-turn monotonic ordering key stamped when the row is first created, so + * a rehydrated timeline orders rows identically to the live stream (shares + * the per-turn ordering space with {@link PersistedTranscriptItem.seq}). + * Absent on snapshots written before this field. */ + seq?: number; } export interface PersistedTurnState { diff --git a/src/core/socketio.rs b/src/core/socketio.rs index 9459c21ddf..cc0397943b 100644 --- a/src/core/socketio.rs +++ b/src/core/socketio.rs @@ -223,6 +223,15 @@ pub struct WebChannelEvent { /// for synthetic done events that never ran a real turn. #[serde(skip_serializing_if = "Option::is_none")] pub usage: Option, + /// Additive per-request monotonic ordering key stamped by the web-channel + /// progress bridge on every event it emits (conversations-timeline-refactor, + /// Phase 4). Together with the always-present `request_id`, the frontend + /// dedups replayed vs live events by `(request_id, seq)` and orders them + /// identically to the persisted turn-state snapshot. `None` on events not + /// emitted through the stamping bridge and on older cores — older frontends + /// simply ignore it. + #[serde(skip_serializing_if = "Option::is_none")] + pub seq: Option, } /// Token/cost/context totals for one completed turn, attached to `chat_done`. diff --git a/src/openhuman/agent/harness/session/runtime.rs b/src/openhuman/agent/harness/session/runtime.rs index e75bcf4a7d..074c756612 100644 --- a/src/openhuman/agent/harness/session/runtime.rs +++ b/src/openhuman/agent/harness/session/runtime.rs @@ -386,6 +386,105 @@ impl Agent { Ok(()) } + /// Cold-boot resume for the web-chat path: pre-populate this session's + /// LLM context from the **full-fidelity** `session_raw/{stem}.jsonl` + /// transcript for `thread_id`. + /// + /// This is the high-fidelity counterpart to + /// [`Self::seed_resume_from_messages`]. That fallback sources lossy + /// `(sender, content)` prose from the conversation log, so it drops every + /// tool call, tool-role result, and reasoning block — after an app restart + /// the model then "forgets" all its tool interactions. This path instead + /// routes thread → transcript via + /// [`transcript::find_root_transcript_for_thread`] and reuses the exact + /// [`transcript::read_transcript`] + + /// [`Self::bound_cached_transcript_messages`] machinery as + /// [`Self::try_load_session_transcript`], so `tool_calls`, `role:"tool"` + /// messages, and `reasoning_content` all survive the round-trip. The only + /// difference from `try_load_session_transcript` is the lookup key (thread + /// id vs. per-thread agent name), so a thread whose transcript was written + /// under a differently-scoped agent name still resumes. + /// + /// Returns `true` when a transcript was found, loaded, and seeded into + /// `cached_transcript_messages`; `false` (a no-op) when the agent is already + /// warm, no root transcript exists for the thread, the transcript is empty, + /// or it fails to parse — the caller then falls back to prose-pair seeding. + /// + /// Best-effort like `try_load_session_transcript`: read/parse failures are + /// logged and reported as `false` rather than propagated. The current turn's + /// user message is appended later by [`Self::run_single`] / `turn`, so it is + /// intentionally absent from the loaded prefix — no dedup is needed here (the + /// on-disk transcript ends at the previous completed turn). + pub fn seed_resume_from_thread_transcript(&mut self, thread_id: &str) -> bool { + if !self.history.is_empty() || self.cached_transcript_messages.is_some() { + log::debug!( + "[web-channel] seed_resume_from_thread_transcript no-op — agent already warm \ + (history_len={}, cached={}) thread={thread_id}", + self.history.len(), + self.cached_transcript_messages.is_some() + ); + return false; + } + + let Some(path) = + super::transcript::find_root_transcript_for_thread(&self.workspace_dir, thread_id) + else { + log::debug!( + "[web-channel] no root session_raw transcript for thread={thread_id} — \ + falling back to conversation-log prose seeding" + ); + return false; + }; + + log::info!( + "[web-channel] cold-boot resume — loading full-fidelity transcript for \ + thread={thread_id} path={}", + path.display() + ); + + match super::transcript::read_transcript(&path) { + Ok(session) => { + if session.messages.is_empty() { + log::debug!( + "[web-channel] root transcript for thread={thread_id} is empty — \ + falling back to prose seeding" + ); + return false; + } + let loaded_count = session.messages.len(); + // Count the tool-role results carried into the resumed prefix — + // the fidelity the prose fallback would have silently dropped. + let tool_result_msgs = session.messages.iter().filter(|m| m.role == "tool").count(); + let bounded = self.bound_cached_transcript_messages(session.messages); + if bounded.len() < loaded_count { + log::warn!( + "[web-channel] resume prefix trimmed from {} to {} messages \ + (max_history_messages={}) for thread={thread_id}", + loaded_count, + bounded.len(), + self.config.max_history_messages + ); + } + log::info!( + "[web-channel] cold-boot resume — primed {} transcript message(s) \ + ({} tool-role result(s) preserved) for thread={thread_id}", + bounded.len(), + tool_result_msgs + ); + self.cached_transcript_messages = Some(bounded); + true + } + Err(err) => { + log::warn!( + "[web-channel] failed to parse root transcript {} for thread={thread_id}: \ + {err} — falling back to prose seeding", + path.display() + ); + false + } + } + } + /// Drain and return memory citations collected for the latest completed turn. pub fn take_last_turn_citations( &mut self, diff --git a/src/openhuman/agent/harness/session/tests.rs b/src/openhuman/agent/harness/session/tests.rs index da62b719cf..fba8213a77 100644 --- a/src/openhuman/agent/harness/session/tests.rs +++ b/src/openhuman/agent/harness/session/tests.rs @@ -1342,6 +1342,171 @@ fn bound_cached_transcript_messages_snaps_past_leading_orphan_tool() { ); } +/// Cold-boot web-chat resume must prefer the full-fidelity `session_raw` +/// transcript over lossy conversation-log prose. This is the regression for the +/// "model forgets its tool interactions after an app restart" bug: once the +/// in-memory agent is dropped and a fresh agent cold-boots for the same thread, +/// the resumed context must still carry the tool call, the tool-role result, and +/// the reasoning that prose seeding (`seed_resume_from_messages`) discards. +#[test] +fn seed_resume_from_thread_transcript_preserves_tool_calls_and_reasoning() { + use super::transcript::{self, MessageUsage, TranscriptMeta, TurnUsage}; + use crate::openhuman::inference::provider::{ChatMessage, ToolCall}; + + let ws = tempfile::TempDir::new().expect("temp workspace"); + let wsp = ws.path().to_path_buf(); + let thread_id = "thr_resume_fidelity"; + + // ── Simulate a prior session persisted to session_raw carrying a tool + // call + reasoning on the tool-calling assistant turn and a tool-role + // result — exactly the fidelity the prose fallback drops. ── + let mut assistant_toolcall = ChatMessage::assistant("Let me look that up."); + transcript::attach_turn_usage_metadata( + &mut assistant_toolcall, + &TurnUsage { + provider: "openai".to_string(), + model: "gpt-x".to_string(), + usage: MessageUsage { + input: 10, + output: 5, + cached_input: 0, + context_window: 0, + cost_usd: 0.0, + }, + ts: "2026-01-01T00:00:00Z".to_string(), + reasoning_content: Some("I should search the web for the price.".to_string()), + tool_calls: vec![ToolCall { + id: "call_1".to_string(), + name: "web_search".to_string(), + arguments: r#"{"query":"btc price"}"#.to_string(), + extra_content: None, + }], + iteration: 1, + }, + ); + + let messages = vec![ + ChatMessage::system("system prompt"), + ChatMessage::user("what is btc price"), + assistant_toolcall, + ChatMessage::tool(r#"{"tool_call_id":"call_1","content":"$80,000"}"#), + ChatMessage::assistant("BTC is around $80,000."), + ]; + let meta = TranscriptMeta { + agent_name: "orchestrator_thread-resume".to_string(), + agent_id: Some("orchestrator".to_string()), + agent_type: Some("root".to_string()), + dispatcher: "native".to_string(), + provider: None, + model: None, + created: "2026-01-01T00:00:00Z".to_string(), + updated: "2026-01-01T00:00:00Z".to_string(), + turn_count: 1, + input_tokens: 10, + output_tokens: 5, + cached_input_tokens: 0, + charged_amount_usd: 0.0, + thread_id: Some(thread_id.to_string()), + task_id: None, + }; + // Root stem: no `__`, so `find_root_transcript_for_thread` accepts it. + let path = transcript::resolve_keyed_transcript_path(&wsp, "1700000000_orchestrator") + .expect("resolve transcript path"); + transcript::write_transcript(&path, &messages, &meta, None).expect("write transcript"); + + // ── Cold boot: a brand-new agent for the same thread whose agent + // definition name deliberately does NOT match the transcript stem — the + // resume must route purely by thread id, not by agent name. ── + let memory_cfg = crate::openhuman::config::MemoryConfig { + backend: "none".into(), + ..crate::openhuman::config::MemoryConfig::default() + }; + let mem: Arc = + Arc::from(crate::openhuman::memory_store::create_memory(&memory_cfg, &wsp).unwrap()); + let mut agent = Agent::builder() + .provider(Box::new(MockProvider { + responses: Mutex::new(vec![]), + })) + .tools(vec![Box::new(MockTool)]) + .memory(mem) + .tool_dispatcher(Box::new(NativeToolDispatcher)) + .agent_definition_name("some_other_agent_name") + .workspace_dir(wsp.clone()) + .build() + .expect("agent build should succeed"); + + let loaded = agent.seed_resume_from_thread_transcript(thread_id); + assert!( + loaded, + "cold-boot resume must load the thread's root transcript" + ); + + let cached = agent + .cached_transcript_messages + .as_ref() + .expect("cached transcript populated"); + + // The tool-role result must survive — prose seeding would have dropped it. + assert!( + cached.iter().any(|m| m.role == "tool"), + "resumed context must include the tool-role result message" + ); + + // The assistant tool call + reasoning survive, carried in metadata. + let tool_call_carrier = cached + .iter() + .find(|m| { + m.role == "assistant" + && m.extra_metadata + .as_ref() + .and_then(|v| v.get("openhuman_turn_usage")) + .is_some() + }) + .expect("resumed context must include the assistant tool-call turn"); + let usage_value = tool_call_carrier + .extra_metadata + .as_ref() + .and_then(|v| v.get("openhuman_turn_usage")) + .cloned() + .expect("turn usage metadata present"); + let parsed: TurnUsage = serde_json::from_value(usage_value).expect("turn usage deserializes"); + assert!( + parsed.tool_calls.iter().any(|c| c.name == "web_search"), + "the persisted tool call must round-trip into the resumed context" + ); + assert_eq!( + parsed.reasoning_content.as_deref(), + Some("I should search the web for the price."), + "reasoning content must be preserved on resume" + ); +} + +/// When no root transcript exists for the thread, the transcript resume is a +/// no-op returning `false` so the caller falls back to prose-pair seeding. +#[test] +fn seed_resume_from_thread_transcript_returns_false_without_transcript() { + let mut agent = build_minimal_agent_with_definition_name(Some("orchestrator")); + assert!(!agent.seed_resume_from_thread_transcript("thr_missing")); + assert!(agent.cached_transcript_messages.is_none()); +} + +/// Transcript resume must not stomp an already-warm agent (in-process session +/// cache hit) — mirrors the `seed_resume_from_messages` warm-agent guard. +#[test] +fn seed_resume_from_thread_transcript_is_noop_on_warm_agent() { + let mut agent = build_minimal_agent_with_definition_name(Some("orchestrator")); + agent.cached_transcript_messages = Some(vec![ + crate::openhuman::inference::provider::ChatMessage::system("warm prefix"), + ]); + assert!(!agent.seed_resume_from_thread_transcript("thr_x")); + let cached = agent + .cached_transcript_messages + .as_ref() + .expect("still populated"); + assert_eq!(cached.len(), 1); + assert_eq!(cached[0].content, "warm prefix"); +} + /// `hide_tools` on an agent that already has a visible-tool filter must drop /// only the named tools and leave the rest of the belt intact. #[test] diff --git a/src/openhuman/channels/proactive.rs b/src/openhuman/channels/proactive.rs index fe9f762d42..c7f7cfcc74 100644 --- a/src/openhuman/channels/proactive.rs +++ b/src/openhuman/channels/proactive.rs @@ -220,6 +220,9 @@ impl EventHandler for ProactiveMessageSubscriber { tool_display_label: None, tool_display_detail: None, usage: None, + // Proactive delivery is emitted outside the seq-stamping progress + // bridge; leave `seq` unset (older clients ignore it). + seq: None, }); // 2. If an active external channel is configured, deliver there too. diff --git a/src/openhuman/threads/turn_state/mirror.rs b/src/openhuman/threads/turn_state/mirror.rs index b36faa121a..cb0bc0c3b2 100644 --- a/src/openhuman/threads/turn_state/mirror.rs +++ b/src/openhuman/threads/turn_state/mirror.rs @@ -36,6 +36,47 @@ const MAX_PERSISTED_TOOL_OUTPUT: usize = 64 * 1024; /// [`MAX_PERSISTED_TOOL_OUTPUT`]. const TRUNCATION_MARKER_BUDGET: usize = 80; +/// Upper bound on a single persisted transcript prose item (one coalesced +/// narration or reasoning block, parent or sub-agent). A runaway reasoning +/// stream would otherwise grow one item without bound and bloat every +/// full-file snapshot rewrite. Tighter than [`MAX_PERSISTED_TOOL_OUTPUT`] +/// because a turn can accumulate many prose items. +const MAX_PERSISTED_TRANSCRIPT_ITEM: usize = 16 * 1024; + +/// Marker appended once when a transcript prose item is truncated at its cap. +const TRANSCRIPT_TRUNCATION_MARKER: &str = "\n…[truncated]"; + +/// Append `delta` to a coalescing transcript prose buffer, enforcing +/// [`MAX_PERSISTED_TRANSCRIPT_ITEM`] on a char boundary and stamping a one-time +/// truncation marker the first time the cap is hit. Once at the cap, further +/// deltas are dropped (the marker is already present). Used by both the parent +/// and sub-agent transcript coalescers so a single streamed block stays bounded. +fn append_capped_transcript_text(text: &mut String, delta: &str) { + if delta.is_empty() { + return; + } + if text.len() >= MAX_PERSISTED_TRANSCRIPT_ITEM { + // Already at the cap but more content is arriving — ensure the marker is + // present exactly once so the truncation is visible even when deltas + // land exactly on the boundary (never straddling it). + if !text.ends_with(TRANSCRIPT_TRUNCATION_MARKER) { + text.push_str(TRANSCRIPT_TRUNCATION_MARKER); + } + return; + } + let remaining = MAX_PERSISTED_TRANSCRIPT_ITEM - text.len(); + if delta.len() <= remaining { + text.push_str(delta); + return; + } + let mut end = remaining; + while end > 0 && !delta.is_char_boundary(end) { + end -= 1; + } + text.push_str(&delta[..end]); + text.push_str(TRANSCRIPT_TRUNCATION_MARKER); +} + /// Cap `output` for snapshot persistence, slicing on a char boundary and /// appending a truncation marker when content was dropped. Returns `None` /// for empty output (payload capture off) so the field serializes away. @@ -69,6 +110,11 @@ pub struct TurnStateMirror { /// order narration vs thinking vs tool calls *within* one iteration, so /// every transcript push stamps and increments this. next_seq: u32, + /// Separate monotonic ordering key for [`ToolTimelineEntry::seq`] — the flat + /// timeline is an independent projection from the interleaved transcript, so + /// it gets its own space (sharing `next_seq` would leave gaps in the + /// transcript's contiguous ordering). + next_tool_seq: u64, } impl TurnStateMirror { @@ -87,6 +133,7 @@ impl TurnStateMirror { state, turn_completed: false, next_seq: 0, + next_tool_seq: 0, }; mirror.flush(); mirror @@ -153,6 +200,7 @@ impl TurnStateMirror { existing.detail = display_detail.clone(); } } else { + let seq = self.next_tool_seq(); self.state.tool_timeline.push(ToolTimelineEntry { id: call_id.clone(), name: tool_name.clone(), @@ -165,6 +213,7 @@ impl TurnStateMirror { subagent: None, failure: None, output: None, + seq: Some(seq), }); } self.flush(); @@ -216,6 +265,7 @@ impl TurnStateMirror { } => { self.state.phase = Some(TurnPhase::Subagent); self.state.active_subagent = Some(agent_id.clone()); + let seq = self.next_tool_seq(); self.state.tool_timeline.push(ToolTimelineEntry { id: format!("subagent:{task_id}"), name: format!("subagent:{agent_id}"), @@ -242,6 +292,7 @@ impl TurnStateMirror { }), failure: None, output: None, + seq: Some(seq), }); self.flush(); true @@ -447,6 +498,7 @@ impl TurnStateMirror { // No matching entry yet — `ToolCallArgsDelta` may // arrive before `ToolCallStarted` so synthesise a // placeholder we can update once the start event lands. + let seq = self.next_tool_seq(); self.state.tool_timeline.push(ToolTimelineEntry { id: call_id.clone(), name: tool_name.clone(), @@ -459,6 +511,7 @@ impl TurnStateMirror { subagent: None, failure: None, output: None, + seq: Some(seq), }); } false @@ -526,16 +579,16 @@ impl TurnStateMirror { self.state.transcript.last_mut() { if *r == round { - text.push_str(delta); + append_capped_transcript_text(text, delta); return; } } let seq = self.next_seq(); - self.state.transcript.push(TranscriptItem::Narration { - round, - seq, - text: delta.to_string(), - }); + let mut text = String::new(); + append_capped_transcript_text(&mut text, delta); + self.state + .transcript + .push(TranscriptItem::Narration { round, seq, text }); } /// Append a hidden-reasoning delta to the transcript, with the same @@ -545,16 +598,16 @@ impl TurnStateMirror { self.state.transcript.last_mut() { if *r == round { - text.push_str(delta); + append_capped_transcript_text(text, delta); return; } } let seq = self.next_seq(); - self.state.transcript.push(TranscriptItem::Thinking { - round, - seq, - text: delta.to_string(), - }); + let mut text = String::new(); + append_capped_transcript_text(&mut text, delta); + self.state + .transcript + .push(TranscriptItem::Thinking { round, seq, text }); } /// Record a tool call in the transcript at the point it occurred, as a @@ -583,6 +636,13 @@ impl TurnStateMirror { seq } + /// Return the next monotonic tool-timeline ordering key and advance it. + fn next_tool_seq(&mut self) -> u64 { + let seq = self.next_tool_seq; + self.next_tool_seq = self.next_tool_seq.saturating_add(1); + seq + } + fn find_subagent_entry_mut(&mut self, task_id: &str) -> Option<&mut ToolTimelineEntry> { let needle = format!("subagent:{task_id}"); self.state @@ -616,27 +676,29 @@ impl TurnStateMirror { iteration: it, text, }) if is_thinking && *it == Some(iteration) => { - text.push_str(delta); + append_capped_transcript_text(text, delta); return; } Some(SubagentTranscriptItem::Text { iteration: it, text, }) if !is_thinking && *it == Some(iteration) => { - text.push_str(delta); + append_capped_transcript_text(text, delta); return; } _ => {} } + let mut text = String::new(); + append_capped_transcript_text(&mut text, delta); activity.transcript.push(if is_thinking { SubagentTranscriptItem::Thinking { iteration: Some(iteration), - text: delta.to_string(), + text, } } else { SubagentTranscriptItem::Text { iteration: Some(iteration), - text: delta.to_string(), + text, } }); } diff --git a/src/openhuman/threads/turn_state/mirror_tests.rs b/src/openhuman/threads/turn_state/mirror_tests.rs index 2ca9ab0c97..2e46bd6691 100644 --- a/src/openhuman/threads/turn_state/mirror_tests.rs +++ b/src/openhuman/threads/turn_state/mirror_tests.rs @@ -194,6 +194,160 @@ fn tool_call_completed_persists_capped_output() { assert!(persisted.contains("truncated")); } +#[test] +fn tool_timeline_entries_carry_monotonic_seq() { + // Every timeline row is stamped with a per-turn monotonic `seq` at creation + // so a rehydrated snapshot can order rows identically to the live stream + // (conversations-timeline-refactor, Phase 4 amendment). Cover the three + // creation paths: a normal tool start, an args-delta placeholder, and a + // sub-agent spawn. + let (_d, mut m) = fresh("t"); + m.observe(&AgentProgress::IterationStarted { + iteration: 1, + max_iterations: 25, + }); + m.observe(&AgentProgress::ToolCallStarted { + call_id: "tc-1".into(), + tool_name: "shell".into(), + arguments: serde_json::json!({}), + iteration: 1, + display_label: None, + display_detail: None, + }); + // Placeholder-first path (args delta before start) for a second call. + m.observe(&AgentProgress::ToolCallArgsDelta { + call_id: "tc-2".into(), + tool_name: "shell".into(), + delta: "{".into(), + iteration: 1, + }); + m.observe(&AgentProgress::SubagentSpawned { + agent_id: "researcher".into(), + task_id: "sub-1".into(), + mode: "typed".into(), + dedicated_thread: false, + prompt_chars: 10, + prompt: String::new(), + worker_thread_id: None, + display_name: None, + }); + + let seqs: Vec = m + .snapshot() + .tool_timeline + .iter() + .map(|e| e.seq.expect("every row is seq-stamped")) + .collect(); + assert_eq!(seqs.len(), 3); + // Strictly increasing in creation order. + assert!( + seqs.windows(2).all(|w| w[0] < w[1]), + "tool timeline seqs must be strictly increasing, got {seqs:?}" + ); + + // A later `ToolCallStarted` that reuses the args-delta placeholder must NOT + // restamp the row's seq (the row keeps its original creation order). + let tc2_seq_before = m + .snapshot() + .tool_timeline + .iter() + .find(|e| e.id == "tc-2") + .and_then(|e| e.seq) + .expect("placeholder seq"); + m.observe(&AgentProgress::ToolCallStarted { + call_id: "tc-2".into(), + tool_name: "shell".into(), + arguments: serde_json::json!({}), + iteration: 1, + display_label: None, + display_detail: None, + }); + let tc2_seq_after = m + .snapshot() + .tool_timeline + .iter() + .find(|e| e.id == "tc-2") + .and_then(|e| e.seq) + .expect("placeholder seq after reuse"); + assert_eq!( + tc2_seq_before, tc2_seq_after, + "reusing a placeholder must not restamp its seq" + ); +} + +#[test] +fn subagent_prose_item_is_size_capped() { + // A runaway reasoning stream must not grow a single transcript item without + // bound (the snapshot is rewritten in full at every flush). Streaming far + // past the per-item cap coalesces into one item that stays bounded and + // carries a truncation marker. + let (_d, mut m) = fresh("t"); + m.observe(&AgentProgress::IterationStarted { + iteration: 1, + max_iterations: 25, + }); + m.observe(&AgentProgress::SubagentSpawned { + agent_id: "researcher".into(), + task_id: "sub-1".into(), + mode: "typed".into(), + dedicated_thread: false, + prompt_chars: 10, + prompt: String::new(), + worker_thread_id: None, + display_name: None, + }); + // 40 KiB of reasoning in same-iteration chunks — must coalesce and cap. + for _ in 0..40 { + m.observe(&AgentProgress::SubagentThinkingDelta { + agent_id: "researcher".into(), + task_id: "sub-1".into(), + delta: "x".repeat(1024), + iteration: 1, + }); + } + let activity = m.snapshot().tool_timeline[0] + .subagent + .as_ref() + .expect("activity") + .clone(); + assert_eq!( + activity.transcript.len(), + 1, + "same-iteration prose coalesces" + ); + match &activity.transcript[0] { + SubagentTranscriptItem::Thinking { text, .. } => { + assert!( + text.len() <= super::MAX_PERSISTED_TRANSCRIPT_ITEM + 64, + "capped prose item stays bounded, got {} bytes", + text.len() + ); + assert!(text.contains("truncated"), "capped item carries a marker"); + } + other => panic!("expected thinking, got {other:?}"), + } +} + +#[test] +fn parent_transcript_prose_item_is_size_capped() { + let (_d, mut m) = fresh("t"); + for _ in 0..40 { + m.observe(&AgentProgress::ThinkingDelta { + delta: "y".repeat(1024), + iteration: 1, + }); + } + let s = m.snapshot(); + assert_eq!(s.transcript.len(), 1); + match &s.transcript[0] { + TranscriptItem::Thinking { text, .. } => { + assert!(text.len() <= super::MAX_PERSISTED_TRANSCRIPT_ITEM + 64); + assert!(text.contains("truncated")); + } + other => panic!("expected thinking, got {other:?}"), + } +} + #[test] fn args_delta_arriving_before_start_creates_placeholder() { let (_d, mut m) = fresh("t"); diff --git a/src/openhuman/threads/turn_state/store_tests.rs b/src/openhuman/threads/turn_state/store_tests.rs index 4af251891e..6f48527786 100644 --- a/src/openhuman/threads/turn_state/store_tests.rs +++ b/src/openhuman/threads/turn_state/store_tests.rs @@ -2,7 +2,8 @@ use super::*; use crate::openhuman::threads::turn_state::types::{ - ToolTimelineEntry, ToolTimelineStatus, TurnLifecycle, TurnState, + SubagentActivity, SubagentToolCall, SubagentTranscriptItem, ToolTimelineEntry, + ToolTimelineStatus, TurnLifecycle, TurnState, }; use tempfile::tempdir; @@ -44,6 +45,7 @@ fn put_then_get_roundtrips_state() { subagent: None, failure: None, output: None, + seq: Some(0), }); store.put(&state).expect("put"); @@ -51,6 +53,107 @@ fn put_then_get_roundtrips_state() { assert_eq!(loaded, state); } +#[test] +fn roundtrips_subagent_interleaved_transcript_with_full_fidelity() { + // A settled turn whose subagent streamed reasoning, called a tool, then + // narrated — the interleaved transcript (not just the flat tool rows) plus + // the per-row `seq` ordering keys must survive a disk round-trip verbatim, + // so a reopened transcript rehydrates without losing the subagent's + // reasoning (the gap SubagentDrawer/chatRuntimeSlice documented). + let dir = tempdir().expect("tempdir"); + let store = TurnStateStore::new(dir.path().to_path_buf()); + let mut state = sample_state("thread-sub"); + state.lifecycle = TurnLifecycle::Completed; + + let activity = SubagentActivity { + task_id: "sub-1".into(), + agent_id: "researcher".into(), + status: Some("completed".into()), + mode: Some("typed".into()), + dedicated_thread: Some(false), + child_iteration: Some(1), + child_max_iterations: Some(8), + iterations: Some(2), + elapsed_ms: Some(1234), + output_chars: Some(42), + worker_thread_id: Some("worker-thread-9".into()), + tool_calls: vec![SubagentToolCall { + call_id: "c1".into(), + tool_name: "search".into(), + status: ToolTimelineStatus::Success, + iteration: Some(1), + elapsed_ms: Some(12), + output_chars: Some(6), + display_name: Some("Searching".into()), + detail: None, + failure: None, + output: Some("3 hits".into()), + }], + transcript: vec![ + SubagentTranscriptItem::Thinking { + iteration: Some(1), + text: "let me search.".into(), + }, + SubagentTranscriptItem::Tool { + iteration: Some(1), + call_id: "c1".into(), + tool_name: "search".into(), + status: ToolTimelineStatus::Success, + elapsed_ms: Some(12), + output_chars: Some(6), + display_name: Some("Searching".into()), + detail: None, + }, + SubagentTranscriptItem::Text { + iteration: Some(1), + text: "Found it.".into(), + }, + ], + }; + + state.tool_timeline.push(ToolTimelineEntry { + id: "subagent:sub-1".into(), + name: "subagent:researcher".into(), + round: 1, + status: ToolTimelineStatus::Success, + args_buffer: None, + display_name: Some("Researcher".into()), + detail: None, + source_tool_name: Some("spawn_subagent".into()), + subagent: Some(activity), + failure: None, + output: None, + seq: Some(3), + }); + + store.put(&state).expect("put"); + let loaded = store.get("thread-sub").expect("get").expect("present"); + // Structural equality proves nothing in the interleaved transcript, + // subagent activity, or the `seq` ordering keys was dropped or reordered. + assert_eq!(loaded, state); + + // Spot-check the interleaving explicitly so a future regression that keeps + // the fields but loses the ordering still fails here. + let activity = loaded.tool_timeline[0] + .subagent + .as_ref() + .expect("subagent activity restored"); + assert_eq!(activity.transcript.len(), 3); + assert!(matches!( + activity.transcript[0], + SubagentTranscriptItem::Thinking { .. } + )); + assert!(matches!( + activity.transcript[1], + SubagentTranscriptItem::Tool { .. } + )); + assert!(matches!( + activity.transcript[2], + SubagentTranscriptItem::Text { .. } + )); + assert_eq!(loaded.tool_timeline[0].seq, Some(3)); +} + #[test] fn get_returns_none_when_absent() { let dir = tempdir().expect("tempdir"); diff --git a/src/openhuman/threads/turn_state/types.rs b/src/openhuman/threads/turn_state/types.rs index ec42e95ebb..054a588547 100644 --- a/src/openhuman/threads/turn_state/types.rs +++ b/src/openhuman/threads/turn_state/types.rs @@ -125,6 +125,13 @@ pub struct ToolTimelineEntry { /// `tool_result`. `None` while running and on legacy snapshots. #[serde(default, skip_serializing_if = "Option::is_none")] pub output: Option, + /// Per-turn monotonic ordering key stamped at the moment the row is first + /// created, so a rehydrated timeline can order rows identically to the live + /// stream (conversations-timeline-refactor, Phase 4 amendment). Shares the + /// per-turn ordering space with [`TranscriptItem::seq`]. `None` on snapshots + /// written before this field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seq: Option, } /// Live sub-agent activity nested under a `subagent:*` timeline row. diff --git a/src/openhuman/web_chat/presentation.rs b/src/openhuman/web_chat/presentation.rs index ef40227b0a..dfb117477d 100644 --- a/src/openhuman/web_chat/presentation.rs +++ b/src/openhuman/web_chat/presentation.rs @@ -110,6 +110,9 @@ pub(crate) async fn deliver_response( Some(serde_json::json!(citations)) }, usage: usage_payload, + // Terminal delivery events are emitted outside the seq-stamping + // progress bridge; leave `seq` unset (older clients ignore it). + seq: None, }); return; } @@ -161,6 +164,7 @@ pub(crate) async fn deliver_response( }, // Usage is attached only to the terminal `chat_done`, never segments. usage: None, + seq: None, }); } @@ -201,6 +205,9 @@ pub(crate) async fn deliver_response( Some(serde_json::json!(citations)) }, usage: usage_payload, + // Terminal delivery events are emitted outside the seq-stamping + // progress bridge; leave `seq` unset (older clients ignore it). + seq: None, }); } diff --git a/src/openhuman/web_chat/progress_bridge.rs b/src/openhuman/web_chat/progress_bridge.rs index fec232ae61..d3fd5fc111 100644 --- a/src/openhuman/web_chat/progress_bridge.rs +++ b/src/openhuman/web_chat/progress_bridge.rs @@ -34,6 +34,7 @@ fn flush_interim_narration( client_id: &str, thread_id: &str, request_id: &str, + emit_seq: &mut u64, ) { let text = std::mem::take(buffer); let Some(narration) = interim_narration_text(&text) else { @@ -45,15 +46,31 @@ fn flush_interim_narration( narration.chars().count(), request_id, ); - publish_web_channel_event(WebChannelEvent { - event: "chat_interim".to_string(), - client_id: client_id.to_string(), - thread_id: thread_id.to_string(), - request_id: request_id.to_string(), - full_response: Some(narration), - round: Some(round), - ..Default::default() - }); + publish_seq_stamped( + emit_seq, + WebChannelEvent { + event: "chat_interim".to_string(), + client_id: client_id.to_string(), + thread_id: thread_id.to_string(), + request_id: request_id.to_string(), + full_response: Some(narration), + round: Some(round), + ..Default::default() + }, + ); +} + +/// Stamp a per-request monotonic sequence number on an outgoing web-channel +/// event and publish it. `request_id` is already carried on every +/// [`WebChannelEvent`]; `seq` is the additive ordering key the frontend uses to +/// dedup replayed vs live events by `(request_id, seq)` and to order them +/// identically to the persisted turn-state snapshot +/// (conversations-timeline-refactor, Phase 4). The counter advances once per +/// emitted event so each `(request_id, seq)` pair is unique within a turn. +fn publish_seq_stamped(next_seq: &mut u64, mut event: WebChannelEvent) { + event.seq = Some(*next_seq); + *next_seq = next_seq.saturating_add(1); + publish_web_channel_event(event); } /// The trimmed narration to surface as an interim bubble, or `None` when it is @@ -340,6 +357,10 @@ pub(crate) fn spawn_progress_bridge( // (it belongs to the terminal round, which ends with no tool call). let mut pending_narration = String::new(); let mut events_seen: u64 = 0; + // Per-request monotonic ordering key stamped on every emitted + // web-channel event (see `publish_seq_stamped`). Unique per emission so + // the frontend can dedup by `(request_id, seq)`. + let mut emit_seq: u64 = 0; let mut parent_completed = false; let mut parent_tool_count: u64 = 0; let mut child_tool_counts: HashMap = HashMap::new(); @@ -444,7 +465,7 @@ pub(crate) fn spawn_progress_bridge( thread_id, request_id, ); - publish_web_channel_event(WebChannelEvent { + publish_seq_stamped(&mut emit_seq, WebChannelEvent { event: "inference_heartbeat".to_string(), client_id: client_id.clone(), thread_id: thread_id.clone(), @@ -580,13 +601,16 @@ pub(crate) fn spawn_progress_bridge( payload: json!({ "threadId": thread_id, "clientId": client_id }), }, ); - publish_web_channel_event(WebChannelEvent { - event: "inference_start".to_string(), - client_id: client_id.clone(), - thread_id: thread_id.clone(), - request_id: request_id.clone(), - ..Default::default() - }); + publish_seq_stamped( + &mut emit_seq, + WebChannelEvent { + event: "inference_start".to_string(), + client_id: client_id.clone(), + thread_id: thread_id.clone(), + request_id: request_id.clone(), + ..Default::default() + }, + ); } AgentProgress::IterationStarted { iteration, @@ -594,15 +618,18 @@ pub(crate) fn spawn_progress_bridge( } => { round = iteration; parent_max_iterations = max_iterations; - publish_web_channel_event(WebChannelEvent { - event: "iteration_start".to_string(), - client_id: client_id.clone(), - thread_id: thread_id.clone(), - request_id: request_id.clone(), - message: Some(format!("Iteration {iteration}/{max_iterations}")), - round: Some(iteration), - ..Default::default() - }); + publish_seq_stamped( + &mut emit_seq, + WebChannelEvent { + event: "iteration_start".to_string(), + client_id: client_id.clone(), + thread_id: thread_id.clone(), + request_id: request_id.clone(), + message: Some(format!("Iteration {iteration}/{max_iterations}")), + round: Some(iteration), + ..Default::default() + }, + ); } AgentProgress::ToolCallStarted { call_id, @@ -621,6 +648,7 @@ pub(crate) fn spawn_progress_bridge( &client_id, &thread_id, &request_id, + &mut emit_seq, ); parent_tool_count += 1; ledger_append_event( @@ -643,20 +671,23 @@ pub(crate) fn spawn_progress_bridge( ..Default::default() }, ); - publish_web_channel_event(WebChannelEvent { - event: "tool_call".to_string(), - client_id: client_id.clone(), - thread_id: thread_id.clone(), - request_id: request_id.clone(), - tool_name: Some(tool_name), - skill_id: Some("web_channel".to_string()), - args: Some(arguments), - round: Some(iteration), - tool_call_id: Some(call_id), - tool_display_label: display_label, - tool_display_detail: display_detail, - ..Default::default() - }); + publish_seq_stamped( + &mut emit_seq, + WebChannelEvent { + event: "tool_call".to_string(), + client_id: client_id.clone(), + thread_id: thread_id.clone(), + request_id: request_id.clone(), + tool_name: Some(tool_name), + skill_id: Some("web_channel".to_string()), + args: Some(arguments), + round: Some(iteration), + tool_call_id: Some(call_id), + tool_display_label: display_label, + tool_display_detail: display_detail, + ..Default::default() + }, + ); } AgentProgress::ToolCallCompleted { call_id, @@ -687,24 +718,27 @@ pub(crate) fn spawn_progress_bridge( }), }, ); - publish_web_channel_event(WebChannelEvent { - event: "tool_result".to_string(), - client_id: client_id.clone(), - thread_id: thread_id.clone(), - request_id: request_id.clone(), - tool_name: Some(tool_name), - skill_id: Some("web_channel".to_string()), - // Forward the real tool result (size-capped) so the UI - // can render tool output — mirrors the subagent - // `subagent_tool_result` path. Frontends that only - // need size/timing read the ledger telemetry instead. - output: Some(cap_wire_output(output)), - success: Some(success), - round: Some(iteration), - tool_call_id: Some(call_id), - failure: failure_json, - ..Default::default() - }); + publish_seq_stamped( + &mut emit_seq, + WebChannelEvent { + event: "tool_result".to_string(), + client_id: client_id.clone(), + thread_id: thread_id.clone(), + request_id: request_id.clone(), + tool_name: Some(tool_name), + skill_id: Some("web_channel".to_string()), + // Forward the real tool result (size-capped) so the UI + // can render tool output — mirrors the subagent + // `subagent_tool_result` path. Frontends that only + // need size/timing read the ledger telemetry instead. + output: Some(cap_wire_output(output)), + success: Some(success), + round: Some(iteration), + tool_call_id: Some(call_id), + failure: failure_json, + ..Default::default() + }, + ); } AgentProgress::SubagentSpawned { agent_id, @@ -770,25 +804,28 @@ pub(crate) fn spawn_progress_bridge( }), }, ); - publish_web_channel_event(WebChannelEvent { - event: "subagent_spawned".to_string(), - client_id: client_id.clone(), - thread_id: thread_id.clone(), - request_id: request_id.clone(), - message: Some(format!("Sub-agent '{label}' spawned")), - tool_name: Some(agent_id), - skill_id: Some(task_id), - round: Some(round), - subagent: Some(SubagentProgressDetail { - mode: Some(mode), - dedicated_thread: Some(dedicated_thread), - prompt_chars: Some(prompt_chars as u64), - worker_thread_id, - display_name, + publish_seq_stamped( + &mut emit_seq, + WebChannelEvent { + event: "subagent_spawned".to_string(), + client_id: client_id.clone(), + thread_id: thread_id.clone(), + request_id: request_id.clone(), + message: Some(format!("Sub-agent '{label}' spawned")), + tool_name: Some(agent_id), + skill_id: Some(task_id), + round: Some(round), + subagent: Some(SubagentProgressDetail { + mode: Some(mode), + dedicated_thread: Some(dedicated_thread), + prompt_chars: Some(prompt_chars as u64), + worker_thread_id, + display_name, + ..Default::default() + }), ..Default::default() - }), - ..Default::default() - }); + }, + ); } AgentProgress::SubagentCompleted { agent_id, @@ -851,29 +888,36 @@ pub(crate) fn spawn_progress_bridge( }), }, ); - publish_web_channel_event(WebChannelEvent { - event: "subagent_completed".to_string(), - client_id: client_id.clone(), - thread_id: thread_id.clone(), - request_id: request_id.clone(), - message: Some(format!( - "Sub-agent '{agent_id}' completed in {elapsed_ms}ms" - )), - tool_name: Some(agent_id), - skill_id: Some(task_id), - success: Some(true), - round: Some(round), - subagent: Some(SubagentProgressDetail { - elapsed_ms: Some(elapsed_ms), - iterations: Some(iterations), - output_chars: Some(output_chars as u64), - // Worktree isolation metadata (#3376) — drives the - // inline subagent worktree row's open/diff/remove - // actions. All `None`/absent for non-isolated workers. - ..subagent_worktree_detail(worktree_path, changed_files, dirty_status) - }), - ..Default::default() - }); + publish_seq_stamped( + &mut emit_seq, + WebChannelEvent { + event: "subagent_completed".to_string(), + client_id: client_id.clone(), + thread_id: thread_id.clone(), + request_id: request_id.clone(), + message: Some(format!( + "Sub-agent '{agent_id}' completed in {elapsed_ms}ms" + )), + tool_name: Some(agent_id), + skill_id: Some(task_id), + success: Some(true), + round: Some(round), + subagent: Some(SubagentProgressDetail { + elapsed_ms: Some(elapsed_ms), + iterations: Some(iterations), + output_chars: Some(output_chars as u64), + // Worktree isolation metadata (#3376) — drives the + // inline subagent worktree row's open/diff/remove + // actions. All `None`/absent for non-isolated workers. + ..subagent_worktree_detail( + worktree_path, + changed_files, + dirty_status, + ) + }), + ..Default::default() + }, + ); } AgentProgress::SubagentFailed { agent_id, @@ -920,18 +964,21 @@ pub(crate) fn spawn_progress_bridge( payload: json!({ "agentId": agent_id, "error": error }), }, ); - publish_web_channel_event(WebChannelEvent { - event: "subagent_failed".to_string(), - client_id: client_id.clone(), - thread_id: thread_id.clone(), - request_id: request_id.clone(), - message: Some(error), - tool_name: Some(agent_id), - skill_id: Some(task_id), - success: Some(false), - round: Some(round), - ..Default::default() - }); + publish_seq_stamped( + &mut emit_seq, + WebChannelEvent { + event: "subagent_failed".to_string(), + client_id: client_id.clone(), + thread_id: thread_id.clone(), + request_id: request_id.clone(), + message: Some(error), + tool_name: Some(agent_id), + skill_id: Some(task_id), + success: Some(false), + round: Some(round), + ..Default::default() + }, + ); } AgentProgress::SubagentAwaitingUser { agent_id, @@ -995,22 +1042,25 @@ pub(crate) fn spawn_progress_bridge( }), }, ); - publish_web_channel_event(WebChannelEvent { - event: "subagent_awaiting_user".to_string(), - client_id: client_id.clone(), - thread_id: thread_id.clone(), - request_id: request_id.clone(), - message: Some(question), - tool_name: Some(agent_id), - skill_id: Some(task_id), - success: Some(true), - round: Some(round), - subagent: Some(SubagentProgressDetail { - worker_thread_id, + publish_seq_stamped( + &mut emit_seq, + WebChannelEvent { + event: "subagent_awaiting_user".to_string(), + client_id: client_id.clone(), + thread_id: thread_id.clone(), + request_id: request_id.clone(), + message: Some(question), + tool_name: Some(agent_id), + skill_id: Some(task_id), + success: Some(true), + round: Some(round), + subagent: Some(SubagentProgressDetail { + worker_thread_id, + ..Default::default() + }), ..Default::default() - }), - ..Default::default() - }); + }, + ); } AgentProgress::SubagentIterationStarted { agent_id, @@ -1019,30 +1069,35 @@ pub(crate) fn spawn_progress_bridge( max_iterations, extended_policy, } => { - publish_web_channel_event(WebChannelEvent { - event: "subagent_iteration_start".to_string(), - client_id: client_id.clone(), - thread_id: thread_id.clone(), - request_id: request_id.clone(), - message: Some(if extended_policy { - format!("Sub-agent '{agent_id}' step {iteration}") - } else { - format!("Sub-agent '{agent_id}' iteration {iteration}/{max_iterations}") - }), - tool_name: Some(agent_id), - skill_id: Some(task_id), - round: Some(round), - subagent: Some(SubagentProgressDetail { - child_iteration: Some(iteration), - child_max_iterations: if extended_policy { - None + publish_seq_stamped( + &mut emit_seq, + WebChannelEvent { + event: "subagent_iteration_start".to_string(), + client_id: client_id.clone(), + thread_id: thread_id.clone(), + request_id: request_id.clone(), + message: Some(if extended_policy { + format!("Sub-agent '{agent_id}' step {iteration}") } else { - Some(max_iterations) - }, + format!( + "Sub-agent '{agent_id}' iteration {iteration}/{max_iterations}" + ) + }), + tool_name: Some(agent_id), + skill_id: Some(task_id), + round: Some(round), + subagent: Some(SubagentProgressDetail { + child_iteration: Some(iteration), + child_max_iterations: if extended_policy { + None + } else { + Some(max_iterations) + }, + ..Default::default() + }), ..Default::default() - }), - ..Default::default() - }); + }, + ); } AgentProgress::SubagentToolCallStarted { agent_id, @@ -1077,33 +1132,36 @@ pub(crate) fn spawn_progress_bridge( }), }, ); - publish_web_channel_event(WebChannelEvent { - event: "subagent_tool_call".to_string(), - client_id: client_id.clone(), - thread_id: thread_id.clone(), - request_id: request_id.clone(), - tool_name: Some(tool_name), - skill_id: Some(task_id.clone()), - // The child's tool arguments, so the UI can show what - // the sub-agent actually did (issue: subagent drawer - // detail). Skipped from the wire when `null`. - args: if arguments.is_null() { - None - } else { - Some(arguments) - }, - round: Some(round), - tool_call_id: Some(call_id), - tool_display_label: display_label, - tool_display_detail: display_detail, - subagent: Some(SubagentProgressDetail { - child_iteration: Some(iteration), - agent_id: Some(agent_id), - task_id: Some(task_id), + publish_seq_stamped( + &mut emit_seq, + WebChannelEvent { + event: "subagent_tool_call".to_string(), + client_id: client_id.clone(), + thread_id: thread_id.clone(), + request_id: request_id.clone(), + tool_name: Some(tool_name), + skill_id: Some(task_id.clone()), + // The child's tool arguments, so the UI can show what + // the sub-agent actually did (issue: subagent drawer + // detail). Skipped from the wire when `null`. + args: if arguments.is_null() { + None + } else { + Some(arguments) + }, + round: Some(round), + tool_call_id: Some(call_id), + tool_display_label: display_label, + tool_display_detail: display_detail, + subagent: Some(SubagentProgressDetail { + child_iteration: Some(iteration), + agent_id: Some(agent_id), + task_id: Some(task_id), + ..Default::default() + }), ..Default::default() - }), - ..Default::default() - }); + }, + ); } AgentProgress::SubagentToolCallCompleted { agent_id, @@ -1139,32 +1197,35 @@ pub(crate) fn spawn_progress_bridge( }), }, ); - publish_web_channel_event(WebChannelEvent { - event: "subagent_tool_result".to_string(), - client_id: client_id.clone(), - thread_id: thread_id.clone(), - request_id: request_id.clone(), - tool_name: Some(tool_name), - skill_id: Some(task_id.clone()), - success: Some(success), - round: Some(round), - tool_call_id: Some(call_id), - // The child's actual tool output, so the drawer can show - // *what came back* (not just a char count). Capped to a - // bounded size for the wire (#4007); `output_chars` + - // `elapsed_ms` still ride along in `subagent` below. - output: Some(cap_wire_output(output)), - failure: failure_json, - subagent: Some(SubagentProgressDetail { - child_iteration: Some(iteration), - agent_id: Some(agent_id), - task_id: Some(task_id), - elapsed_ms: Some(elapsed_ms), - output_chars: Some(output_chars as u64), + publish_seq_stamped( + &mut emit_seq, + WebChannelEvent { + event: "subagent_tool_result".to_string(), + client_id: client_id.clone(), + thread_id: thread_id.clone(), + request_id: request_id.clone(), + tool_name: Some(tool_name), + skill_id: Some(task_id.clone()), + success: Some(success), + round: Some(round), + tool_call_id: Some(call_id), + // The child's actual tool output, so the drawer can show + // *what came back* (not just a char count). Capped to a + // bounded size for the wire (#4007); `output_chars` + + // `elapsed_ms` still ride along in `subagent` below. + output: Some(cap_wire_output(output)), + failure: failure_json, + subagent: Some(SubagentProgressDetail { + child_iteration: Some(iteration), + agent_id: Some(agent_id), + task_id: Some(task_id), + elapsed_ms: Some(elapsed_ms), + output_chars: Some(output_chars as u64), + ..Default::default() + }), ..Default::default() - }), - ..Default::default() - }); + }, + ); } AgentProgress::SubagentTextDelta { agent_id, @@ -1172,23 +1233,26 @@ pub(crate) fn spawn_progress_bridge( delta, iteration, } => { - publish_web_channel_event(WebChannelEvent { - event: "subagent_text_delta".to_string(), - client_id: client_id.clone(), - thread_id: thread_id.clone(), - request_id: request_id.clone(), - round: Some(round), - delta: Some(delta), - delta_kind: Some("text".to_string()), - skill_id: Some(task_id.clone()), - subagent: Some(SubagentProgressDetail { - child_iteration: Some(iteration), - agent_id: Some(agent_id), - task_id: Some(task_id), + publish_seq_stamped( + &mut emit_seq, + WebChannelEvent { + event: "subagent_text_delta".to_string(), + client_id: client_id.clone(), + thread_id: thread_id.clone(), + request_id: request_id.clone(), + round: Some(round), + delta: Some(delta), + delta_kind: Some("text".to_string()), + skill_id: Some(task_id.clone()), + subagent: Some(SubagentProgressDetail { + child_iteration: Some(iteration), + agent_id: Some(agent_id), + task_id: Some(task_id), + ..Default::default() + }), ..Default::default() - }), - ..Default::default() - }); + }, + ); } AgentProgress::SubagentThinkingDelta { agent_id, @@ -1196,23 +1260,26 @@ pub(crate) fn spawn_progress_bridge( delta, iteration, } => { - publish_web_channel_event(WebChannelEvent { - event: "subagent_thinking_delta".to_string(), - client_id: client_id.clone(), - thread_id: thread_id.clone(), - request_id: request_id.clone(), - round: Some(round), - delta: Some(delta), - delta_kind: Some("thinking".to_string()), - skill_id: Some(task_id.clone()), - subagent: Some(SubagentProgressDetail { - child_iteration: Some(iteration), - agent_id: Some(agent_id), - task_id: Some(task_id), + publish_seq_stamped( + &mut emit_seq, + WebChannelEvent { + event: "subagent_thinking_delta".to_string(), + client_id: client_id.clone(), + thread_id: thread_id.clone(), + request_id: request_id.clone(), + round: Some(round), + delta: Some(delta), + delta_kind: Some("thinking".to_string()), + skill_id: Some(task_id.clone()), + subagent: Some(SubagentProgressDetail { + child_iteration: Some(iteration), + agent_id: Some(agent_id), + task_id: Some(task_id), + ..Default::default() + }), ..Default::default() - }), - ..Default::default() - }); + }, + ); } AgentProgress::TaskBoardUpdated { board } => { log::debug!( @@ -1222,43 +1289,52 @@ pub(crate) fn spawn_progress_bridge( request_id, board.cards.len() ); - publish_web_channel_event(WebChannelEvent { - event: "task_board_updated".to_string(), - client_id: client_id.clone(), - thread_id: thread_id.clone(), - request_id: request_id.clone(), - task_board: Some(serde_json::to_value(board).unwrap_or_else( - |_| serde_json::json!({ "threadId": thread_id, "cards": [] }), - )), - ..Default::default() - }); + publish_seq_stamped( + &mut emit_seq, + WebChannelEvent { + event: "task_board_updated".to_string(), + client_id: client_id.clone(), + thread_id: thread_id.clone(), + request_id: request_id.clone(), + task_board: Some(serde_json::to_value(board).unwrap_or_else( + |_| serde_json::json!({ "threadId": thread_id, "cards": [] }), + )), + ..Default::default() + }, + ); } AgentProgress::TextDelta { delta, iteration } => { // Buffer the round's narration so it can be flushed as an // interim bubble if a tool call closes this round. pending_narration.push_str(&delta); - publish_web_channel_event(WebChannelEvent { - event: "text_delta".to_string(), - client_id: client_id.clone(), - thread_id: thread_id.clone(), - request_id: request_id.clone(), - round: Some(iteration), - delta: Some(delta), - delta_kind: Some("text".to_string()), - ..Default::default() - }); + publish_seq_stamped( + &mut emit_seq, + WebChannelEvent { + event: "text_delta".to_string(), + client_id: client_id.clone(), + thread_id: thread_id.clone(), + request_id: request_id.clone(), + round: Some(iteration), + delta: Some(delta), + delta_kind: Some("text".to_string()), + ..Default::default() + }, + ); } AgentProgress::ThinkingDelta { delta, iteration } => { - publish_web_channel_event(WebChannelEvent { - event: "thinking_delta".to_string(), - client_id: client_id.clone(), - thread_id: thread_id.clone(), - request_id: request_id.clone(), - round: Some(iteration), - delta: Some(delta), - delta_kind: Some("thinking".to_string()), - ..Default::default() - }); + publish_seq_stamped( + &mut emit_seq, + WebChannelEvent { + event: "thinking_delta".to_string(), + client_id: client_id.clone(), + thread_id: thread_id.clone(), + request_id: request_id.clone(), + round: Some(iteration), + delta: Some(delta), + delta_kind: Some("thinking".to_string()), + ..Default::default() + }, + ); } AgentProgress::ToolCallArgsDelta { call_id, @@ -1266,23 +1342,26 @@ pub(crate) fn spawn_progress_bridge( delta, iteration, } => { - publish_web_channel_event(WebChannelEvent { - event: "tool_args_delta".to_string(), - client_id: client_id.clone(), - thread_id: thread_id.clone(), - request_id: request_id.clone(), - tool_name: if tool_name.is_empty() { - None - } else { - Some(tool_name) + publish_seq_stamped( + &mut emit_seq, + WebChannelEvent { + event: "tool_args_delta".to_string(), + client_id: client_id.clone(), + thread_id: thread_id.clone(), + request_id: request_id.clone(), + tool_name: if tool_name.is_empty() { + None + } else { + Some(tool_name) + }, + skill_id: Some("web_channel".to_string()), + round: Some(iteration), + delta: Some(delta), + delta_kind: Some("tool_args".to_string()), + tool_call_id: Some(call_id), + ..Default::default() }, - skill_id: Some("web_channel".to_string()), - round: Some(iteration), - delta: Some(delta), - delta_kind: Some("tool_args".to_string()), - tool_call_id: Some(call_id), - ..Default::default() - }); + ); } AgentProgress::TurnCompleted { iterations } => { parent_completed = true; @@ -1790,4 +1869,59 @@ mod tests { drop(tx); } + + /// Every event the bridge emits carries an additive per-request monotonic + /// `seq` (conversations-timeline-refactor, Phase 4), so the frontend can + /// dedup replayed vs live events by `(request_id, seq)` and order them + /// identically to the persisted snapshot. Drive a short deterministic + /// sequence and assert the emitted seqs are present and strictly increasing. + #[tokio::test] + async fn stamps_monotonic_seq_on_emitted_events() { + let mut events = super::super::event_bus::subscribe_web_channel_events(); + let thread_id = "thread-seq-stamp"; + let request_id = "req-seq-stamp"; + let tx = spawn_test_bridge(thread_id, request_id); + + // Each of these emits exactly one web-channel event: inference_start, + // tool_call, tool_result (the fast test never trips the 20s heartbeat). + tx.send(AgentProgress::TurnStarted).await.unwrap(); + tx.send(AgentProgress::ToolCallStarted { + call_id: "tc-1".into(), + tool_name: "shell".into(), + arguments: serde_json::json!({}), + iteration: 1, + display_label: None, + display_detail: None, + }) + .await + .unwrap(); + tx.send(AgentProgress::ToolCallCompleted { + call_id: "tc-1".into(), + tool_name: "shell".into(), + success: true, + output_chars: 0, + output: String::new(), + arguments: None, + elapsed_ms: 5, + iteration: 1, + failure: None, + }) + .await + .unwrap(); + + let mut seqs = Vec::new(); + for _ in 0..3 { + let ev = recv_for_thread(&mut events, thread_id).await; + assert_eq!(ev.request_id, request_id); + seqs.push(ev.seq.expect("every emitted event carries a seq")); + } + // The very first emitted event starts the per-request counter at 0. + assert_eq!(seqs[0], 0, "seq counter starts at 0 for the request"); + assert!( + seqs.windows(2).all(|w| w[0] < w[1]), + "emitted seqs must be strictly increasing, got {seqs:?}" + ); + + drop(tx); + } } diff --git a/src/openhuman/web_chat/run_task.rs b/src/openhuman/web_chat/run_task.rs index a5b6c34114..cb3f874393 100644 --- a/src/openhuman/web_chat/run_task.rs +++ b/src/openhuman/web_chat/run_task.rs @@ -163,39 +163,57 @@ pub(crate) async fn run_chat_task( ), }; - // Cold-boot resume from the conversation JSONL. + // Cold-boot resume. Prefer the full-fidelity `session_raw/{stem}.jsonl` + // transcript (tool calls, tool-role results, reasoning) routed by thread + // id — the model must not "forget" its tool interactions across an app + // restart. Only fall back to the lossy conversation-log prose pairs when + // no root transcript exists for the thread or it fails to load; the two + // sources overlap (user prompts + final assistant text), so we take one + // or the other, never both, to avoid duplicated context. if was_built_fresh { - match crate::openhuman::memory_conversations::get_messages( - config.workspace_dir.clone(), - thread_id, - ) { - Ok(prior_messages) if !prior_messages.is_empty() => { - let pairs: Vec<(String, String)> = prior_messages - .into_iter() - .map(|m| (m.sender, m.content)) - .collect(); - if let Err(err) = agent.seed_resume_from_messages(pairs, message) { + if agent.seed_resume_from_thread_transcript(thread_id) { + log::info!( + "[web-channel] cold-boot resumed thread={} from full-fidelity session transcript", + thread_id + ); + } else { + log::debug!( + "[web-channel] no usable session transcript for thread={} — seeding resume \ + from conversation-log prose", + thread_id + ); + match crate::openhuman::memory_conversations::get_messages( + config.workspace_dir.clone(), + thread_id, + ) { + Ok(prior_messages) if !prior_messages.is_empty() => { + let pairs: Vec<(String, String)> = prior_messages + .into_iter() + .map(|m| (m.sender, m.content)) + .collect(); + if let Err(err) = agent.seed_resume_from_messages(pairs, message) { + log::warn!( + "[web-channel] failed to seed agent resume from conversation log \ + thread={} err={}", + thread_id, + err + ); + } + } + Ok(_) => { + log::debug!( + "[web-channel] no prior messages to seed for thread={} — first turn", + thread_id + ); + } + Err(err) => { log::warn!( - "[web-channel] failed to seed agent resume from conversation log \ - thread={} err={}", + "[web-channel] failed to read conversation log for resume thread={} err={}", thread_id, err ); } } - Ok(_) => { - log::debug!( - "[web-channel] no prior messages to seed for thread={} — first turn", - thread_id - ); - } - Err(err) => { - log::warn!( - "[web-channel] failed to read conversation log for resume thread={} err={}", - thread_id, - err - ); - } } } From 684ad0d2f51a8398c3fbfd45f3f61a3f43fbf535 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:00:38 +0300 Subject: [PATCH 05/56] perf(core): consolidate boot polls, silence per-snapshot log spam (#5075) --- .github/tauri-cef-expected-sha | 2 +- .gitignore | 5 +- .husky/pre-push | 34 +- app/src-tauri/vendor/tauri-cef | 2 +- app/src/AppRoutes.tsx | 56 +++- .../HarnessInitOverlay.test.tsx | 21 ++ .../InitProgressScreen/HarnessInitOverlay.tsx | 26 +- .../components/LocalAIDownloadSnackbar.tsx | 92 +++++- .../LocalAIDownloadSnackbar.test.tsx | 47 ++- .../layout/shell/CollapsedNavRail.test.tsx | 1 - .../orchestration/AgentChatPanel.tsx | 8 +- .../orchestration/ConnectionsPanel.tsx | 2 +- .../orchestration/OrchestrationView.tsx | 251 +++++++++++++++ .../__tests__/OrchestrationView.test.tsx | 144 +++++++++ .../settings/settingsRouteElements.tsx | 4 +- app/src/config/__tests__/navConfig.test.ts | 12 +- app/src/config/navConfig.ts | 14 +- .../features/conversations/Conversations.tsx | 4 +- .../components/InterruptedAnswer.tsx | 8 +- app/src/hooks/useDaemonHealth.ts | 21 +- 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 | 2 +- app/src/pages/Brain.tsx | 301 ++++++++++-------- app/src/pages/OrchestrationPage.tsx | 289 ----------------- app/src/pages/__tests__/Brain.test.tsx | 20 +- .../__tests__/OrchestrationPage.test.tsx | 126 -------- app/src/providers/CoreStateProvider.tsx | 60 +++- .../__tests__/daemonHealthService.test.ts | 128 ++++++++ app/src/services/coreStateApi.ts | 27 ++ app/src/services/daemonHealthService.ts | 79 +++-- .../__tests__/chatRuntimeSlice.thunk.test.ts | 2 +- app/src/store/chatRuntimeSlice.test.ts | 4 +- app/test/e2e/specs/navigation.spec.ts | 6 +- src/openhuman/app_state/ops.rs | 7 + .../config/schema/load/env_overlay.rs | 53 ++- src/openhuman/config/schema/load_tests.rs | 42 +++ src/openhuman/health/core.rs | 6 +- src/openhuman/keyring_consent/policy.rs | 64 +++- src/openhuman/keyring_consent/types.rs | 2 +- tests/json_rpc_e2e.rs | 24 ++ worktrees/.gitkeep | 0 53 files changed, 1292 insertions(+), 730 deletions(-) create mode 100644 app/src/components/orchestration/OrchestrationView.tsx create mode 100644 app/src/components/orchestration/__tests__/OrchestrationView.test.tsx delete mode 100644 app/src/pages/OrchestrationPage.tsx delete mode 100644 app/src/pages/__tests__/OrchestrationPage.test.tsx create mode 100644 app/src/services/__tests__/daemonHealthService.test.ts create mode 100644 worktrees/.gitkeep diff --git a/.github/tauri-cef-expected-sha b/.github/tauri-cef-expected-sha index fc80ee845c..0bc836bb06 100644 --- a/.github/tauri-cef-expected-sha +++ b/.github/tauri-cef-expected-sha @@ -1 +1 @@ -5ec3d8836cbae300e36b7a9c0d302a65de92c58d +11ef51edbeadcca4517b18a037fff858a5dfae0f diff --git a/.gitignore b/.gitignore index b5dfc2b822..4ab2b54663 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +worktrees/* +!worktrees/.gitkeep + # Workflow docs (local only) workflow create_issue @@ -129,4 +132,4 @@ distribution.cer # Release note previews CHANGELOG.preview.md *.profraw -*.diff \ No newline at end of file +*.diff diff --git a/.husky/pre-push b/.husky/pre-push index d39768b34a..8cbf5b3b49 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,16 +1,26 @@ #!/usr/bin/env sh -# Bail out immediately on Ctrl+C / SIGTERM. Without this trap, an interrupt -# only kills the current pnpm subprocess; the script then captures its 130 -# exit, mistakes it for a normal failure, and runs the next pnpm step. +# Bail out immediately on Ctrl+C / SIGTERM. abort() { + EXIT_CODE="$1" echo echo "Pre-push aborted." - trap - INT TERM - kill -- -$$ 2>/dev/null - exit 130 + exit "$EXIT_CODE" +} +trap 'abort 130' INT +trap 'abort 143' TERM + +# Commands run synchronously in the hook's foreground process group, so Ctrl+C +# reaches pnpm and its children. Do not mistake the resulting exit code for a +# normal check failure and continue with the next command. +run_check() { + "$@" + CHECK_EXIT=$? + case "$CHECK_EXIT" in + 130|143) abort "$CHECK_EXIT" ;; + esac + return "$CHECK_EXIT" } -trap abort INT TERM # Windows Git Bash can miss Node/Pnpm in PATH when hooks run. # Recover from common PATH drift by hydrating from where.exe. @@ -65,7 +75,7 @@ fi # Run format check first (capture exit code without breaking script) set +e -pnpm format:check +run_check pnpm format:check FORMAT_EXIT=$? set -e @@ -77,7 +87,7 @@ fi # Run lint check (capture exit code without breaking script) set +e -pnpm lint +run_check pnpm lint LINT_EXIT=$? set -e @@ -89,19 +99,19 @@ fi # Run TypeScript compile check (capture exit code without breaking script) set +e -pnpm compile +run_check pnpm compile COMPILE_EXIT=$? set -e # Run Clippy for both Rust codebases; Clippy also performs compile checks. set +e -pnpm rust:clippy +run_check pnpm rust:clippy RUST_CLIPPY_EXIT=$? set -e # Enforce scoped cmd-* tokens in components/commands/ set +e -pnpm --dir app run lint:commands-tokens +run_check pnpm --dir app run lint:commands-tokens CMD_TOKENS_EXIT=$? set -e diff --git a/app/src-tauri/vendor/tauri-cef b/app/src-tauri/vendor/tauri-cef index 5ec3d8836c..11ef51edbe 160000 --- a/app/src-tauri/vendor/tauri-cef +++ b/app/src-tauri/vendor/tauri-cef @@ -1 +1 @@ -Subproject commit 5ec3d8836cbae300e36b7a9c0d302a65de92c58d +Subproject commit 11ef51edbeadcca4517b18a037fff858a5dfae0f diff --git a/app/src/AppRoutes.tsx b/app/src/AppRoutes.tsx index 492e4240b8..a62f7bc15f 100644 --- a/app/src/AppRoutes.tsx +++ b/app/src/AppRoutes.tsx @@ -1,4 +1,4 @@ -import { type Location, Navigate, Route, Routes } from 'react-router-dom'; +import { type Location, Navigate, Route, Routes, useLocation } from 'react-router-dom'; import AgentWorldShell from './agentworld/AgentWorldShell'; import AgentWorld from './agentworld/pages/AgentWorld'; @@ -18,7 +18,6 @@ import FlowsPage from './pages/FlowsPage'; import Invites from './pages/Invites'; import Notifications from './pages/Notifications'; import Onboarding from './pages/onboarding/Onboarding'; -import OrchestrationPage from './pages/OrchestrationPage'; import { PttOverlayPage } from './pages/PttOverlayPage'; import Rewards from './pages/Rewards'; import Skills from './pages/Skills'; @@ -36,6 +35,38 @@ interface AppRoutesProps { location?: Location | string; } +/** + * Redirects the retired `/orchestration` route to its new home under Brain + * (`/brain?tab=orchestration`), mapping the legacy `?tab=`/`?sub=` query onto + * Brain's `?ov=`/`?sub=` scheme so old deep links land on the same view: + * - `?tab=connections|discover|usage` → `?ov=network&sub=` + * - `?tab=agent|overview|tasks|network|medulla` → `?ov=` + * - `?session=` is preserved for the agent chat. + */ +const NETWORK_SUBS = ['connections', 'discover', 'usage']; +const ORCH_VIEWS = ['medulla', 'agent', 'overview', 'tasks', 'network']; + +function OrchestrationRedirect() { + const { search } = useLocation(); + const legacy = new URLSearchParams(search); + const tab = legacy.get('tab'); + + const next = new URLSearchParams(); + next.set('tab', 'orchestration'); + if (tab && NETWORK_SUBS.includes(tab)) { + next.set('ov', 'network'); + next.set('sub', tab); + } else { + if (tab && ORCH_VIEWS.includes(tab)) next.set('ov', tab); + const sub = legacy.get('sub'); + if (sub && NETWORK_SUBS.includes(sub)) next.set('sub', sub); + } + const session = legacy.get('session'); + if (session) next.set('session', session); + + return ; +} + const AppRoutes = ({ location }: AppRoutesProps = {}) => { // Mobile target (iOS or Android): pair → Human/Chat/Settings only. // Desktop routes are not rendered. @@ -137,21 +168,16 @@ const AppRoutes = ({ location }: AppRoutesProps = {}) => { } /> - {/* Orchestration — TinyPlace multi-agent coordination surface, promoted - from a Brain sub-tab into a first-class sidebar destination (sits - right after Workflows). */} - - - - } - /> - {/* Back-compat: the old Brain deep link → the promoted top-level tab. */} + {/* Orchestration folded back under Brain (`/brain?tab=orchestration`). + The old first-class `/orchestration` route and the even older Brain + deep link both redirect there; `` maps the + legacy `?tab=`/`?sub=` query onto Brain's `?ov=`/`?sub=` scheme so + deep links (e.g. `/orchestration?tab=tasks`) keep landing on the same + view. */} + } /> } + element={} /> {/* Back-compat: /activity and /intelligence → settings notifications page. */} diff --git a/app/src/components/InitProgressScreen/HarnessInitOverlay.test.tsx b/app/src/components/InitProgressScreen/HarnessInitOverlay.test.tsx index 9e214e6e67..454b1c4a3c 100644 --- a/app/src/components/InitProgressScreen/HarnessInitOverlay.test.tsx +++ b/app/src/components/InitProgressScreen/HarnessInitOverlay.test.tsx @@ -82,6 +82,27 @@ describe('HarnessInitOverlay', () => { expect(second.container).toBeEmptyDOMElement(); }); + it('coalesces overlapping status polls into one RPC (StrictMode double-mount)', async () => { + // Hold the fetch pending so both mounts' immediate polls overlap. + let resolveFetch: (snap: HarnessInitSnapshot) => void = () => {}; + fetchHarnessInitStatus.mockImplementation( + () => + new Promise(resolve => { + resolveFetch = resolve; + }) + ); + + // Two concurrent overlays stand in for the effect→cleanup→effect double-mount. + renderWithProviders(); + renderWithProviders(); + + // Both immediate polls should share a single in-flight request. + expect(fetchHarnessInitStatus).toHaveBeenCalledTimes(1); + + resolveFetch(snapshot({ overall: 'done', startedAt: 'warm-run' })); + await waitFor(() => expect(screen.queryByText('Run in background')).not.toBeInTheDocument()); + }); + it('reopens for a genuinely new provisioning run after a prior dismissal', async () => { // Dismiss the first run. fetchHarnessInitStatus.mockResolvedValue(snapshot({ startedAt: 'run-1' })); diff --git a/app/src/components/InitProgressScreen/HarnessInitOverlay.tsx b/app/src/components/InitProgressScreen/HarnessInitOverlay.tsx index 0b6b0836da..bd8b79faf4 100644 --- a/app/src/components/InitProgressScreen/HarnessInitOverlay.tsx +++ b/app/src/components/InitProgressScreen/HarnessInitOverlay.tsx @@ -25,6 +25,30 @@ const UNKEYED_RUN = 'pending'; let dismissedRunMirror: string | null = null; +// Coalesce overlapping status polls onto a single in-flight request. React +// StrictMode double-mounts this overlay in dev (effect → cleanup → effect), +// and each setup fires an immediate poll — without this that boots two +// `harness_init_status` RPCs at the same instant. Concurrent callers share the +// in-flight promise; it clears once settled, so the ongoing (sequential) poll +// loop is unaffected. Also guards any genuine remount during the boot window. +let inflightStatusFetch: Promise | null = null; + +function fetchHarnessInitStatusCoalesced(): Promise { + if (inflightStatusFetch) { + log('status poll: joining in-flight request (coalesced)'); + return inflightStatusFetch; + } + log('status poll: dispatching harness_init_status'); + const pending = fetchHarnessInitStatus().finally(() => { + if (inflightStatusFetch === pending) { + inflightStatusFetch = null; + log('status poll: in-flight request settled, cache cleared'); + } + }); + inflightStatusFetch = pending; + return pending; +} + function runKey(snapshot: HarnessInitSnapshot | null): string { return snapshot?.startedAt ?? UNKEYED_RUN; } @@ -85,7 +109,7 @@ export default function HarnessInitOverlay() { const poll = async () => { try { - const next = await fetchHarnessInitStatus(); + const next = await fetchHarnessInitStatusCoalesced(); if (cancelledRef.current || dismissedRef.current) { return; } diff --git a/app/src/components/LocalAIDownloadSnackbar.tsx b/app/src/components/LocalAIDownloadSnackbar.tsx index 419c4b5606..3ac116e970 100644 --- a/app/src/components/LocalAIDownloadSnackbar.tsx +++ b/app/src/components/LocalAIDownloadSnackbar.tsx @@ -1,7 +1,9 @@ +import debugFactory from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useT } from '../lib/i18n/I18nContext'; +import { useCoreState } from '../providers/CoreStateProvider'; import { formatBytes, formatEta, @@ -18,7 +20,35 @@ import { } from '../utils/tauriCommands'; import Button from './ui/Button'; -const POLL_INTERVAL = 2000; +const log = debugFactory('local-ai-download'); + +const ACTIVE_POLL_INTERVAL = 2000; + +const IN_FLIGHT_STATES = new Set(['loading', 'downloading', 'installing']); + +/** Whether a `LocalAiStatus.state` string denotes an active download/bootstrap. */ +const isInFlightState = (state: string | undefined): boolean => + state != null && IN_FLIGHT_STATES.has(state); + +/** + * Pure predicate deciding whether a download/bootstrap is currently in flight, + * mirroring the `isDownloading` derivation used for rendering. Drives the fast + * poll's continuation: keep polling `inference_downloads_progress` + + * `inference_status` only while a download is genuinely in flight. + */ +const isDownloadInFlight = ( + status: LocalAiStatus | null, + downloads: LocalAiDownloadsProgress | null +): boolean => { + const downloadState = downloads?.state; + const currentState = isInFlightState(downloadState) + ? downloadState + : (status?.state ?? downloadState ?? 'idle'); + return ( + isInFlightState(currentState) || + (downloads?.progress != null && downloads.progress > 0 && downloads.progress < 1) + ); +}; /** * Persistent snackbar that shows local AI download progress. @@ -31,7 +61,7 @@ const LocalAIDownloadSnackbar = () => { const [downloads, setDownloads] = useState(null); const [dismissed, setDismissed] = useState(false); const [collapsed, setCollapsed] = useState(false); - const timerRef = useRef>(undefined); + const timerRef = useRef>(undefined); // Track previous isDownloading in state so we can reset the dismiss flag on a // not-downloading → downloading transition during render (render-phase update, // the officially recommended React pattern for adjusting state on derived-value changes). @@ -46,11 +76,26 @@ const LocalAIDownloadSnackbar = () => { } })(); - // Poll download status + // Detect an active download from the folded local-AI state in the app-state + // snapshot (polled by CoreStateProvider) instead of a dedicated idle poll of + // the inference RPCs. When idle this component issues ZERO inference calls; + // the snapshot's `runtime.localAi.state` is what flips us into the fast poll. + const { snapshot: coreSnapshot } = useCoreState(); + const coreDownloadActive = isInFlightState(coreSnapshot.runtime.localAi?.state ?? undefined); + + // While a download is in flight, poll the inference RPCs fast for smooth, + // granular progress/speed/ETA (the 2–5s app-state cadence is too coarse and + // carries no downloads-progress detail). The poll starts when core state + // reports activity and keeps going as long as the download itself is in + // flight, then stops — so there is no steady-state inference polling. useEffect(() => { - if (!tauriAvailable) return; + if (!tauriAvailable || !coreDownloadActive) return; + + let cancelled = false; + log('fast poll: starting (core reports download active)'); const poll = async () => { + let settled = false; try { const [statusRes, downloadsRes] = await Promise.all([ openhumanLocalAiStatus(), @@ -58,26 +103,47 @@ const LocalAIDownloadSnackbar = () => { ]); if (statusRes.result) setStatus(statusRes.result); if (downloadsRes.result) setDownloads(downloadsRes.result); - } catch { - // Silently ignore — core may not be ready + // The download reached a terminal state — stop the fast poll early + // rather than waiting for core state to catch up (it lags up to ~5s). + settled = !isDownloadInFlight(statusRes.result ?? null, downloadsRes.result ?? null); + } catch (err) { + // Transient RPC failure (core may be busy). Do NOT treat this as + // settled: keep polling while core state still reports the download + // active, otherwise one blip would permanently stop progress updates + // for the rest of the download (the effect won't re-run until + // `coreDownloadActive` flips). + log('fast poll: transient error, will retry: %O', err); + } + if (!cancelled && !settled) { + timerRef.current = setTimeout(poll, ACTIVE_POLL_INTERVAL); + } else if (settled) { + log('fast poll: download settled, stopping'); } }; void poll(); - timerRef.current = setInterval(poll, POLL_INTERVAL); - return () => clearInterval(timerRef.current); - }, [tauriAvailable]); + return () => { + cancelled = true; + if (timerRef.current) clearTimeout(timerRef.current); + log('fast poll: stopped (unmount or core reports inactive)'); + }; + }, [tauriAvailable, coreDownloadActive]); const downloadState = downloads?.state; const currentState = downloadState === 'loading' || downloadState === 'downloading' || downloadState === 'installing' ? downloadState : (status?.state ?? downloadState ?? 'idle'); + // Gate on `coreDownloadActive` so a download that the core no longer reports + // active can't leave the snackbar stuck: when the folded state flips inactive, + // polling stops and any still-"downloading" local status/downloads must not + // keep the overlay visible. const isDownloading = - currentState === 'loading' || - currentState === 'downloading' || - currentState === 'installing' || - (downloads?.progress != null && downloads.progress > 0 && downloads.progress < 1); + coreDownloadActive && + (currentState === 'loading' || + currentState === 'downloading' || + currentState === 'installing' || + (downloads?.progress != null && downloads.progress > 0 && downloads.progress < 1)); // Render-phase update: when a new download cycle starts (not-downloading → downloading), // reset the dismiss/collapsed flags so the snackbar reappears automatically. diff --git a/app/src/components/__tests__/LocalAIDownloadSnackbar.test.tsx b/app/src/components/__tests__/LocalAIDownloadSnackbar.test.tsx index 8caf2bde80..1904cf565a 100644 --- a/app/src/components/__tests__/LocalAIDownloadSnackbar.test.tsx +++ b/app/src/components/__tests__/LocalAIDownloadSnackbar.test.tsx @@ -1,6 +1,7 @@ import { screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getCoreStateSnapshot, setCoreStateSnapshot } from '../../lib/coreState/store'; import { renderWithProviders } from '../../test/test-utils'; import LocalAIDownloadSnackbar from '../LocalAIDownloadSnackbar'; @@ -11,6 +12,29 @@ vi.mock('../../utils/tauriCommands', () => ({ openhumanLocalAiDownloadsProgress: vi.fn().mockResolvedValue({ result: null }), })); +/** + * Seed the folded `runtime.localAi.state` in core state. The snackbar reads this + * (instead of an idle inference poll) to decide whether to run its fast poll, so + * tests that expect it to poll must mark a download active here first. + */ +function seedLocalAiState(state: string | null): void { + const current = getCoreStateSnapshot(); + setCoreStateSnapshot({ + ...current, + snapshot: { + ...current.snapshot, + runtime: { + ...current.snapshot.runtime, + localAi: state === null ? null : ({ state } as never), + }, + }, + }); +} + +afterEach(() => { + seedLocalAiState(null); +}); + describe('LocalAIDownloadSnackbar', () => { it('does not render when not in Tauri environment', () => { renderWithProviders(); @@ -19,30 +43,26 @@ describe('LocalAIDownloadSnackbar', () => { expect(screen.queryByLabelText('Dismiss download notification')).not.toBeInTheDocument(); }); - it('does not render when no download is active', async () => { + it('does not poll or render when core state reports no active download', async () => { const tauriCommands = await import('../../utils/tauriCommands'); vi.mocked(tauriCommands.isTauri).mockReturnValue(true); - vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({ - result: { state: 'ready' } as never, - logs: [], - }); - vi.mocked(tauriCommands.openhumanLocalAiDownloadsProgress).mockResolvedValue({ - result: { state: 'idle', progress: null } as never, - logs: [], - }); + vi.mocked(tauriCommands.openhumanLocalAiStatus).mockClear(); + vi.mocked(tauriCommands.openhumanLocalAiDownloadsProgress).mockClear(); + seedLocalAiState('ready'); renderWithProviders(); - // Wait for poll cycle await vi.waitFor(() => { expect(screen.queryByText('Downloading')).not.toBeInTheDocument(); }); + // Idle → zero inference polls (the fold's whole point). + expect(tauriCommands.openhumanLocalAiStatus).not.toHaveBeenCalled(); + expect(tauriCommands.openhumanLocalAiDownloadsProgress).not.toHaveBeenCalled(); - // Reset mock vi.mocked(tauriCommands.isTauri).mockReturnValue(false); }); - it('renders immediately when status reports bootstrap activity before downloads progress catches up', async () => { + it('polls and renders when core state reports an active download', async () => { const tauriCommands = await import('../../utils/tauriCommands'); vi.mocked(tauriCommands.isTauri).mockReturnValue(true); vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({ @@ -59,6 +79,7 @@ describe('LocalAIDownloadSnackbar', () => { result: { state: 'idle', progress: null } as never, logs: [], }); + seedLocalAiState('loading'); renderWithProviders(); diff --git a/app/src/components/layout/shell/CollapsedNavRail.test.tsx b/app/src/components/layout/shell/CollapsedNavRail.test.tsx index 7b4984f1b8..b9d10b6120 100644 --- a/app/src/components/layout/shell/CollapsedNavRail.test.tsx +++ b/app/src/components/layout/shell/CollapsedNavRail.test.tsx @@ -29,7 +29,6 @@ describe('CollapsedNavRail', () => { 'nav.human', 'nav.brain', 'nav.flows', - 'nav.orchestration', 'nav.agentWorld', 'nav.connections', ]) { diff --git a/app/src/components/orchestration/AgentChatPanel.tsx b/app/src/components/orchestration/AgentChatPanel.tsx index 0d12a7c0e4..805c7ff116 100644 --- a/app/src/components/orchestration/AgentChatPanel.tsx +++ b/app/src/components/orchestration/AgentChatPanel.tsx @@ -367,8 +367,8 @@ function SessionChatView({ session }: { session: SessionSummary }) { export interface AgentChatPanelProps { /** * Controlled open peer-session id (the full-page session subpage). When - * `onOpenSession` is provided the parent owns this (OrchestrationPage drives - * it from the `?session=` query param + the sidebar's active sub-agents list); + * `onOpenSession` is provided the parent owns this (OrchestrationView drives + * it from the `?session=` query param + the active sub-agents rail); * otherwise the panel falls back to its own local state. */ openSessionId?: string | null; @@ -405,8 +405,8 @@ export default function AgentChatPanel({ const [composerBody, setComposerBody] = useState(''); const [sending, setSending] = useState(false); const [runningReview, setRunningReview] = useState(false); - // Controlled by the parent when `onOpenSession` is wired (OrchestrationPage - // drives it from the URL + sidebar session list); local state otherwise. + // Controlled by the parent when `onOpenSession` is wired (OrchestrationView + // drives it from the URL + active sub-agents rail); local state otherwise. const [localOpenSessionId, setLocalOpenSessionId] = useState(null); const openSessionId = controlledOpenSessionId !== undefined ? controlledOpenSessionId : localOpenSessionId; diff --git a/app/src/components/orchestration/ConnectionsPanel.tsx b/app/src/components/orchestration/ConnectionsPanel.tsx index 35c44e7c93..94ecae191c 100644 --- a/app/src/components/orchestration/ConnectionsPanel.tsx +++ b/app/src/components/orchestration/ConnectionsPanel.tsx @@ -8,7 +8,7 @@ * composer. Pending requests + a summary sit on top. Linking new agents still * lives in the sibling DiscoverPanel. * - * Renders inside the same single `max-w-3xl` column OrchestrationPage gives it. + * Renders inside the same single `max-w-3xl` column OrchestrationView gives it. */ import debugFactory from 'debug'; import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; diff --git a/app/src/components/orchestration/OrchestrationView.tsx b/app/src/components/orchestration/OrchestrationView.tsx new file mode 100644 index 0000000000..c527e743e0 --- /dev/null +++ b/app/src/components/orchestration/OrchestrationView.tsx @@ -0,0 +1,251 @@ +/** + * OrchestrationView — the TinyPlace multi-agent orchestration surface, embedded + * as the Brain page's **Orchestration** sub-tab (`/brain?tab=orchestration`). + * + * It was previously a first-class sidebar destination (`/orchestration`) with + * its own projected sidebar. Folding it back under Brain means the top-level + * views — **Overview** (Medulla), **Chat**, **Agent graph**, **Tasks**, + * **Network** — become an in-content chip row instead of a left rail, so Brain + * keeps a single sidebar. State travels in query params that don't collide with + * Brain's own `?tab=`: + * + * - `?ov=` — the orchestration view (medulla | agent | overview | tasks | network) + * - `?sub=` — the network sub-view (connections | discover | usage) + * - `?session=` — the open peer session (read by {@link AgentChatPanel}) + * + * The live list of active peer sessions (the old sidebar "Active sub-agents" + * rail) moves into the **Chat** view as a left column, since clicking a session + * opens it in that chat anyway. + */ +import { useCallback, useMemo } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { useMedullaAccess } from '../../lib/orchestration/useMedullaAccess'; +import { useContactSessions } from '../../lib/orchestration/useOrchestrationSessions'; +import ChipTabs from '../layout/ChipTabs'; +import PageSectionHeader from '../layout/PageSectionHeader'; +import PanelPage from '../layout/PanelPage'; +import ActiveSubagentsRail from './ActiveSubagentsRail'; +import AgentChatPanel from './AgentChatPanel'; +import ConnectionsPanel from './ConnectionsPanel'; +import MedullaDemoChat from './demo/MedullaDemoChat'; +import MedullaDemoGraph from './demo/MedullaDemoGraph'; +import MedullaDemoNetwork from './demo/MedullaDemoNetwork'; +import DiscoverPanel from './DiscoverPanel'; +import MedullaOverviewPanel from './MedullaOverviewPanel'; +import OrchestratorTaskBoard from './OrchestratorTaskBoard'; +import OverviewPanel from './OverviewPanel'; +import UsagePanel from './UsagePanel'; + +type OrchestrationTab = 'medulla' | 'overview' | 'agent' | 'tasks' | 'network'; +type NetworkSub = 'connections' | 'discover' | 'usage'; + +const ORCH_TABS: readonly OrchestrationTab[] = ['medulla', 'agent', 'overview', 'tasks', 'network']; +const NETWORK_SUBS: readonly NetworkSub[] = ['connections', 'discover', 'usage']; + +export default function OrchestrationView() { + const { t } = useT(); + const location = useLocation(); + const navigate = useNavigate(); + const contactSessions = useContactSessions(); + // Without Medulla access, Medulla-specific live surfaces are replaced by a + // scale showcase. The global task board remains available to every user. + const hasMedullaAccess = useMedullaAccess(); + + const params = useMemo(() => new URLSearchParams(location.search), [location.search]); + const rawOv = params.get('ov'); + const rawSub = params.get('sub'); + + // Default landing is the Medulla overview (the orchestration overview page). + const activeTab: OrchestrationTab = (ORCH_TABS as readonly string[]).includes(rawOv ?? '') + ? (rawOv as OrchestrationTab) + : 'medulla'; + + const networkSub: NetworkSub = NETWORK_SUBS.includes(rawSub as NetworkSub) + ? (rawSub as NetworkSub) + : 'connections'; + + const openSessionId = params.get('session'); + + const updateParams = useCallback( + (mut: (p: URLSearchParams) => void) => { + const next = new URLSearchParams(location.search); + mut(next); + navigate({ pathname: location.pathname, search: `?${next.toString()}` }); + }, + [location.pathname, location.search, navigate] + ); + + const setActiveTab = useCallback( + (tab: OrchestrationTab) => { + updateParams(p => { + // Preserve the hosting Brain sub-tab; only the orchestration view moves. + p.set('tab', 'orchestration'); + p.set('ov', tab); + // Selecting Chat returns to the master chat — drop any open session so + // the session subpage closes (there's no in-view back button). + if (tab === 'agent') p.delete('session'); + // Landing on the network page needs a valid sub selected. + if (tab === 'network' && !NETWORK_SUBS.includes(p.get('sub') as NetworkSub)) { + p.set('sub', 'connections'); + } + }); + }, + [updateParams] + ); + + const setNetworkSub = useCallback( + (sub: NetworkSub) => { + updateParams(p => { + p.set('tab', 'orchestration'); + p.set('ov', 'network'); + p.set('sub', sub); + }); + }, + [updateParams] + ); + + // Open (or close, when null) a peer session in the agent chat. Always lands on + // the agent tab so the session's transcript is actually visible. + const setOpenSessionId = useCallback( + (sessionId: string | null) => { + updateParams(p => { + p.set('tab', 'orchestration'); + p.set('ov', 'agent'); + if (sessionId) p.set('session', sessionId); + else p.delete('session'); + }); + }, + [updateParams] + ); + + console.debug( + '[orchestration] view mount ov=%s sub=%s session=%s medullaAccess=%s', + activeTab, + networkSub, + openSessionId, + hasMedullaAccess + ); + + const chipItems = useMemo( + () => [ + { id: 'medulla' as const, label: t('orchPage.medulla.nav') }, + { id: 'agent' as const, label: t('orchPage.agent.nav') }, + { id: 'overview' as const, label: t('orchPage.overview.nav') }, + { id: 'tasks' as const, label: t('orchPage.tasks.nav') }, + { id: 'network' as const, label: t('orchPage.group.network') }, + ], + [t] + ); + + return ( +
+ {/* Top-level view switcher — the old sidebar rail, folded into an in-content + chip row so Brain keeps a single sidebar. */} +
+ + as="tab" + ariaLabel={t('nav.orchestration')} + testIdPrefix="orch-view" + items={chipItems} + value={activeTab} + onChange={setActiveTab} + /> +
+ +
+ {activeTab === 'medulla' ? ( + // Orchestration overview — the Medulla teaser / early-access landing. + + ) : activeTab === 'overview' ? ( + // Interactive graph of the agent / sub-agent system — or the scale + // showcase (core → 2 devices → 120 agents) without Medulla access. + hasMedullaAccess ? ( + + ) : ( + + ) + ) : activeTab === 'agent' ? ( + // Full-bleed so it reads exactly like the normal chat page — or a + // read-only demo conversation (composer disabled) without Medulla + // access. The active peer-session list sits in a left column (moved + // from the former sidebar rail) when there are live sessions. + hasMedullaAccess ? ( +
+ {contactSessions.byContact.size > 0 ? ( + + ) : null} +
+ +
+
+ ) : ( + + ) + ) : activeTab === 'tasks' ? ( + // One global Kanban board owned by the orchestrator (not per-thread). + // Tasks predate Medulla access and must remain usable without it. +
+ +
+ + +
+
+
+ ) : hasMedullaAccess ? ( +
+ {/* Network: one page with a Brain-style chip sub-nav (flush pills, no + header background) over connections/discover/usage, aligned to the + same content column. */} + +
+ + as="tab" + ariaLabel={t('orchPage.group.network')} + testIdPrefix="orch-network" + className="inline-flex flex-wrap items-center gap-1.5" + items={[ + { id: 'connections', label: t('orchPage.connections.nav') }, + { id: 'discover', label: t('orchPage.discover.nav') }, + { id: 'usage', label: t('orchPage.usage.nav') }, + ]} + value={networkSub} + onChange={setNetworkSub} + /> + } + /> + {networkSub === 'connections' && ( + setNetworkSub('discover')} + onInitializeAgent={() => setActiveTab('agent')} + /> + )} + {networkSub === 'discover' && } + {networkSub === 'usage' && } +
+
+
+ ) : ( + // Scale showcase: fake peer-agent mesh with the preview banner. + + )} +
+
+ ); +} diff --git a/app/src/components/orchestration/__tests__/OrchestrationView.test.tsx b/app/src/components/orchestration/__tests__/OrchestrationView.test.tsx new file mode 100644 index 0000000000..25c5fd562b --- /dev/null +++ b/app/src/components/orchestration/__tests__/OrchestrationView.test.tsx @@ -0,0 +1,144 @@ +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../../../test/test-utils'; +import OrchestrationView from '../OrchestrationView'; + +vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); + +// Medulla access is toggled per-test; default to granted so the view-routing +// tests exercise the live panels. +let medullaAccess = true; +vi.mock('../../../lib/orchestration/useMedullaAccess', () => ({ + useMedullaAccess: () => medullaAccess, +})); + +// No live peer sessions by default, so the agent view's left rail stays hidden. +vi.mock('../../../lib/orchestration/useOrchestrationSessions', () => ({ + useContactSessions: () => ({ byContact: new Map() }), +})); + +// Stub the data-backed panels so the view's tab routing is tested in isolation +// (the panels have their own unit tests). +vi.mock('../MedullaOverviewPanel', () => ({ default: () =>
})); +vi.mock('../AgentChatPanel', () => ({ default: () =>
})); +vi.mock('../ConnectionsPanel', () => ({ + default: ({ onDiscover }: { onDiscover?: () => void }) => ( + + ), +})); +vi.mock('../DiscoverPanel', () => ({ default: () =>
})); +vi.mock('../UsagePanel', () => ({ default: () =>
})); +vi.mock('../OverviewPanel', () => ({ default: () =>
})); +vi.mock('../OrchestratorTaskBoard', () => ({ default: () =>
})); +vi.mock('../demo/MedullaDemoChat', () => ({ default: () =>
})); +vi.mock('../demo/MedullaDemoGraph', () => ({ + default: () =>
, +})); +vi.mock('../demo/MedullaDemoNetwork', () => ({ + default: () =>
, +})); + +const BASE = '/brain?tab=orchestration'; + +describe('OrchestrationView (Medulla access)', () => { + beforeEach(() => { + medullaAccess = true; + }); + + it('defaults to the Medulla overview panel', async () => { + await act(async () => { + renderWithProviders(, { initialEntries: [BASE] }); + }); + expect(screen.getByTestId('panel-medulla')).toBeInTheDocument(); + }); + + it('renders the agent chat panel from ?ov=agent', async () => { + await act(async () => { + renderWithProviders(, { initialEntries: [`${BASE}&ov=agent`] }); + }); + expect(screen.getByTestId('panel-agent')).toBeInTheDocument(); + }); + + it('renders the agent graph panel from ?ov=overview', async () => { + await act(async () => { + renderWithProviders(, { initialEntries: [`${BASE}&ov=overview`] }); + }); + expect(screen.getByTestId('panel-graph')).toBeInTheDocument(); + }); + + it('renders the task board from ?ov=tasks', async () => { + await act(async () => { + renderWithProviders(, { initialEntries: [`${BASE}&ov=tasks`] }); + }); + expect(screen.getByTestId('panel-tasks')).toBeInTheDocument(); + }); + + it.each([ + ['connections', 'panel-connections'], + ['discover', 'panel-discover'], + ['usage', 'panel-usage'], + ])('renders the %s network sub from ?ov=network&sub=%s', async (sub, testId) => { + await act(async () => { + renderWithProviders(, { + initialEntries: [`${BASE}&ov=network&sub=${sub}`], + }); + }); + expect(screen.getByTestId(testId)).toBeInTheDocument(); + }); + + it('switches views via the chip nav', async () => { + await act(async () => { + renderWithProviders(, { initialEntries: [BASE] }); + }); + await act(async () => { + fireEvent.click(screen.getByTestId('orch-view-network')); + }); + await waitFor(() => expect(screen.getByTestId('panel-connections')).toBeInTheDocument()); + }); + + it('lets the connections panel jump to discover via its callback', async () => { + await act(async () => { + renderWithProviders(, { + initialEntries: [`${BASE}&ov=network&sub=connections`], + }); + }); + await act(async () => { + fireEvent.click(screen.getByTestId('panel-connections')); + }); + await waitFor(() => expect(screen.getByTestId('panel-discover')).toBeInTheDocument()); + }); +}); + +describe('OrchestrationView scale showcase (no Medulla access)', () => { + beforeEach(() => { + medullaAccess = false; + }); + + it.each([ + ['agent', 'orch-demo-chat'], + ['overview', 'orch-demo-graph'], + ['network', 'orch-demo-network'], + ])('renders the demo surface for ?ov=%s', async (ov, testId) => { + await act(async () => { + renderWithProviders(, { initialEntries: [`${BASE}&ov=${ov}`] }); + }); + expect(screen.getByTestId(testId)).toBeInTheDocument(); + }); + + it('keeps the real task board available without Medulla access', async () => { + await act(async () => { + renderWithProviders(, { initialEntries: [`${BASE}&ov=tasks`] }); + }); + expect(screen.getByTestId('panel-tasks')).toBeInTheDocument(); + }); + + it('still lands on the Medulla overview by default', async () => { + await act(async () => { + renderWithProviders(, { initialEntries: [BASE] }); + }); + expect(screen.getByTestId('panel-medulla')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/settings/settingsRouteElements.tsx b/app/src/components/settings/settingsRouteElements.tsx index 69d3be1bb7..e18300e14f 100644 --- a/app/src/components/settings/settingsRouteElements.tsx +++ b/app/src/components/settings/settingsRouteElements.tsx @@ -179,8 +179,8 @@ export function settingsRouteElements(): ReactNode { element={} /> )} /> - {/* Tasks now live on the Orchestration page's Kanban board. */} - } /> + {/* Tasks now live on Brain's Orchestration Kanban board. */} + } /> {/* Workflows is a first-level module now — /settings/automations bounces to /flows (the Workflows page). */} } /> diff --git a/app/src/config/__tests__/navConfig.test.ts b/app/src/config/__tests__/navConfig.test.ts index aded4ceef9..862870f4b7 100644 --- a/app/src/config/__tests__/navConfig.test.ts +++ b/app/src/config/__tests__/navConfig.test.ts @@ -3,8 +3,8 @@ import { describe, expect, it } from 'vitest'; import { AVATAR_MENU_ITEMS, NAV_TABS } from '../navConfig'; describe('NAV_TABS', () => { - it('has exactly 7 entries', () => { - expect(NAV_TABS).toHaveLength(7); + it('has exactly 6 entries', () => { + expect(NAV_TABS).toHaveLength(6); }); it('has the correct ids in order', () => { @@ -13,7 +13,6 @@ describe('NAV_TABS', () => { 'human', 'brain', 'flows', - 'orchestration', 'agent-world', 'connections', ]); @@ -25,7 +24,6 @@ describe('NAV_TABS', () => { '/human', '/brain', '/flows', - '/orchestration', '/agent-world', '/connections', ]); @@ -37,7 +35,6 @@ describe('NAV_TABS', () => { 'nav.human', 'nav.brain', 'nav.flows', - 'nav.orchestration', 'nav.agentWorld', 'nav.connections', ]); @@ -49,12 +46,15 @@ describe('NAV_TABS', () => { 'tab-human', 'tab-brain', 'tab-flows', - 'tab-orchestration', 'tab-agent-world', 'tab-connections', ]); }); + it('no longer contains a top-level orchestration tab (folded under Brain)', () => { + expect(NAV_TABS.find(t => t.id === 'orchestration')).toBeUndefined(); + }); + it('no longer contains home or settings tabs (moved to the sidebar header)', () => { expect(NAV_TABS.find(t => t.id === 'home')).toBeUndefined(); expect(NAV_TABS.find(t => t.id === 'settings')).toBeUndefined(); diff --git a/app/src/config/navConfig.ts b/app/src/config/navConfig.ts index 97a2c4fb76..d5b5915d1d 100644 --- a/app/src/config/navConfig.ts +++ b/app/src/config/navConfig.ts @@ -22,11 +22,11 @@ export interface NavTab { /** * Ordered list of sidebar nav entries: - * chat → human → brain → flows → orchestration → agent-world → connections + * chat → human → brain → flows → agent-world → connections * - * The Orchestration tab (TinyPlace multi-agent coordination) sits right after - * Workflows; it was promoted out of the Brain sub-tab drawer into a first-class - * destination at `/orchestration`. + * Orchestration (TinyPlace multi-agent coordination) is no longer a top-level + * tab — it was folded back under Brain as the `/brain?tab=orchestration` + * sub-tab, so the sidebar stays lean. * * Settings has no primary tab — it's reached via the gear icon in the sidebar * header. Chat is the default landing and the merged Home surface: its empty @@ -42,12 +42,6 @@ export const NAV_TABS: NavTab[] = [ { id: 'human', labelKey: 'nav.human', path: '/human', walkthroughAttr: 'tab-human' }, { id: 'brain', labelKey: 'nav.brain', path: '/brain', walkthroughAttr: 'tab-brain' }, { id: 'flows', labelKey: 'nav.flows', path: '/flows', walkthroughAttr: 'tab-flows' }, - { - id: 'orchestration', - labelKey: 'nav.orchestration', - path: '/orchestration', - walkthroughAttr: 'tab-orchestration', - }, { id: 'agent-world', labelKey: 'nav.agentWorld', diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index 243f234dc9..b5a74e1dd3 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -37,6 +37,8 @@ import { CitationChips, type MessageCitation, } from '../../features/conversations/components/CitationChips'; +import { InterruptedAnswer } from '../../features/conversations/components/InterruptedAnswer'; +import { PastTurnInsights } from '../../features/conversations/components/PastTurnInsights'; import { PlanReviewCard } from '../../features/conversations/components/PlanReviewCard'; import { SubagentDrawer } from '../../features/conversations/components/SubagentDrawer'; import { @@ -46,8 +48,6 @@ import { } from '../../features/conversations/components/ThreadGoalChip'; import { ThreadTodoStrip } from '../../features/conversations/components/ThreadTodoStrip'; import { ToolTimelineBlock } from '../../features/conversations/components/ToolTimelineBlock'; -import { InterruptedAnswer } from '../../features/conversations/components/InterruptedAnswer'; -import { PastTurnInsights } from '../../features/conversations/components/PastTurnInsights'; import { evaluateComposerSend, getComposerBlockedSendFeedback, diff --git a/app/src/features/conversations/components/InterruptedAnswer.tsx b/app/src/features/conversations/components/InterruptedAnswer.tsx index 5989bf81b2..bbcbce56d0 100644 --- a/app/src/features/conversations/components/InterruptedAnswer.tsx +++ b/app/src/features/conversations/components/InterruptedAnswer.tsx @@ -13,13 +13,7 @@ import { BubbleMarkdown } from './AgentMessageBubble'; * list — it is a restore-time surfacing of what the agent had produced, so the * user sees the partial work instead of a blank turn. */ -export function InterruptedAnswer({ - content, - thinking, -}: { - content: string; - thinking: string; -}) { +export function InterruptedAnswer({ content, thinking }: { content: string; thinking: string }) { const { t } = useT(); const trimmedContent = content.trim(); const trimmedThinking = thinking.trim(); diff --git a/app/src/hooks/useDaemonHealth.ts b/app/src/hooks/useDaemonHealth.ts index 1b015dcf55..eb2804a109 100644 --- a/app/src/hooks/useDaemonHealth.ts +++ b/app/src/hooks/useDaemonHealth.ts @@ -13,7 +13,6 @@ import { setIsRecovering, useDaemonUserState, } from '../features/daemon/store'; -import { daemonHealthService } from '../services/daemonHealthService'; import { type CommandResponse, openhumanAgentServerStatus, @@ -165,23 +164,9 @@ export const useDaemonHealth = (userId?: string) => { void probeAgentStatus(); }, [probeAgentStatus]); - useEffect(() => { - let cleanup: (() => void) | null = null; - let cancelled = false; - - void daemonHealthService.setupHealthListener().then(result => { - if (cancelled) { - result?.(); - } else { - cleanup = result; - } - }); - - return () => { - cancelled = true; - cleanup?.(); - }; - }, []); + // Health is no longer polled here — CoreStateProvider feeds each + // app_state_snapshot's health payload to daemonHealthService.ingestHealthSnapshot. + // This hook only reads the resulting daemon store state. return { // State diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 86ccd740d5..58cefa5073 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -552,7 +552,7 @@ const messages: TranslationMap = { 'brain.goals.actionError': 'حدث خطأ ما. يرجى المحاولة مرة أخرى.', 'brain.tabs.sources': 'المصادر', 'brain.tabs.sync': 'المزامنة', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': 'التنسيق', 'tinyplaceOrchestration.title': 'مرحّل TinyPlace', 'tinyplaceOrchestration.subtitle': 'قنوات الوكلاء المثبتة ودردشات جلسات التطبيق', 'tinyplaceOrchestration.refresh': 'تحديث', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index d67bba8a75..330052021d 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -570,7 +570,7 @@ const messages: TranslationMap = { 'brain.goals.actionError': 'কিছু ভুল হয়েছে। অনুগ্রহ করে আবার চেষ্টা করুন।', 'brain.tabs.sources': 'উৎস', 'brain.tabs.sync': 'সিঙ্ক', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': 'অর্কেস্ট্রেশন', 'tinyplaceOrchestration.title': 'TinyPlace রিলে', 'tinyplaceOrchestration.subtitle': 'পিন করা এজেন্ট চ্যানেল এবং অ্যাপ সেশন চ্যাট', 'tinyplaceOrchestration.refresh': 'রিফ্রেশ', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 0cf85fef8a..5b83faec0a 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -598,7 +598,7 @@ const messages: TranslationMap = { 'brain.goals.actionError': 'Etwas ist schiefgelaufen. Bitte versuche es erneut.', 'brain.tabs.sources': 'Quellen', 'brain.tabs.sync': 'Synchronisierung', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': 'Orchestrierung', 'tinyplaceOrchestration.title': 'TinyPlace-Relay', 'tinyplaceOrchestration.subtitle': 'Angepinnte Agentenkanäle und App-Sitzungs-Chats', 'tinyplaceOrchestration.refresh': 'Aktualisieren', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 54342cc03d..3368ea52ca 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -321,7 +321,7 @@ const en: TranslationMap = { 'brain.tabs.goals': 'Goals', 'brain.tabs.sources': 'Sources', 'brain.tabs.sync': 'Sync', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': 'Orchestration', 'brain.empty': 'Your brain is empty for now: connect a source to start building memory.', 'brain.error': "Couldn't load your brain. Please try again.", 'brain.goals.title': 'Long-term Goals', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index d9773b9e72..870fc00a09 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -581,7 +581,7 @@ const messages: TranslationMap = { 'brain.goals.actionError': 'Algo salió mal. Inténtalo de nuevo.', 'brain.tabs.sources': 'Fuentes', 'brain.tabs.sync': 'Sincronización', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': 'Orquestación', 'tinyplaceOrchestration.title': 'Relay de TinyPlace', 'tinyplaceOrchestration.subtitle': 'Canales de agentes fijados y chats de sesiones de app', 'tinyplaceOrchestration.refresh': 'Actualizar', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 3ad118d95e..2a4b49ee03 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -590,7 +590,7 @@ const messages: TranslationMap = { 'brain.goals.actionError': 'Une erreur s’est produite. Veuillez réessayer.', 'brain.tabs.sources': 'Sources', 'brain.tabs.sync': 'Synchronisation', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': 'Orchestration', 'tinyplaceOrchestration.title': 'Relais TinyPlace', 'tinyplaceOrchestration.subtitle': "Canaux d'agents épinglés et chats de sessions app", 'tinyplaceOrchestration.refresh': 'Actualiser', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 12facfb7a7..be21c6c877 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -570,7 +570,7 @@ const messages: TranslationMap = { 'brain.goals.actionError': 'कुछ गलत हो गया। कृपया पुनः प्रयास करें।', 'brain.tabs.sources': 'स्रोत', 'brain.tabs.sync': 'सिंक', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': 'ऑर्केस्ट्रेशन', 'tinyplaceOrchestration.title': 'TinyPlace रिले', 'tinyplaceOrchestration.subtitle': 'पिन किए गए एजेंट चैनल और ऐप-सत्र चैट', 'tinyplaceOrchestration.refresh': 'रीफ़्रेश', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index a2a7ce85b7..1a4a837230 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -576,7 +576,7 @@ const messages: TranslationMap = { 'brain.goals.actionError': 'Terjadi kesalahan. Silakan coba lagi.', 'brain.tabs.sources': 'Sumber', 'brain.tabs.sync': 'Sinkronisasi', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': 'Orkestrasi', 'tinyplaceOrchestration.title': 'Relay TinyPlace', 'tinyplaceOrchestration.subtitle': 'Kanal agen tersemat dan chat sesi aplikasi', 'tinyplaceOrchestration.refresh': 'Segarkan', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index e92f9c2bed..d0e6c1bdf5 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -584,7 +584,7 @@ const messages: TranslationMap = { 'brain.goals.actionError': 'Qualcosa è andato storto. Riprova.', 'brain.tabs.sources': 'Fonti', 'brain.tabs.sync': 'Sincronizzazione', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': 'Orchestrazione', 'tinyplaceOrchestration.title': 'Relay TinyPlace', 'tinyplaceOrchestration.subtitle': 'Canali agente fissati e chat delle sessioni app', 'tinyplaceOrchestration.refresh': 'Aggiorna', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 6485cc380a..315bf4b4d4 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -563,7 +563,7 @@ const messages: TranslationMap = { 'brain.goals.actionError': '문제가 발생했습니다. 다시 시도해 주세요.', 'brain.tabs.sources': '소스', 'brain.tabs.sync': '동기화', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': '오케스트레이션', 'tinyplaceOrchestration.title': 'TinyPlace 릴레이', 'tinyplaceOrchestration.subtitle': '고정된 에이전트 채널과 앱 세션 채팅', 'tinyplaceOrchestration.refresh': '새로 고침', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index df2555176b..51838b6e92 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -583,7 +583,7 @@ const messages: TranslationMap = { 'brain.goals.actionError': 'Coś poszło nie tak. Spróbuj ponownie.', 'brain.tabs.sources': 'Źródła', 'brain.tabs.sync': 'Synchronizacja', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': 'Orkiestracja', 'tinyplaceOrchestration.title': 'Przekaźnik TinyPlace', 'tinyplaceOrchestration.subtitle': 'Przypięte kanały agentów i czaty sesji aplikacji', 'tinyplaceOrchestration.refresh': 'Odśwież', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 16707e454d..4faab0c694 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -576,7 +576,7 @@ const messages: TranslationMap = { 'brain.goals.actionError': 'Algo deu errado. Tente novamente.', 'brain.tabs.sources': 'Fontes', 'brain.tabs.sync': 'Sincronização', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': 'Orquestração', 'tinyplaceOrchestration.title': 'Relay TinyPlace', 'tinyplaceOrchestration.subtitle': 'Canais de agentes fixados e chats de sessões do app', 'tinyplaceOrchestration.refresh': 'Atualizar', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 762abc76e0..5aa939b2c9 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -576,7 +576,7 @@ const messages: TranslationMap = { 'brain.goals.actionError': 'Что-то пошло не так. Пожалуйста, попробуйте снова.', 'brain.tabs.sources': 'Источники', 'brain.tabs.sync': 'Синхронизация', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': 'Оркестрация', 'tinyplaceOrchestration.title': 'Ретранслятор TinyPlace', 'tinyplaceOrchestration.subtitle': 'Закрепленные каналы агентов и чаты сессий приложения', 'tinyplaceOrchestration.refresh': 'Обновить', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 344bd1a704..e86dfac2a7 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -536,7 +536,7 @@ const messages: TranslationMap = { 'brain.goals.actionError': '出了点问题。请重试。', 'brain.tabs.sources': '来源', 'brain.tabs.sync': '同步', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': '编排', 'tinyplaceOrchestration.title': 'TinyPlace 中继', 'tinyplaceOrchestration.subtitle': '固定的代理频道和应用会话聊天', 'tinyplaceOrchestration.refresh': '刷新', diff --git a/app/src/pages/Brain.tsx b/app/src/pages/Brain.tsx index 291493ad5b..344f30f158 100644 --- a/app/src/pages/Brain.tsx +++ b/app/src/pages/Brain.tsx @@ -1,9 +1,9 @@ /** * Brain — the centerpiece memory + subconscious surface. * - * Two sub-tabs: - * - **Memory**: knowledge graph, tree status, and connected sources. - * - **Subconscious**: background thinking engine controls. + * Sub-tabs: Welcome, Graph, Goals, Sources, Sync, Subconscious, and + * **Orchestration** (the TinyPlace multi-agent surface, folded back in from the + * former top-level `/orchestration` tab — see {@link OrchestrationView}). */ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; @@ -23,6 +23,7 @@ import PageWelcome from '../components/layout/PageWelcome'; import PanelPage from '../components/layout/PanelPage'; import { SidebarContent } from '../components/layout/shell/SidebarSlot'; import TwoPaneNav from '../components/layout/TwoPaneNav'; +import OrchestrationView from '../components/orchestration/OrchestrationView'; import BetaBanner from '../components/ui/BetaBanner'; import { useSubconscious } from '../hooks/useSubconscious'; import { useT } from '../lib/i18n/I18nContext'; @@ -34,7 +35,14 @@ import { memoryTreeGraphExport, } from '../utils/tauriCommands'; -type BrainTab = 'welcome' | 'graph' | 'goals' | 'sources' | 'sync' | 'subconscious'; +type BrainTab = + | 'welcome' + | 'graph' + | 'goals' + | 'sources' + | 'sync' + | 'subconscious' + | 'orchestration'; /** Small inline icon helper for the Brain sidebar nav. */ const navIcon = (d: string) => ( @@ -50,10 +58,18 @@ const BRAIN_TABS: readonly BrainTab[] = [ 'sources', 'sync', 'subconscious', + 'orchestration', ]; -/** Canonical text header (title + one-line description) per functional tab. */ -const BRAIN_HEADERS: Record, { titleKey: string; descKey: string }> = { +/** + * Canonical text header (title + one-line description) per functional tab. + * Orchestration is excluded — it renders its own full-bleed surface + * ({@link OrchestrationView}) with its own chip nav, not the shared scaffold. + */ +const BRAIN_HEADERS: Record< + Exclude, + { titleKey: string; descKey: string } +> = { graph: { titleKey: 'brain.tabs.graph', descKey: 'brain.header.graph' }, goals: { titleKey: 'brain.tabs.goals', descKey: 'brain.header.goals' }, sources: { titleKey: 'brain.tabs.sources', descKey: 'brain.header.sources' }, @@ -79,12 +95,13 @@ export default function Brain() { }, [location.pathname, location.search, navigate] ); - // Back-compat: TinyPlace Orchestration was promoted out of Brain into the - // top-level `/orchestration` tab. Bounce the old deep link there. + // Back-compat: the old `?tab=tinyplace-orchestration` slug (from when + // Orchestration was briefly a top-level tab) now maps to the folded-in + // Orchestration sub-tab. useEffect(() => { if (new URLSearchParams(location.search).get('tab') === 'tinyplace-orchestration') { - console.debug('[brain] legacy tinyplace-orchestration deep link → /orchestration'); - navigate('/orchestration', { replace: true }); + console.debug('[brain] legacy tinyplace-orchestration deep link → ?tab=orchestration'); + navigate('/brain?tab=orchestration', { replace: true }); } }, [location.search, navigate]); @@ -201,144 +218,168 @@ export default function Brain() { 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z' ), }, + { + // TinyPlace multi-agent orchestration, folded back under + // Brain from the former top-level `/orchestration` tab. + value: 'orchestration', + label: t('brain.tabs.orchestration'), + icon: navIcon( + 'M12 7v3m0 0l-5.5 6M12 10l5.5 6M12 5a2 2 0 100 0M5 19a2 2 0 100 0M19 19a2 2 0 100 0' + ), + }, ], }, ]} />
-
- {activeTab === 'welcome' ? ( - setActiveTab('graph'), - testId: 'brain-welcome-cta-graph', - }, - { - label: t('brain.welcome.ctaGoals'), - icon: '🎯', - onClick: () => setActiveTab('goals'), - }, - { - label: t('brain.welcome.ctaSources'), - icon: '🔗', - onClick: () => setActiveTab('sources'), - }, - ]} - featuresHeading={t('brain.welcome.featsLabel')} - features={[ - { - icon: '🕸️', - title: t('brain.welcome.feat1Title'), - description: t('brain.welcome.feat1Body'), - }, - { - icon: '🎯', - title: t('brain.welcome.feat2Title'), - description: t('brain.welcome.feat2Body'), - }, - { - icon: '🔄', - title: t('brain.welcome.feat3Title'), - description: t('brain.welcome.feat3Body'), - }, - ]} - /> - ) : ( - /* All tabs share the standard scaffold: a single scrolling body, + {activeTab === 'orchestration' ? ( + // Full-bleed: OrchestrationView renders its own chip nav + surfaces + // (chat, graph, task board), which need the full content width — so it + // sits outside the shared max-w scaffold the other tabs use. +
+ +
+ ) : ( +
+ {activeTab === 'welcome' ? ( + setActiveTab('graph'), + testId: 'brain-welcome-cta-graph', + }, + { + label: t('brain.welcome.ctaGoals'), + icon: '🎯', + onClick: () => setActiveTab('goals'), + }, + { + label: t('brain.welcome.ctaSources'), + icon: '🔗', + onClick: () => setActiveTab('sources'), + }, + ]} + featuresHeading={t('brain.welcome.featsLabel')} + features={[ + { + icon: '🕸️', + title: t('brain.welcome.feat1Title'), + description: t('brain.welcome.feat1Body'), + }, + { + icon: '🎯', + title: t('brain.welcome.feat2Title'), + description: t('brain.welcome.feat2Body'), + }, + { + icon: '🔄', + title: t('brain.welcome.feat3Title'), + description: t('brain.welcome.feat3Body'), + }, + ]} + /> + ) : ( + /* All tabs share the standard scaffold: a single scrolling body, all custom controls live inside it. Each tab opens with the canonical header card (title + one-line description), aligned to the content. */ - -
- ].titleKey)} - description={t(BRAIN_HEADERS[activeTab as Exclude].descKey)} - /> - {activeTab === 'graph' && ( -
- - - {graph ? ( - +
+ ] + .titleKey + )} + description={t( + BRAIN_HEADERS[activeTab as Exclude] + .descKey + )} + /> + {activeTab === 'graph' && ( +
+ - ) : error ? ( -
- {t('brain.error')} -
- ) : null} -
- )} - {activeTab === 'goals' && } + {graph ? ( + + ) : error ? ( +
+ {t('brain.error')} +
+ ) : null} +
+ )} - {activeTab === 'sources' && ( -
- - -
- )} + {activeTab === 'goals' && } - {activeTab === 'sync' && ( -
-
- + {activeTab === 'sources' && ( +
+ +
- {/* Sync history relocated from the Memory Inspection panel so + )} + + {activeTab === 'sync' && ( +
+
+ +
+ {/* Sync history relocated from the Memory Inspection panel so the Sync tab is the single sync surface. */} -
-

- {t('sync.auditTitle', 'Sync History')} -

- +
+

+ {t('sync.auditTitle', 'Sync History')} +

+ +
-
- )} + )} - {activeTab === 'subconscious' && ( -
- -
- + {activeTab === 'subconscious' && ( +
+ +
+ +
+
- -
- )} -
- - )} -
+ )} +
+ + )} +
+ )}
diff --git a/app/src/pages/OrchestrationPage.tsx b/app/src/pages/OrchestrationPage.tsx deleted file mode 100644 index 30650257f8..0000000000 --- a/app/src/pages/OrchestrationPage.tsx +++ /dev/null @@ -1,289 +0,0 @@ -/** - * OrchestrationPage — the TinyPlace multi-agent orchestration surface. - * - * Promoted out of Brain into a first-class sidebar destination (`/orchestration`), - * it splits into two sidebar destinations projected into the shell's dynamic - * sidebar region, driven by `?tab=`: - * - * - **agent** — chat with the main agent + its subconscious steering loop - * - **network** — one page with a chip sub-nav (`?sub=`) over the peer-network - * views: **connections**, **discover**, **usage** - * - * The sidebar lists the two destinations flat (no category headers), then a - * separator, then a live list of the agent's active peer sessions — mirroring - * how the chat window lists active threads. Clicking a session opens it in the - * agent chat (via the `session` query param, read by {@link AgentChatPanel}). - */ -import { useCallback, useMemo } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; - -import ChipTabs from '../components/layout/ChipTabs'; -import PageSectionHeader from '../components/layout/PageSectionHeader'; -import PanelPage from '../components/layout/PanelPage'; -import { SidebarContent } from '../components/layout/shell/SidebarSlot'; -import TwoPaneNav from '../components/layout/TwoPaneNav'; -import ActiveSubagentsRail from '../components/orchestration/ActiveSubagentsRail'; -import AgentChatPanel from '../components/orchestration/AgentChatPanel'; -import ConnectionsPanel from '../components/orchestration/ConnectionsPanel'; -import MedullaDemoChat from '../components/orchestration/demo/MedullaDemoChat'; -import MedullaDemoGraph from '../components/orchestration/demo/MedullaDemoGraph'; -import MedullaDemoNetwork from '../components/orchestration/demo/MedullaDemoNetwork'; -import DiscoverPanel from '../components/orchestration/DiscoverPanel'; -import MedullaOverviewPanel from '../components/orchestration/MedullaOverviewPanel'; -import OrchestratorTaskBoard from '../components/orchestration/OrchestratorTaskBoard'; -import OverviewPanel from '../components/orchestration/OverviewPanel'; -import UsagePanel from '../components/orchestration/UsagePanel'; -import { useT } from '../lib/i18n/I18nContext'; -import { useMedullaAccess } from '../lib/orchestration/useMedullaAccess'; -import { useContactSessions } from '../lib/orchestration/useOrchestrationSessions'; - -type OrchestrationTab = 'medulla' | 'overview' | 'agent' | 'tasks' | 'network'; -type NetworkSub = 'connections' | 'discover' | 'usage'; - -const NETWORK_SUBS: readonly NetworkSub[] = ['connections', 'discover', 'usage']; - -/** Small inline icon helper for the Orchestration sub-nav (matches Brain). */ -const navIcon = (d: string) => ( - - - -); - -export default function OrchestrationPage() { - const { t } = useT(); - const location = useLocation(); - const navigate = useNavigate(); - const contactSessions = useContactSessions(); - // Without Medulla access, Medulla-specific live surfaces are replaced by a - // scale showcase. The global task board remains available to every user. - const hasMedullaAccess = useMedullaAccess(); - - const params = useMemo(() => new URLSearchParams(location.search), [location.search]); - const rawTab = params.get('tab'); - const rawSub = params.get('sub'); - - // `?tab=connections|discover|usage` is a legacy deep link → the network page - // with that sub selected. New links use `?tab=network&sub=…`. - // Default landing is the Medulla overview (the orchestration overview page). - const activeTab: OrchestrationTab = - rawTab === 'overview' - ? 'overview' - : rawTab === 'agent' - ? 'agent' - : rawTab === 'tasks' - ? 'tasks' - : rawTab === 'network' || NETWORK_SUBS.includes(rawTab as NetworkSub) - ? 'network' - : 'medulla'; - - const networkSub: NetworkSub = NETWORK_SUBS.includes(rawTab as NetworkSub) - ? (rawTab as NetworkSub) - : NETWORK_SUBS.includes(rawSub as NetworkSub) - ? (rawSub as NetworkSub) - : 'connections'; - - const openSessionId = params.get('session'); - - const updateParams = useCallback( - (mut: (p: URLSearchParams) => void) => { - const next = new URLSearchParams(location.search); - mut(next); - navigate({ pathname: location.pathname, search: `?${next.toString()}` }); - }, - [location.pathname, location.search, navigate] - ); - - const setActiveTab = useCallback( - (tab: OrchestrationTab) => { - updateParams(p => { - p.set('tab', tab); - // Selecting Chat returns to the master chat — drop any open session so - // the session subpage closes (there's no in-view back button). - if (tab === 'agent') p.delete('session'); - // Landing on the network page needs a valid sub selected. - if (tab === 'network' && !NETWORK_SUBS.includes(p.get('sub') as NetworkSub)) { - p.set('sub', 'connections'); - } - }); - }, - [updateParams] - ); - - const setNetworkSub = useCallback( - (sub: NetworkSub) => { - updateParams(p => { - p.set('tab', 'network'); - p.set('sub', sub); - }); - }, - [updateParams] - ); - - // Open (or close, when null) a peer session in the agent chat. Always lands on - // the agent tab so the session's transcript is actually visible. - const setOpenSessionId = useCallback( - (sessionId: string | null) => { - updateParams(p => { - p.set('tab', 'agent'); - if (sessionId) p.set('session', sessionId); - else p.delete('session'); - }); - }, - [updateParams] - ); - - console.debug( - '[orchestration] page mount tab=%s sub=%s session=%s medullaAccess=%s', - activeTab, - networkSub, - openSessionId, - hasMedullaAccess - ); - - return ( -
- -
- setActiveTab(value as OrchestrationTab)} - groups={[ - { - // Flat list — no category headers: Overview · Chat · Agent - // graph · Tasks · Network. Overview (Medulla) is the landing. - items: [ - { - value: 'medulla', - label: t('orchPage.medulla.nav'), - icon: navIcon( - 'M12 3v2m0 14v2m9-9h-2M5 12H3m14.657-6.657l-1.414 1.414M7.757 16.243l-1.414 1.414m0-11.314l1.414 1.414m8.486 8.486l1.414 1.414M15 12a3 3 0 11-6 0 3 3 0 016 0z' - ), - }, - { - value: 'agent', - label: t('orchPage.agent.nav'), - icon: navIcon( - 'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z' - ), - }, - { - value: 'overview', - label: t('orchPage.overview.nav'), - icon: navIcon( - 'M4 5a2 2 0 012-2h12a2 2 0 012 2M9 12a2 2 0 11-4 0 2 2 0 014 0zm10 4a2 2 0 11-4 0 2 2 0 014 0zM7 12l7 4' - ), - }, - { - value: 'tasks', - label: t('orchPage.tasks.nav'), - icon: navIcon( - 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4' - ), - }, - { - value: 'network', - label: t('orchPage.group.network'), - icon: navIcon('M13 10V3L4 14h7v7l9-11h-7z M17 8a3 3 0 100-6 3 3 0 000 6z'), - }, - ], - }, - ]} - footer={ - // Active sub-agents, grouped by instance (contact) with a - // connection-status dot. Clicking a sub-agent opens its chat. - // Driven by live peer sessions, so it's hidden in showcase mode - // (no live sub-agents without Medulla access). - hasMedullaAccess ? ( - - ) : undefined - } - /> -
-
- - {activeTab === 'medulla' ? ( - // Orchestration overview — the Medulla teaser / early-access landing. - - ) : activeTab === 'overview' ? ( - // Interactive graph of the agent / sub-agent system — or the scale - // showcase (core → 2 devices → 120 agents) without Medulla access. - hasMedullaAccess ? ( - - ) : ( - - ) - ) : activeTab === 'agent' ? ( - // Full-bleed so it reads exactly like the normal chat page (dark - // background, floating composer, one vertical scroll) — or a read-only - // demo conversation (composer disabled) without Medulla access. - hasMedullaAccess ? ( -
- -
- ) : ( - - ) - ) : activeTab === 'tasks' ? ( - // One global Kanban board owned by the orchestrator (not per-thread). - // Tasks predate Medulla access and must remain usable without it. -
- -
- - -
-
-
- ) : hasMedullaAccess ? ( -
- {/* Network: one page with a Brain-style chip sub-nav (flush pills, no - header background) over connections/discover/usage, aligned to the - same content column. */} - -
- - as="tab" - ariaLabel={t('orchPage.group.network')} - testIdPrefix="orch-network" - className="inline-flex flex-wrap items-center gap-1.5" - items={[ - { id: 'connections', label: t('orchPage.connections.nav') }, - { id: 'discover', label: t('orchPage.discover.nav') }, - { id: 'usage', label: t('orchPage.usage.nav') }, - ]} - value={networkSub} - onChange={setNetworkSub} - /> - } - /> - {networkSub === 'connections' && ( - setNetworkSub('discover')} - onInitializeAgent={() => setActiveTab('agent')} - /> - )} - {networkSub === 'discover' && } - {networkSub === 'usage' && } -
-
-
- ) : ( - // Scale showcase: fake peer-agent mesh with the preview banner. - - )} -
- ); -} diff --git a/app/src/pages/__tests__/Brain.test.tsx b/app/src/pages/__tests__/Brain.test.tsx index 540eb66bc9..2a7de73e2f 100644 --- a/app/src/pages/__tests__/Brain.test.tsx +++ b/app/src/pages/__tests__/Brain.test.tsx @@ -9,7 +9,7 @@ const graphExportMock = vi.hoisted(() => vi.fn()); // (userId null → set) and assert the graph reloads (#4149). const coreAuthRef = vi.hoisted(() => ({ current: 'user-A' as string | null })); // Captures navigate() calls so we can assert the legacy TinyPlace-orchestration -// deep link bounces to the promoted top-level /orchestration tab. +// deep link bounces to the folded-in Orchestration sub-tab. const navigateSpy = vi.hoisted(() => vi.fn()); vi.mock('react-router-dom', async importOriginal => { @@ -62,6 +62,10 @@ vi.mock('../../components/layout/ChipTabs', async () => { }; }); vi.mock('../../components/ui/BetaBanner', () => ({ default: () => null })); +vi.mock('../../components/orchestration/OrchestrationView', async () => { + const React = await import('react'); + return { default: () => React.createElement('div', { 'data-testid': 'brain-orchestration' }) }; +}); vi.mock('../../components/intelligence/MemoryControls', () => ({ MemoryControls: () => null })); vi.mock('../../components/intelligence/MemoryTreeStatusPanel', async () => { @@ -181,13 +185,23 @@ describe('Brain page', () => { }); }); - it('redirects the legacy tinyplace-orchestration deep link to /orchestration', async () => { + it('renders the folded-in Orchestration view on the orchestration tab', async () => { + graphExportMock.mockResolvedValue(makeGraph(0)); + await act(async () => { + renderWithProviders(, { initialEntries: ['/?tab=orchestration'] }); + }); + await waitFor(() => { + expect(screen.getByTestId('brain-orchestration')).toBeInTheDocument(); + }); + }); + + it('redirects the legacy tinyplace-orchestration deep link to the orchestration tab', async () => { graphExportMock.mockResolvedValue(makeGraph(0)); await act(async () => { renderWithProviders(, { initialEntries: ['/?tab=tinyplace-orchestration'] }); }); await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith('/orchestration', { replace: true }); + expect(navigateSpy).toHaveBeenCalledWith('/brain?tab=orchestration', { replace: true }); }); }); }); diff --git a/app/src/pages/__tests__/OrchestrationPage.test.tsx b/app/src/pages/__tests__/OrchestrationPage.test.tsx deleted file mode 100644 index 8843ddf5c8..0000000000 --- a/app/src/pages/__tests__/OrchestrationPage.test.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { act, fireEvent, screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { renderWithProviders } from '../../test/test-utils'; -import OrchestrationPage from '../OrchestrationPage'; - -vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); - -// Medulla access is toggled per-test; default to granted so the shell-routing -// tests exercise the live panels. -let medullaAccess = true; -vi.mock('../../lib/orchestration/useMedullaAccess', () => ({ - useMedullaAccess: () => medullaAccess, -})); - -// Stub the data-backed panels so the shell's tab routing is tested in -// isolation (the panels have their own unit tests). -vi.mock('../../components/orchestration/MedullaOverviewPanel', () => ({ - default: () =>
, -})); -vi.mock('../../components/orchestration/AgentChatPanel', () => ({ - default: () =>
, -})); -vi.mock('../../components/orchestration/ConnectionsPanel', () => ({ - default: ({ onDiscover }: { onDiscover?: () => void }) => ( - - ), -})); -vi.mock('../../components/orchestration/DiscoverPanel', () => ({ - default: () =>
, -})); -vi.mock('../../components/orchestration/UsagePanel', () => ({ - default: () =>
, -})); -vi.mock('../../components/orchestration/OrchestratorTaskBoard', () => ({ - default: () =>
, -})); - -describe('OrchestrationPage shell', () => { - beforeEach(() => { - medullaAccess = true; - }); - - it('defaults to the Medulla overview panel', async () => { - await act(async () => { - renderWithProviders(, { initialEntries: ['/orchestration'] }); - }); - expect(screen.getByTestId('panel-medulla')).toBeInTheDocument(); - }); - - it('renders the agent chat panel from ?tab=agent', async () => { - await act(async () => { - renderWithProviders(, { initialEntries: ['/orchestration?tab=agent'] }); - }); - expect(screen.getByTestId('panel-agent')).toBeInTheDocument(); - }); - - it.each([ - ['connections', 'panel-connections'], - ['discover', 'panel-discover'], - ['usage', 'panel-usage'], - ])('renders the %s panel from ?tab=%s', async (tab, testId) => { - await act(async () => { - renderWithProviders(, { initialEntries: [`/orchestration?tab=${tab}`] }); - }); - expect(screen.getByTestId(testId)).toBeInTheDocument(); - }); - - it('projects a sub-nav that switches tabs', async () => { - await act(async () => { - renderWithProviders(, { initialEntries: ['/orchestration'] }); - }); - // Sub-nav renders via the sidebar portal once the outlet mounts. `usage` is - // now a chip sub of the `network` tab, so the top-level nav exposes `network`. - const networkNav = await screen.findByTestId('two-pane-nav-network'); - await act(async () => { - fireEvent.click(networkNav); - }); - await waitFor(() => expect(screen.getByTestId('panel-connections')).toBeInTheDocument()); - }); - - it('lets the connections panel jump to discover via its callback', async () => { - await act(async () => { - renderWithProviders(, { - initialEntries: ['/orchestration?tab=connections'], - }); - }); - await act(async () => { - fireEvent.click(screen.getByTestId('panel-connections')); - }); - await waitFor(() => expect(screen.getByTestId('panel-discover')).toBeInTheDocument()); - }); -}); - -describe('OrchestrationPage scale showcase (no Medulla access)', () => { - beforeEach(() => { - medullaAccess = false; - }); - - it.each([ - ['agent', 'orch-demo-chat'], - ['overview', 'orch-demo-graph'], - ['network', 'orch-demo-network'], - ])('renders the demo surface for ?tab=%s', async (tab, testId) => { - await act(async () => { - renderWithProviders(, { initialEntries: [`/orchestration?tab=${tab}`] }); - }); - expect(screen.getByTestId(testId)).toBeInTheDocument(); - }); - - it('keeps the real task board available without Medulla access', async () => { - await act(async () => { - renderWithProviders(, { initialEntries: ['/orchestration?tab=tasks'] }); - }); - expect(screen.getByTestId('panel-tasks')).toBeInTheDocument(); - }); - - it('still lands on the Medulla overview by default', async () => { - await act(async () => { - renderWithProviders(, { initialEntries: ['/orchestration'] }); - }); - expect(screen.getByTestId('panel-medulla')).toBeInTheDocument(); - }); -}); diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx index 1b54499be0..e6274ac594 100644 --- a/app/src/providers/CoreStateProvider.tsx +++ b/app/src/providers/CoreStateProvider.tsx @@ -24,6 +24,7 @@ import { listTeams, updateCoreLocalState, } from '../services/coreStateApi'; +import { daemonHealthService } from '../services/daemonHealthService'; import { socketService } from '../services/socketService'; import { store } from '../store'; import { resetUserScopedState } from '../store/resetActions'; @@ -48,6 +49,14 @@ const POLL_MS = 2000; const MAX_BOOTSTRAP_RETRIES = 5; const SUPPRESS_POLL_WARNING_AT = MAX_BOOTSTRAP_RETRIES + 1; const BACKOFF_POLL_MS = 10_000; +// Once the app has finished bootstrapping and is authenticated, the snapshot +// (auth, onboarding, service/local-AI state) changes rarely and mostly through +// event-driven refreshes (deep-link, settings toggles) that fire immediately +// regardless of this cadence. `app_state_snapshot` is expensive server-side +// (rebuilds the runtime snapshot, reloads config + local state), so steady-state +// polling backs off from POLL_MS to this slower cadence rather than hammering +// every 2s for the life of the session. +const STABLE_POLL_MS = 5000; /** Extract only non-sensitive fields from an RPC/fetch error. */ function sanitizeError(error: unknown): { message?: string; code?: string; status?: number } { @@ -283,7 +292,8 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) const refreshCore = useCallback(async () => { const requestId = ++snapshotRequestIdRef.current; - const snapshot = normalizeSnapshot(await fetchCoreAppSnapshot()); + const rawSnapshot = await fetchCoreAppSnapshot(); + const snapshot = normalizeSnapshot(rawSnapshot); if (!isMountedRef.current) { return; } @@ -341,6 +351,30 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) }; }); + // Feed the folded health payload to the daemon-health store (replaces the + // former standalone health_snapshot poll). Done AFTER the commit and only + // when this refresh is still current, so `daemonHealthService` resolves the + // freshly-committed identity — not a stale/pre-commit or superseded token, + // which during a login/identity flip would write health under the prior or + // `__pending__` user. + if (requestId === snapshotRequestIdRef.current) { + // Privacy-safe: log presence/shape only, never the payload/tokens. + log( + 'health ingest: requestId=%d current=%d hasHealth=%s components=%d', + requestId, + snapshotRequestIdRef.current, + rawSnapshot.health != null, + rawSnapshot.health ? Object.keys(rawSnapshot.health.components ?? {}).length : 0 + ); + daemonHealthService.ingestHealthSnapshot(rawSnapshot.health); + } else { + log( + 'health ingest skipped: superseded refresh requestId=%d current=%d', + requestId, + snapshotRequestIdRef.current + ); + } + // When the authenticated identity changes without a full restart-driven // flip (e.g. same-process session attach or web where `restartApp` is a // no-op), the thread slice can still hold rows from the pre-login @@ -521,11 +555,31 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) } }; + // Arm a baseline disconnect watchdog before the first snapshot lands, so a + // core whose snapshots never succeed still falls back to `disconnected` + // instead of sticking at a probe-set `running`. Each successful ingest + // re-arms it. + daemonHealthService.ensureWatchdogArmed(); + void load(); let timeoutId: number | null = null; + const computePollDelay = (): { delay: number; reason: string } => { + // Repeated bootstrap failures → slowest cadence to avoid log/CPU churn. + if (bootstrapFailCountRef.current >= MAX_BOOTSTRAP_RETRIES) { + return { delay: BACKOFF_POLL_MS, reason: 'failure-backoff' }; + } + // Booted and authenticated → steady state; back off the expensive snapshot + // poll. Still-bootstrapping or unauthenticated stays fast so login / boot + // transitions surface promptly. + const state = getCoreStateSnapshot(); + if (!state.isBootstrapping && state.snapshot.auth.isAuthenticated) { + return { delay: STABLE_POLL_MS, reason: 'authenticated' }; + } + return { delay: POLL_MS, reason: 'bootstrap' }; + }; const scheduleNext = () => { - const delay = - bootstrapFailCountRef.current >= MAX_BOOTSTRAP_RETRIES ? BACKOFF_POLL_MS : POLL_MS; + const { delay, reason } = computePollDelay(); + log('poll scheduled: delay=%dms reason=%s', delay, reason); timeoutId = window.setTimeout(async () => { await doRefresh(); if (!cancelled) { diff --git a/app/src/services/__tests__/daemonHealthService.test.ts b/app/src/services/__tests__/daemonHealthService.test.ts new file mode 100644 index 0000000000..2f50a69912 --- /dev/null +++ b/app/src/services/__tests__/daemonHealthService.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { setDaemonStatus, updateHealthSnapshot } from '../../features/daemon/store'; +import { DaemonHealthService } from '../daemonHealthService'; + +vi.mock('../../features/daemon/store', () => ({ + setDaemonStatus: vi.fn(), + updateHealthSnapshot: vi.fn(), +})); + +vi.mock('../../lib/coreState/store', () => ({ + getCoreStateSnapshot: () => ({ snapshot: { sessionToken: null } }), +})); + +const mockedUpdate = vi.mocked(updateHealthSnapshot); +const mockedSetStatus = vi.mocked(setDaemonStatus); + +const healthPayload = (overrides: Record = {}) => ({ + pid: 123, + updated_at: '2026-07-21T00:00:00Z', + uptime_seconds: 10, + components: { gateway: { status: 'ok', updated_at: '2026-07-21T00:00:00Z', restart_count: 0 } }, + ...overrides, +}); + +describe('DaemonHealthService.ingestHealthSnapshot', () => { + beforeEach(() => { + vi.useFakeTimers(); + mockedUpdate.mockReset(); + mockedSetStatus.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('parses a valid payload and updates the daemon store', () => { + const service = new DaemonHealthService(); + service.ingestHealthSnapshot(healthPayload()); + + expect(mockedUpdate).toHaveBeenCalledTimes(1); + const [, snapshot] = mockedUpdate.mock.calls[0]; + expect(snapshot.pid).toBe(123); + expect(snapshot.components.gateway.status).toBe('ok'); + + service.cleanup(); + }); + + it('ignores a missing or unparseable payload but keeps the core connected (older core)', () => { + const service = new DaemonHealthService(); + service.ingestHealthSnapshot(undefined); + service.ingestHealthSnapshot(null); + service.ingestHealthSnapshot({ not: 'a health snapshot' }); + + // Store is not updated with health... + expect(mockedUpdate).not.toHaveBeenCalled(); + // ...but the arriving (health-less) snapshot is proof of liveness, so the + // watchdog is re-armed and the core is NOT marked disconnected while these + // snapshots keep succeeding. + vi.advanceTimersByTime(60000); + service.ingestHealthSnapshot(null); + vi.advanceTimersByTime(60000); + expect(mockedSetStatus).not.toHaveBeenCalled(); + service.cleanup(); + }); + + it('arms a baseline watchdog so a core whose snapshots never arrive disconnects', () => { + const service = new DaemonHealthService(); + // No ingest ever (snapshots keep timing out), but the baseline watchdog is + // armed at startup, so status still falls back to disconnected. + service.ensureWatchdogArmed(); + vi.advanceTimersByTime(120000); + expect(mockedSetStatus).toHaveBeenCalledWith(expect.any(String), 'disconnected'); + service.cleanup(); + }); + + it('ensureWatchdogArmed is idempotent and does not reset an in-flight watchdog', () => { + const service = new DaemonHealthService(); + service.ensureWatchdogArmed(); + vi.advanceTimersByTime(90000); + // A second call must NOT restart the timer (would delay disconnect detection). + service.ensureWatchdogArmed(); + vi.advanceTimersByTime(30000); + expect(mockedSetStatus).toHaveBeenCalledWith(expect.any(String), 'disconnected'); + service.cleanup(); + }); + + it('does not mark disconnected during a slow-but-alive snapshot window', () => { + const service = new DaemonHealthService(); + service.ingestHealthSnapshot(healthPayload()); + + // A first-launch app_state_snapshot can legitimately take 30–40s; the + // watchdog must NOT false-fire while one slow snapshot is in flight. + vi.advanceTimersByTime(60000); + expect(mockedSetStatus).not.toHaveBeenCalled(); + + service.cleanup(); + }); + + it('marks the daemon disconnected when no snapshot arrives within the timeout', () => { + const service = new DaemonHealthService(); + service.ingestHealthSnapshot(healthPayload()); + expect(mockedSetStatus).not.toHaveBeenCalled(); + + // No further ingest for the full watchdog window → disconnected. + vi.advanceTimersByTime(120000); + expect(mockedSetStatus).toHaveBeenCalledWith(expect.any(String), 'disconnected'); + + service.cleanup(); + }); + + it('re-arms the disconnect watchdog on each ingest', () => { + const service = new DaemonHealthService(); + service.ingestHealthSnapshot(healthPayload()); + + // A fresh snapshot before the deadline pushes it out. + vi.advanceTimersByTime(100000); + service.ingestHealthSnapshot(healthPayload()); + vi.advanceTimersByTime(100000); + expect(mockedSetStatus).not.toHaveBeenCalled(); + + // Then go quiet past the window → disconnected. + vi.advanceTimersByTime(120000); + expect(mockedSetStatus).toHaveBeenCalledWith(expect.any(String), 'disconnected'); + + service.cleanup(); + }); +}); diff --git a/app/src/services/coreStateApi.ts b/app/src/services/coreStateApi.ts index c124772cb0..8be2a5c563 100644 --- a/app/src/services/coreStateApi.ts +++ b/app/src/services/coreStateApi.ts @@ -64,6 +64,33 @@ interface AppStateSnapshotResult { autocomplete: AutocompleteStatus; service: ServiceStatus; }; + /** + * Process + component health, folded into this snapshot (#daemon-poll-fold) + * so the daemon-health store hydrates from the same poll instead of a second + * `health_snapshot` poller. Fields are snake_case on the wire (the core type + * has no camelCase rename). Optional so older cores that omit it degrade + * gracefully — the daemon store simply isn't refreshed from those. + */ + health?: RawHealthSnapshot; +} + +/** Raw (snake_case) health payload embedded in the app-state snapshot. */ +export interface RawHealthSnapshot { + pid: number; + updated_at: string; + uptime_seconds: number; + components: Record< + string, + { + status: string; + updated_at: string; + // Rust serializes absent `Option` as `null` (no skip attribute), + // so match `src/openhuman/health/core.rs` — not `string | undefined`. + last_ok?: string | null; + last_error?: string | null; + restart_count: number; + } + >; } /** diff --git a/app/src/services/daemonHealthService.ts b/app/src/services/daemonHealthService.ts index e61fc235fb..f1d0be0ff5 100644 --- a/app/src/services/daemonHealthService.ts +++ b/app/src/services/daemonHealthService.ts @@ -1,7 +1,15 @@ /** * Daemon Health Service * - * Polls the Rust core health snapshot and keeps the frontend daemon store in sync. + * Keeps the frontend daemon store in sync with the Rust core's component health. + * + * Health is no longer polled on its own timer: the core folds its health + * snapshot into `app_state_snapshot`, and `CoreStateProvider` feeds each + * snapshot's `health` payload here via {@link ingestHealthSnapshot}. That + * collapses the former separate `health_snapshot` poll into the one app-state + * poll. This service now owns only the parse + store update + the + * disconnect-timeout watchdog (no data yet after {@link HEALTH_TIMEOUT_MS} → + * mark the daemon disconnected). */ import { type ComponentHealth, @@ -10,47 +18,52 @@ import { updateHealthSnapshot, } from '../features/daemon/store'; import { getCoreStateSnapshot } from '../lib/coreState/store'; -import { callCoreRpc } from './coreRpcClient'; export class DaemonHealthService { private healthTimeoutId: ReturnType | null = null; - private readonly HEALTH_TIMEOUT_MS = 30000; - private pollingIntervalId: ReturnType | null = null; - private readonly POLL_MS = 2000; - - async setupHealthListener(): Promise<(() => void) | null> { - if (this.pollingIntervalId) { - return () => this.cleanup(); + // Health now arrives folded into `app_state_snapshot`, which is allowed to run + // for up to `SNAPSHOT_TIMEOUT_MS` (90s) — first-launch snapshots legitimately + // take 30–40s. The disconnect watchdog must therefore tolerate one worst-case + // slow snapshot (plus the poll cadence) between successful ingests, or a merely + // slow-but-alive core would be marked `disconnected`. 120s covers the 90s cap + // with margin; genuine disconnection (snapshots stop succeeding entirely) is + // still detected, just less aggressively than the old dedicated 2s poll. + private readonly HEALTH_TIMEOUT_MS = 120000; + + /** + * Arm the disconnect watchdog once when daemon-health tracking starts, if it + * isn't already armed. Without this, a core whose `app_state_snapshot`s never + * succeed (repeated timeouts) — after `useDaemonHealth`'s one-shot agent probe + * has set the status to `running` — would never arm a watchdog and stick at + * `running` forever. The baseline watchdog guarantees a fallback to + * `disconnected` if no snapshot ever arrives, and is re-armed by each ingest. + */ + ensureWatchdogArmed(): void { + if (this.healthTimeoutId === null) { + this.startHealthTimeout(); } + } - const pollOnce = async () => { - try { - const payload = await callCoreRpc({ method: 'openhuman.health_snapshot' }); - const healthSnapshot = this.parseHealthSnapshot(payload); - if (healthSnapshot) { - this.updateDaemonStoreFromHealth(healthSnapshot); - this.startHealthTimeout(); - } - } catch { - // The health endpoint can fail while the sidecar is starting. - } - }; - - await pollOnce(); - this.pollingIntervalId = setInterval(() => { - void pollOnce(); - }, this.POLL_MS); + /** + * Ingest a health payload carried by an `app_state_snapshot` refresh. + * + * The snapshot arriving at all is proof the core is alive, so the disconnect + * watchdog is re-armed unconditionally — even when the payload is missing or + * unparseable (an older core that doesn't fold health, or a partial payload) — + * otherwise a live-but-health-less core would eventually be marked + * `disconnected`. The daemon store is only updated when a valid health + * snapshot is present; otherwise it keeps its last-known state. + */ + ingestHealthSnapshot(payload: unknown): void { + // Called by CoreStateProvider only after a successful snapshot → liveness. this.startHealthTimeout(); - - return () => this.cleanup(); + const healthSnapshot = this.parseHealthSnapshot(payload); + if (healthSnapshot) { + this.updateDaemonStoreFromHealth(healthSnapshot); + } } cleanup(): void { - if (this.pollingIntervalId) { - clearInterval(this.pollingIntervalId); - this.pollingIntervalId = null; - } - if (this.healthTimeoutId) { clearTimeout(this.healthTimeoutId); this.healthTimeoutId = null; diff --git a/app/src/store/__tests__/chatRuntimeSlice.thunk.test.ts b/app/src/store/__tests__/chatRuntimeSlice.thunk.test.ts index fb1cbc4495..3d36b6eb69 100644 --- a/app/src/store/__tests__/chatRuntimeSlice.thunk.test.ts +++ b/app/src/store/__tests__/chatRuntimeSlice.thunk.test.ts @@ -89,7 +89,7 @@ describe('fetchAndHydrateTurnHistory', () => { expect(timelines['req-0']).toBeUndefined(); }); - it('keeps each past turn\'s reasoning/narration transcript (fix 1), ordered by seq (fix 5)', async () => { + it("keeps each past turn's reasoning/narration transcript (fix 1), ordered by seq (fix 5)", async () => { const store = configureStore({ reducer }); mockThreadApi.getTurnStateHistory.mockResolvedValueOnce([ // req-latest is skipped (index 0). diff --git a/app/src/store/chatRuntimeSlice.test.ts b/app/src/store/chatRuntimeSlice.test.ts index 396ab8d67f..99a2224d32 100644 --- a/app/src/store/chatRuntimeSlice.test.ts +++ b/app/src/store/chatRuntimeSlice.test.ts @@ -932,7 +932,9 @@ describe('hydrateRuntimeFromSnapshot — interrupted partial answer (fix 2)', () it('surfaces the persisted partial reply + thinking as a settled buffer', () => { const store = makeStore(); - store.dispatch(hydrateRuntimeFromSnapshot({ snapshot: makeInterruptedPartialSnapshot('t-int') })); + store.dispatch( + hydrateRuntimeFromSnapshot({ snapshot: makeInterruptedPartialSnapshot('t-int') }) + ); const state = store.getState().chatRuntime; expect(state.interruptedAssistantByThread['t-int']).toEqual({ diff --git a/app/test/e2e/specs/navigation.spec.ts b/app/test/e2e/specs/navigation.spec.ts index 9ea0ac4f6e..4d9a2ddc31 100644 --- a/app/test/e2e/specs/navigation.spec.ts +++ b/app/test/e2e/specs/navigation.spec.ts @@ -45,7 +45,11 @@ const ROUTES: Route[] = [ { hash: '/settings' }, { hash: '/agent-world' }, { hash: '/flows' }, - { hash: '/orchestration' }, + // Orchestration folded under Brain; `/orchestration` now redirects to + // `/brain?tab=orchestration`, so we assert the Brain destination instead + // (the bare `/orchestration` hash would settle on the redirect target and + // fail the `^#/orchestration` match, same reasoning as /home above). + { hash: '/brain' }, ]; async function rootTextLength(): Promise { diff --git a/src/openhuman/app_state/ops.rs b/src/openhuman/app_state/ops.rs index b8f12bc6c4..23e155451e 100644 --- a/src/openhuman/app_state/ops.rs +++ b/src/openhuman/app_state/ops.rs @@ -168,6 +168,11 @@ pub struct AppStateSnapshot { pub local_state: StoredAppState, pub keyring_status: crate::openhuman::keyring_consent::KeyringStatus, pub runtime: RuntimeSnapshot, + /// Process + component health, folded into this snapshot so the frontend + /// hydrates the daemon-health store from the same poll instead of running a + /// second `health_snapshot` poller. Fields stay snake_case (the type has no + /// camelCase rename) to match the frontend's existing health parser. + pub health: crate::openhuman::health::HealthSnapshot, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1220,6 +1225,7 @@ pub async fn snapshot() -> Result, String> { ); let keyring_status = crate::openhuman::keyring_consent::policy::current_status(); + let health = crate::openhuman::health::snapshot(); Ok(RpcOutcome::new( AppStateSnapshot { @@ -1233,6 +1239,7 @@ pub async fn snapshot() -> Result, String> { local_state, keyring_status, runtime, + health, }, vec!["core app state snapshot fetched".to_string()], )) diff --git a/src/openhuman/config/schema/load/env_overlay.rs b/src/openhuman/config/schema/load/env_overlay.rs index e454710003..52b5e34505 100644 --- a/src/openhuman/config/schema/load/env_overlay.rs +++ b/src/openhuman/config/schema/load/env_overlay.rs @@ -7,6 +7,31 @@ use super::dirs::MEMORY_SYNC_INTERVAL_SECS_ENV_VAR; use super::env::parse_env_bool; use std::path::PathBuf; +/// Classification of an `OPENHUMAN_SHELL_HIDE_WINDOW` env value. Split out from +/// the apply site (where the three cases differ only by log level) so the +/// empty-vs-unrecognized distinction is unit-testable without capturing tracing +/// output — a bare `VAR=` must classify as `Unset` (silent no-op), not +/// `Unrecognized` (which warns). +#[derive(Debug, PartialEq, Eq)] +pub(super) enum ShellHideWindowParse { + /// Empty / whitespace-only — the var is present but has no value; treat as + /// absent (no change, no warning). + Unset, + /// A recognized boolean value. + Set(bool), + /// A non-empty value that isn't a recognized boolean — warn and ignore. + Unrecognized, +} + +pub(super) fn classify_shell_hide_window(raw: &str) -> ShellHideWindowParse { + match raw.trim().to_ascii_lowercase().as_str() { + "" => ShellHideWindowParse::Unset, + "1" | "true" | "yes" | "on" => ShellHideWindowParse::Set(true), + "0" | "false" | "no" | "off" => ShellHideWindowParse::Set(false), + _ => ShellHideWindowParse::Unrecognized, + } +} + impl Config { pub fn apply_env_overrides(&mut self) { use super::env::ProcessEnv; @@ -119,23 +144,25 @@ impl Config { } if let Some(flag) = env.get_any(&["OPENHUMAN_SHELL_HIDE_WINDOW", "SHELL_HIDE_WINDOW"]) { - let normalized = flag.trim().to_ascii_lowercase(); - match normalized.as_str() { - "1" | "true" | "yes" | "on" => { - self.shell.hide_window = true; - tracing::debug!( - value = %flag, - "[config][shell] OPENHUMAN_SHELL_HIDE_WINDOW applied: hide_window=true" - ); - } - "0" | "false" | "no" | "off" => { - self.shell.hide_window = false; + match classify_shell_hide_window(&flag) { + // An empty / whitespace-only value means the var is present but + // unset (common when a `.env` or launcher exports `VAR=`). Treat + // it as absent — keep the current value rather than warning on + // every boot. Trace-level so the no-op stays diagnosable without + // the INFO/WARN noise this change exists to remove. + ShellHideWindowParse::Unset => tracing::trace!( + "[config][shell] OPENHUMAN_SHELL_HIDE_WINDOW empty value treated as unset; \ + keeping hide_window={}", + self.shell.hide_window + ), + ShellHideWindowParse::Set(value) => { + self.shell.hide_window = value; tracing::debug!( value = %flag, - "[config][shell] OPENHUMAN_SHELL_HIDE_WINDOW applied: hide_window=false" + "[config][shell] OPENHUMAN_SHELL_HIDE_WINDOW applied: hide_window={value}" ); } - _ => tracing::warn!( + ShellHideWindowParse::Unrecognized => tracing::warn!( value = %flag, "[config][shell] OPENHUMAN_SHELL_HIDE_WINDOW unrecognized value ignored; \ keeping current hide_window={}", diff --git a/src/openhuman/config/schema/load_tests.rs b/src/openhuman/config/schema/load_tests.rs index 9e87902ef2..78322fd5c4 100644 --- a/src/openhuman/config/schema/load_tests.rs +++ b/src/openhuman/config/schema/load_tests.rs @@ -235,11 +235,53 @@ fn apply_env_overrides_shell_hide_window_parses_truthy_falsy() { } cfg.apply_env_overrides(); assert!(cfg.shell.hide_window); + + // An empty / whitespace-only value is treated as unset: the field is left + // unchanged and it must NOT hit the "unrecognized value" warn path (a bare + // `OPENHUMAN_SHELL_HIDE_WINDOW=` in the environment previously warned on + // every boot). + cfg.shell.hide_window = true; + unsafe { + std::env::set_var("OPENHUMAN_SHELL_HIDE_WINDOW", ""); + } + cfg.apply_env_overrides(); + assert!( + cfg.shell.hide_window, + "empty value should leave hide_window=true" + ); + + cfg.shell.hide_window = false; + unsafe { + std::env::set_var("OPENHUMAN_SHELL_HIDE_WINDOW", " "); + } + cfg.apply_env_overrides(); + assert!( + !cfg.shell.hide_window, + "whitespace-only value should leave hide_window=false" + ); + unsafe { std::env::remove_var("OPENHUMAN_SHELL_HIDE_WINDOW"); } } +#[test] +fn classify_shell_hide_window_distinguishes_unset_from_unrecognized() { + use super::env_overlay::{classify_shell_hide_window, ShellHideWindowParse as P}; + // The key distinction the change relies on: an empty / whitespace-only value + // is `Unset` (silent no-op), NOT `Unrecognized` (which warns on every boot). + // Testing the classifier directly proves this — the field-unchanged assertion + // above holds for BOTH branches and so can't catch a regression here. + assert_eq!(classify_shell_hide_window(""), P::Unset); + assert_eq!(classify_shell_hide_window(" "), P::Unset); + assert_eq!(classify_shell_hide_window("\t"), P::Unset); + assert_eq!(classify_shell_hide_window("on"), P::Set(true)); + assert_eq!(classify_shell_hide_window("FALSE"), P::Set(false)); + assert_eq!(classify_shell_hide_window(" yes "), P::Set(true)); + assert_eq!(classify_shell_hide_window("maybe"), P::Unrecognized); + assert_eq!(classify_shell_hide_window("2"), P::Unrecognized); +} + #[test] fn apply_env_overrides_web_search_limits_only() { let _g = env_lock(); diff --git a/src/openhuman/health/core.rs b/src/openhuman/health/core.rs index d6a9206037..45c52b12a8 100644 --- a/src/openhuman/health/core.rs +++ b/src/openhuman/health/core.rs @@ -1,11 +1,11 @@ use chrono::Utc; use parking_lot::Mutex; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::sync::OnceLock; use std::time::Instant; -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ComponentHealth { pub status: String, pub updated_at: String, @@ -14,7 +14,7 @@ pub struct ComponentHealth { pub restart_count: u64, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct HealthSnapshot { pub pid: u32, pub updated_at: String, diff --git a/src/openhuman/keyring_consent/policy.rs b/src/openhuman/keyring_consent/policy.rs index ca4e46ed27..2a83bfefb3 100644 --- a/src/openhuman/keyring_consent/policy.rs +++ b/src/openhuman/keyring_consent/policy.rs @@ -24,14 +24,33 @@ static CONSENT_EVENT_PUBLISHED: AtomicBool = AtomicBool::new(false); /// they never touch disk on the hot path. static CONSENT_CACHE: RwLock> = RwLock::new(None); -/// Pre-populate the consent cache from persisted app state. Call once at core -/// startup after config is loadable. -pub fn initialize(consent: Option) { +/// Populate the consent cache from persisted app state. +/// +/// Called from the per-request `app_state_snapshot` path, so it runs many times +/// over a session — not once at startup as the name suggests. It is therefore +/// change-gated: it writes and logs only when the persisted consent actually +/// differs from what is already cached. A repeat call with the same value (the +/// common case on every snapshot) is a silent no-op, keeping boot logs clean. +/// +/// Returns `true` when the cache was updated (the INFO log fired) and `false` +/// on the no-op path — this lets callers/tests observe the suppressed side +/// effect directly rather than only the (identical) resulting cache value. +pub fn initialize(consent: Option) -> bool { + // Hold the write lock across the compare + set so concurrent snapshots + // can't both observe a change and double-log / double-write. + let mut cache = CONSENT_CACHE.write(); + if *cache == consent { + // No-op path (every app_state_snapshot with unchanged consent). Trace so + // it stays diagnosable without the INFO noise this change removes. + log::trace!("{LOG_PREFIX} initialize no-op: cached consent unchanged"); + return false; + } info!( "{LOG_PREFIX} initialize cached_consent={}", consent.as_ref().map_or("none", |p| p.storage_mode.as_str()), ); - *CONSENT_CACHE.write() = consent; + *cache = consent; + true } /// Check whether the caller is allowed to proceed with secret storage. @@ -242,6 +261,7 @@ mod tests { #[test] fn initialize_populates_cache() { let _lock = cache_test_lock(); + *CONSENT_CACHE.write() = None; let pref = ConsentPreference { storage_mode: "declined".to_string(), consented_at_ms: Some(12345), @@ -250,4 +270,40 @@ mod tests { let cached = CONSENT_CACHE.read().clone(); assert_eq!(cached.unwrap().storage_mode, "declined"); } + + #[test] + fn initialize_is_change_gated() { + let _lock = cache_test_lock(); + *CONSENT_CACHE.write() = None; + + // First real value populates the cache and reports it applied (the INFO + // log + write happened). + let pref = ConsentPreference { + storage_mode: "local_encrypted".to_string(), + consented_at_ms: Some(111), + }; + assert!(initialize(Some(pref.clone())), "first value should apply"); + assert_eq!(CONSENT_CACHE.read().clone(), Some(pref.clone())); + + // Repeat with the identical value — the no-op path: returns false (no + // write, no INFO log), which is what every app_state_snapshot hits. + // Asserting the return value proves the side effect is suppressed, not + // merely that the resulting cache value is unchanged. + assert!( + !initialize(Some(pref.clone())), + "identical value must be a no-op (no re-log / re-write)" + ); + assert_eq!(CONSENT_CACHE.read().clone(), Some(pref)); + + // A genuine change is still applied (returns true). + let changed = ConsentPreference { + storage_mode: "declined".to_string(), + consented_at_ms: Some(222), + }; + assert!( + initialize(Some(changed.clone())), + "a genuine change should apply" + ); + assert_eq!(CONSENT_CACHE.read().clone(), Some(changed)); + } } diff --git a/src/openhuman/keyring_consent/types.rs b/src/openhuman/keyring_consent/types.rs index a0cde46712..2d896eb725 100644 --- a/src/openhuman/keyring_consent/types.rs +++ b/src/openhuman/keyring_consent/types.rs @@ -54,7 +54,7 @@ pub struct KeyringStatus { pub backend_name: String, } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct ConsentPreference { #[serde(default)] diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index eba600df18..bba393e0ef 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -6075,6 +6075,30 @@ async fn json_rpc_app_state_snapshot_returns_runtime_shape() { "expected runtime.service object: {runtime}" ); + // Component health is folded into this snapshot so the frontend hydrates the + // daemon-health store from the same poll (no separate health_snapshot poll). + // Its fields stay snake_case (the core HealthSnapshot type has no camelCase + // rename), matching the frontend health parser. + let health = body.get("health").expect("expected health object"); + assert!( + health.get("pid").and_then(Value::as_u64).is_some(), + "expected health.pid: {health}" + ); + assert!( + health + .get("uptime_seconds") + .and_then(Value::as_u64) + .is_some(), + "expected health.uptime_seconds (snake_case): {health}" + ); + assert!( + health + .get("components") + .and_then(Value::as_object) + .is_some(), + "expected health.components object: {health}" + ); + mock_join.abort(); rpc_join.abort(); } diff --git a/worktrees/.gitkeep b/worktrees/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 From f1811150c844e5e9143fc4d1f13df0dd375303a6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:39:39 +0300 Subject: [PATCH 06/56] test(learning): isolate startup subscriber test (#5082) --- src/openhuman/learning/extract/signature.rs | 28 +++- src/openhuman/learning/startup.rs | 167 ++++++++------------ 2 files changed, 87 insertions(+), 108 deletions(-) diff --git a/src/openhuman/learning/extract/signature.rs b/src/openhuman/learning/extract/signature.rs index 2d99eb6138..2f01f86655 100644 --- a/src/openhuman/learning/extract/signature.rs +++ b/src/openhuman/learning/extract/signature.rs @@ -24,7 +24,7 @@ use async_trait::async_trait; use crate::core::event_bus::{subscribe_global, DomainEvent, EventHandler, SubscriptionHandle}; use crate::openhuman::learning::candidate::{ - self, CueFamily, EvidenceRef, FacetClass, LearningCandidate, + self, Buffer, CueFamily, EvidenceRef, FacetClass, LearningCandidate, }; // ── Constants ──────────────────────────────────────────────────────────────── @@ -463,8 +463,16 @@ fn now_secs() -> f64 { // ── Subscriber ─────────────────────────────────────────────────────────────── /// Event subscriber that reacts to `DocumentCanonicalized` events for email -/// sources and routes detected identity candidates into the global buffer. -pub struct EmailSignatureSubscriber; +/// sources and routes detected identity candidates into its configured buffer. +pub struct EmailSignatureSubscriber { + buffer: &'static Buffer, +} + +impl EmailSignatureSubscriber { + fn new(buffer: &'static Buffer) -> Self { + Self { buffer } + } +} #[async_trait] impl EventHandler for EmailSignatureSubscriber { @@ -516,9 +524,8 @@ impl EventHandler for EmailSignatureSubscriber { let candidates = parse_signature(body, source_id, &message_id); let count = candidates.len(); - let buf = candidate::global(); for c in candidates { - buf.push(c); + self.buffer.push(c); } tracing::debug!( "[learning::extract::signature] pushed {} identity candidate(s) for source_id={}", @@ -535,7 +542,16 @@ impl EventHandler for EmailSignatureSubscriber { /// The returned handle keeps the subscription alive — store it in a long-lived /// container (e.g. alongside other `SubscriptionHandle`s in startup). pub fn register_email_signature_subscriber() -> Option { - subscribe_global(Arc::new(EmailSignatureSubscriber)) + subscribe_global(Arc::new(EmailSignatureSubscriber::new(candidate::global()))) +} + +/// Register the email signature subscriber with isolated test dependencies. +#[cfg(test)] +pub(crate) fn register_email_signature_subscriber_on( + bus: &crate::core::event_bus::EventBus, + buffer: &'static Buffer, +) -> SubscriptionHandle { + bus.subscribe(Arc::new(EmailSignatureSubscriber::new(buffer))) } // ── Tests ──────────────────────────────────────────────────────────────────── diff --git a/src/openhuman/learning/startup.rs b/src/openhuman/learning/startup.rs index fbde7c8f87..e703fe2729 100644 --- a/src/openhuman/learning/startup.rs +++ b/src/openhuman/learning/startup.rs @@ -32,6 +32,8 @@ use crate::core::event_bus::SubscriptionHandle; use crate::openhuman::memory::global::client_if_ready; use crate::openhuman::memory_store::MemoryClientRef; +static EMAIL_SIG_HANDLE: OnceLock> = OnceLock::new(); + /// Register the always-on learning subscribers on the global event bus. /// /// Idempotent for any caller: every subscription is guarded by a process-wide @@ -46,10 +48,26 @@ pub fn register_learning_subscribers(workspace_dir: std::path::PathBuf) { // Phase 2 learning producer: email-signature subscriber reacts to // DocumentCanonicalized events and emits Identity candidates into the // buffer. Needs no memory client, so it always registers. - static EMAIL_SIG_HANDLE: OnceLock> = OnceLock::new(); - EMAIL_SIG_HANDLE.get_or_init(|| { - let handle = - crate::openhuman::learning::extract::signature::register_email_signature_subscriber(); + register_email_signature_once(&EMAIL_SIG_HANDLE, || { + crate::openhuman::learning::extract::signature::register_email_signature_subscriber() + }); + + // Phase 3 + Phase 4 learning: rebuild trigger + periodic loop + the + // ProfileMdRenderer. All three need the global memory client. The + // client-dependent work is split into `register_with_client` so both the + // ready and not-ready arms are unit-testable without touching process + // globals. + static CLIENT_HANDLES: OnceLock<(Option, Option)> = + OnceLock::new(); + CLIENT_HANDLES.get_or_init(|| register_with_client(client_if_ready(), &workspace_dir)); +} + +fn register_email_signature_once(handle_cell: &OnceLock>, register: F) +where + F: FnOnce() -> Option, +{ + handle_cell.get_or_init(|| { + let handle = register(); if handle.is_some() { tracing::info!( "[learning] email-signature subscriber registered (channel-independent boot path)" @@ -61,15 +79,6 @@ pub fn register_learning_subscribers(workspace_dir: std::path::PathBuf) { } handle }); - - // Phase 3 + Phase 4 learning: rebuild trigger + periodic loop + the - // ProfileMdRenderer. All three need the global memory client. The - // client-dependent work is split into `register_with_client` so both the - // ready and not-ready arms are unit-testable without touching process - // globals. - static CLIENT_HANDLES: OnceLock<(Option, Option)> = - OnceLock::new(); - CLIENT_HANDLES.get_or_init(|| register_with_client(client_if_ready(), &workspace_dir)); } /// Register the client-dependent learning subscribers. @@ -157,11 +166,12 @@ fn register_with_client( #[cfg(test)] mod tests { use super::*; - use crate::core::event_bus::{init_global, publish_global, DomainEvent, DEFAULT_CAPACITY}; - use crate::openhuman::learning::candidate::{self, EvidenceRef}; - use crate::openhuman::learning::extract::signature::parse_signature; + use crate::core::event_bus::{DomainEvent, EventBus}; + use crate::openhuman::learning::candidate::Buffer; + use crate::openhuman::learning::extract::signature::{ + parse_signature, register_email_signature_subscriber_on, + }; use crate::openhuman::memory_store::MemoryClient; - use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; use tempfile::TempDir; @@ -177,18 +187,6 @@ mod tests { (tmp, client) } - /// Process-unique email source id so buffer assertions never collide with - /// candidates pushed by other tests running in parallel against the shared - /// global buffer. - fn unique_source_id(tag: &str) -> String { - static COUNTER: AtomicU64 = AtomicU64::new(0); - format!( - "gmail:5003-{tag}-{}-{}", - std::process::id(), - COUNTER.fetch_add(1, Ordering::Relaxed) - ) - } - /// A body whose trailing lines form a clear email signature — yields several /// Identity candidates (name/role/timezone/employer). fn signature_body() -> String { @@ -202,50 +200,33 @@ mod tests { .to_string() } - fn publish_email_doc(source_id: &str, body: &str) { - publish_global(DomainEvent::DocumentCanonicalized { + fn email_doc(source_id: &str, body: &str) -> DomainEvent { + DomainEvent::DocumentCanonicalized { source_id: source_id.to_string(), source_kind: "email".to_string(), chunks_written: 1, chunk_ids: vec![format!("{source_id}-c1")], canonicalized_at: 0.0, body_preview: Some(body.to_string()), - }); - } - - /// Count candidates in the global buffer whose evidence points at - /// `source_id`. Isolates this test's assertions from concurrent producers. - fn candidates_for(source_id: &str) -> usize { - candidate::global() - .peek() - .iter() - .filter(|c| { - matches!( - &c.evidence, - EvidenceRef::EmailMessage { source_id: sid, .. } if sid == source_id - ) - }) - .count() + } } - /// Poll the global buffer until at least `expected` candidates for - /// `source_id` appear (async bus delivery), then settle briefly and return - /// the final count so an accidental double-registration would surface. - async fn wait_for_candidates(source_id: &str, expected: usize) -> usize { - for _ in 0..200 { - if candidates_for(source_id) >= expected { + /// Poll an isolated buffer until at least `expected` candidates appear, + /// then settle briefly so an accidental duplicate subscription surfaces. + async fn wait_for_candidates(buffer: &Buffer, expected: usize) -> usize { + for _ in 0..50 { + if buffer.len() >= expected { break; } - tokio::time::sleep(Duration::from_millis(20)).await; + tokio::time::sleep(Duration::from_millis(10)).await; } - // Settle: let any (unexpected) duplicate subscriber also deliver. - tokio::time::sleep(Duration::from_millis(40)).await; - candidates_for(source_id) + tokio::time::sleep(Duration::from_millis(20)).await; + buffer.len() } #[tokio::test] async fn register_with_client_registers_both_handles_when_ready() { - init_global(DEFAULT_CAPACITY); + crate::core::event_bus::init_global(crate::core::event_bus::DEFAULT_CAPACITY); let (tmp, client) = test_client(); let (trigger, renderer) = register_with_client(Some(client), tmp.path()); assert!( @@ -271,67 +252,49 @@ mod tests { #[tokio::test] async fn learning_subscriber_fires_with_no_channel_configured() { - init_global(DEFAULT_CAPACITY); - let (tmp, _client) = test_client(); - // Make the memory client ready so the full Platform wiring runs — no - // channel runtime is ever constructed in this test. - let _ = crate::openhuman::memory::global::init(tmp.path().join("workspace")); - register_learning_subscribers(tmp.path().to_path_buf()); + let bus = EventBus::create(16); + let buffer: &'static Buffer = Box::leak(Box::new(Buffer::new(16))); + let handle_cell = OnceLock::new(); + register_email_signature_once(&handle_cell, || { + Some(register_email_signature_subscriber_on(&bus, buffer)) + }); - let source_id = unique_source_id("e2e"); + let source_id = "gmail:5003-e2e"; let body = signature_body(); - let expected = parse_signature(&body, &source_id, &source_id).len(); + let expected = parse_signature(&body, source_id, source_id).len(); assert!( expected > 0, "signature body must yield at least one identity candidate" ); - // The email-signature subscriber lives on the process-wide *global* - // event bus and pushes into the shared, bounded `candidate::global()` - // ring. Under the full-suite coverage run (thousands of tests in one - // process — the module filter widened when this tree stopped touching - // only `learning/`), that global bus is under heavy concurrent load: a - // single published event can be dropped to tokio broadcast lag, or its - // candidates evicted from the 1024-entry ring before we read them. That - // made the original single-publish assertion flaky (it passes in - // isolation but fails deterministically in busy CI). Re-publish on every - // poll tick until *this* source's candidates land. The subscriber is - // idempotent per event and we filter by our unique `source_id`, so this - // only ever proves the subscriber *fires* — it can never mask a missing - // one (a never-registered subscriber yields 0 forever and still fails). - let mut got = 0; - for _ in 0..200 { - publish_email_doc(&source_id, &body); - tokio::time::sleep(Duration::from_millis(20)).await; - got = candidates_for(&source_id); - if got >= expected { - break; - } - } - assert!( - got >= expected, + bus.publish(email_doc(source_id, &body)); + let got = wait_for_candidates(buffer, expected).await; + assert_eq!( + got, expected, "email-signature subscriber must push the parsed identity candidates \ - with no channel configured anywhere (#5003); got {got}, expected >= {expected}" + with no channel configured anywhere (#5003)" ); } #[tokio::test] async fn register_learning_subscribers_is_idempotent() { - init_global(DEFAULT_CAPACITY); - let tmp = TempDir::new().expect("tempdir"); - let _ = crate::openhuman::memory::global::init(tmp.path().join("workspace")); - // Call twice — the process-wide OnceLock guards must keep exactly one - // email-signature subscriber alive, so a single event is handled once. - register_learning_subscribers(tmp.path().to_path_buf()); - register_learning_subscribers(tmp.path().to_path_buf()); + let bus = EventBus::create(16); + let buffer: &'static Buffer = Box::leak(Box::new(Buffer::new(16))); + let handle_cell = OnceLock::new(); + register_email_signature_once(&handle_cell, || { + Some(register_email_signature_subscriber_on(&bus, buffer)) + }); + register_email_signature_once(&handle_cell, || { + Some(register_email_signature_subscriber_on(&bus, buffer)) + }); - let source_id = unique_source_id("idem"); + let source_id = "gmail:5003-idem"; let body = signature_body(); - let expected = parse_signature(&body, &source_id, &source_id).len(); + let expected = parse_signature(&body, source_id, source_id).len(); assert!(expected > 0); - publish_email_doc(&source_id, &body); - let got = wait_for_candidates(&source_id, expected).await; + bus.publish(email_doc(source_id, &body)); + let got = wait_for_candidates(buffer, expected).await; assert_eq!( got, expected, "double registration must not double the pushed candidates (#5003 idempotency)" From fcbcebedda9c38f886eb71543f3ea6c027221c03 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:13:44 +0300 Subject: [PATCH 07/56] feat(tui): feature-gated terminal chat UI (openhuman tui / chat) (#5084) --- AGENTS.md | 12 + Cargo.lock | 908 ++++++++++++++++++++++-- Cargo.toml | 23 +- docs/TEST-COVERAGE-MATRIX.md | 12 + docs/plans/tui-chat-plan.md | 95 +++ scripts/ci/check-feature-forwarding.mjs | 1 + src/core/cli.rs | 15 +- src/core/cli_tests.rs | 52 ++ src/core/logging.rs | 81 +++ src/openhuman/about_app/catalog_data.rs | 17 + src/openhuman/mod.rs | 1 + src/openhuman/tui/app.rs | 240 +++++++ src/openhuman/tui/mod.rs | 52 ++ src/openhuman/tui/render.rs | 248 +++++++ src/openhuman/tui/runner.rs | 224 ++++++ src/openhuman/tui/state.rs | 491 +++++++++++++ src/openhuman/tui/stub.rs | 37 + src/openhuman/tui/terminal.rs | 83 +++ 18 files changed, 2524 insertions(+), 68 deletions(-) create mode 100644 docs/plans/tui-chat-plan.md create mode 100644 src/openhuman/tui/app.rs create mode 100644 src/openhuman/tui/mod.rs create mode 100644 src/openhuman/tui/render.rs create mode 100644 src/openhuman/tui/runner.rs create mode 100644 src/openhuman/tui/state.rs create mode 100644 src/openhuman/tui/stub.rs create mode 100644 src/openhuman/tui/terminal.rs diff --git a/AGENTS.md b/AGENTS.md index 0bb6a8daa6..a9e637719a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -243,6 +243,7 @@ GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml \ | `skills` | ON | `openhuman::skills` + `openhuman::skill_runtime` + `openhuman::skill_registry` domains — SKILL.md discovery/parse/install, workflow execution + run logs, remote catalogs, the `skill_setup` / `skill_executor` builtin agents, and the 16 skill agent tools | none (see below) | | `flows` | ON | `openhuman::flows` (saved automation graphs — create/run/schedule, the `workflow_builder` + `flow_discovery` agents), `openhuman::tinyflows` (engine seam), `openhuman::rhai_workflows` (`.ragsh` language-workflow tool) | `tinyflows`, `jaq-core`, `jaq-std`, `jaq-json`, `rhai` | | `mcp` | ON | `openhuman::mcp_server` (the `openhuman mcp` stdio/HTTP server), `openhuman::mcp_registry` (dynamic Smithery installs — `mcp_clients` RPC namespace, SQLite, boot spawn, supervisor, OAuth), `openhuman::mcp_audit` (write-audit log), and the static config-declared server set in `openhuman::mcp_client`. ~19 agent tools, ~20k LOC | **none** (see scope note) | +| `tui` | ON | `openhuman::tui` — the `openhuman tui` (alias `chat`) CLI subcommand: a ratatui/crossterm terminal chat UI onto the `web_chat` surface, running the core in-process. No controllers, no agent tools. **Intentionally NOT forwarded to the desktop shell** (allowlisted in `check-feature-forwarding.mjs`). | `ratatui`, `crossterm` | **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. @@ -306,6 +307,17 @@ Follows the voice facade+stub pattern for `mcp_server` / `mcp_registry` / `mcp_a `src/core/all.rs` needs **no** `#[cfg]` for this gate: the stub aggregators return empty vecs, so the registration sites keep compiling unchanged. +#### The `tui` gate + +The terminal chat UI (`openhuman tui` / alias `chat`) lives in `src/openhuman/tui/` and follows the **`mcp`/`voice` facade+stub** pattern: `pub mod tui;` is always compiled; the behavioural submodules (`app`, `render`, `state`, `terminal`, `runner`) are `#[cfg(feature = "tui")]`; and `#[cfg(not(feature = "tui"))] mod stub;` re-exposes the one symbol an always-compiled caller reaches — `run_from_cli` — with a build-fact error body (`"tui feature disabled at compile time … --features tui"`). + +- **The `"tui" | "chat"` CLI arm in `src/core/cli.rs` is un-`#[cfg]`'d on purpose.** In a slim build it resolves to `tui::stub::run_from_cli`, which bails with the disabled-error rather than falling through to `unknown namespace: tui` (which reads like a typo, not a build fact). Same reasoning as the `mcp` arm. Pinned by `tui_subcommand_reports_disabled_build_when_gate_off` / `chat_alias_reports_disabled_build_when_gate_off` in `src/core/cli_tests.rs` (both `#[cfg(not(feature = "tui"))]`). `"tui" | "chat"` is also added to the banner-suppression `matches!` (a TUI owns the terminal — a banner would corrupt it). +- **No controllers, no agent tools, no `all.rs` changes.** The TUI is a pure *client* of the existing `web_chat` surface — it boots the core in-process (`CoreBuilder::new(HostKind::Cli).domains(DomainSet::full()).services(ServiceSet::none())`), sends turns via `runtime.invoke("openhuman.channel_web_chat", …)`, and streams by draining `web_chat::subscribe_web_channel_events()` filtered by its own `client_id`. `openhuman.channel_web_chat` needs `DomainGroup::Channels`, so `DomainSet::full()` (not `harness()`) is required. +- **Terminal hygiene is load-bearing.** `logging::init_for_tui` installs a **file-only** subscriber (never stderr) — a single core boot log on stdout/stderr would corrupt the alternate-screen UI. `terminal::TerminalGuard` restores raw mode + the main screen on `Drop`, and a panic hook chains a restore ahead of the default hook. All `[tui]` state-transition logs go to the file, never `println!`. +- **Intentionally NOT forwarded to the desktop shell** (the app ships its own Tauri UI). It carries the only current entry in `INTENTIONALLY_NOT_FORWARDED` in `scripts/ci/check-feature-forwarding.mjs`; the pure reducer lives in `src/openhuman/tui/state.rs` (`TranscriptState::apply_event`) with unit tests, so most behaviour is testable without a terminal. + +Drops the exclusive `ratatui` + `crossterm` deps when off. Verify with `cargo tree -i ratatui --no-default-features --features tokenjuice-treesitter` (must return nothing). + ### Event bus (`src/core/event_bus/`) Typed pub/sub + native request/response. Both singletons — use module-level functions. diff --git a/Cargo.lock b/Cargo.lock index 4211992566..704cdfef68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,6 +84,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "alsa" version = "0.9.1" @@ -91,7 +97,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" dependencies = [ "alsa-sys", - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "libc", ] @@ -171,6 +177,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -314,6 +329,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -500,7 +524,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cexpr", "clang-sys", "itertools 0.13.0", @@ -514,15 +538,30 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit-vec" version = "0.8.0" @@ -585,9 +624,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bitvec" @@ -688,6 +727,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "byte-slice-cast" version = "1.2.3" @@ -747,6 +792,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cbc" version = "0.1.2" @@ -1044,6 +1098,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "compression-codecs" version = "0.4.38" @@ -1152,6 +1220,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.16.2" @@ -1263,7 +1340,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-graphics-types", "foreign-types 0.5.0", @@ -1276,7 +1353,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.10.1", "libc", ] @@ -1366,6 +1443,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "cron" version = "0.12.1" @@ -1392,6 +1475,33 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more 2.1.1", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -1430,6 +1540,16 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf 0.11.3", +] + [[package]] name = "ctr" version = "0.9.2" @@ -1475,6 +1595,40 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + [[package]] name = "dasp_sample" version = "0.11.0" @@ -1515,6 +1669,12 @@ dependencies = [ "uuid 1.23.1", ] +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + [[package]] name = "der" version = "0.7.10" @@ -1580,6 +1740,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case", "proc-macro2", "quote", "rustc_version", @@ -1680,7 +1841,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", ] @@ -2042,7 +2203,7 @@ dependencies = [ "rlp", "serde", "serde_json", - "strum", + "strum 0.26.3", "tempfile", "thiserror 1.0.69", "tiny-keccak", @@ -2077,6 +2238,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "2.5.3" @@ -2122,13 +2292,23 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set 0.5.3", + "regex", +] + [[package]] name = "fancy-regex" version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" dependencies = [ - "bit-set", + "bit-set 0.8.0", "regex-automata", "regex-syntax", ] @@ -2155,6 +2335,12 @@ dependencies = [ "webdriver", ] +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + [[package]] name = "fastrand" version = "2.4.1" @@ -2192,6 +2378,17 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.29" @@ -2220,6 +2417,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + [[package]] name = "fixed-hash" version = "0.8.0" @@ -2232,6 +2435,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -2555,7 +2764,7 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "libc", "libgit2-sys", "log", @@ -2623,6 +2832,8 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", ] @@ -2631,6 +2842,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashify" @@ -3068,6 +3284,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -3166,6 +3388,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inout" version = "0.1.4" @@ -3176,6 +3407,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -3381,6 +3625,17 @@ dependencies = [ "signature", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + [[package]] name = "keccak" version = "0.1.6" @@ -3420,6 +3675,12 @@ version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + [[package]] name = "landlock" version = "0.4.4" @@ -3538,13 +3799,22 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "linux-keyutils" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "libc", ] @@ -3594,7 +3864,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7184fdea2bc3cd272a1acec4030c321a8f9875e877b3f92a53f2f6033fdc289" dependencies = [ "aes", - "bitflags 2.11.1", + "bitflags 2.13.1", "cbc", "ecb", "encoding_rs", @@ -3615,6 +3885,15 @@ dependencies = [ "weezl", ] +[[package]] +name = "lru" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -3632,6 +3911,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "winapi", +] + [[package]] name = "mach2" version = "0.4.3" @@ -3724,6 +4013,21 @@ dependencies = [ "libc", ] +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -3763,6 +4067,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -3822,7 +4127,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "jni-sys 0.3.1", "log", "ndk-sys", @@ -3845,13 +4150,26 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nix" version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -4001,6 +4319,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc" version = "0.2.7" @@ -4041,7 +4368,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -4057,7 +4384,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -4069,7 +4396,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -4091,7 +4418,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -4113,7 +4440,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.4", ] @@ -4124,7 +4451,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.4", "objc2-core-foundation", @@ -4169,7 +4496,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", @@ -4187,7 +4514,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -4199,7 +4526,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -4212,7 +4539,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", ] @@ -4223,7 +4550,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -4235,7 +4562,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -4248,7 +4575,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -4269,7 +4596,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "objc2 0.6.4", "objc2-cloud-kit", @@ -4398,6 +4725,7 @@ dependencies = [ "console", "cpal", "cron", + "crossterm", "curve25519-dalek", "dialoguer", "directories", @@ -4447,6 +4775,7 @@ dependencies = [ "proptest", "prost", "rand 0.10.1", + "ratatui", "rdev", "regex", "reqwest 0.12.28", @@ -4518,7 +4847,7 @@ version = "0.10.79" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "foreign-types 0.3.2", "libc", @@ -4630,6 +4959,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "os_info" version = "3.14.0" @@ -4638,7 +4976,7 @@ checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224" dependencies = [ "android_system_properties", "log", - "nix", + "nix 0.30.1", "objc2 0.6.4", "objc2-foundation 0.3.2", "objc2-ui-kit", @@ -4653,23 +4991,47 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" [[package]] -name = "parity-scale-codec" -version = "3.7.5" +name = "palette" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" dependencies = [ - "arrayvec", - "bitvec", - "byte-slice-cast", - "const_format", - "impl-trait-for-tuples", - "parity-scale-codec-derive", - "rustversion", - "serde", + "approx", + "fast-srgb8", + "libm", + "palette_derive", ] [[package]] -name = "parity-scale-codec-derive" +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "parity-scale-codec" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +dependencies = [ + "arrayvec", + "bitvec", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "parity-scale-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "parity-scale-codec-derive" version = "3.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" @@ -4762,7 +5124,7 @@ dependencies = [ "adobe-cmap-parser", "cff-parser", "encoding_rs", - "euclid", + "euclid 0.20.14", "log", "lopdf", "postscript", @@ -4776,17 +5138,69 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +dependencies = [ + "pest", +] + [[package]] name = "petgraph" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ - "fixedbitset", + "fixedbitset 0.5.7", "hashbrown 0.15.5", "indexmap", ] +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.12.1" @@ -4806,16 +5220,36 @@ dependencies = [ "serde", ] +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + [[package]] name = "phf_codegen" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator", + "phf_generator 0.13.1", "phf_shared 0.13.1", ] +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.6", +] + [[package]] name = "phf_generator" version = "0.13.1" @@ -4826,6 +5260,28 @@ dependencies = [ "phf_shared 0.13.1", ] +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.12.1" @@ -4911,7 +5367,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", @@ -5126,9 +5582,9 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ - "bit-set", - "bit-vec", - "bitflags 2.11.1", + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.13.1", "num-traits", "rand 0.9.4", "rand_chacha 0.9.0", @@ -5194,7 +5650,7 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76979bea66e7875e7509c4ec5300112b316af87fa7a252ca91c448b32dfe3993" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "getopts", "memchr", "pulldown-cmark-escape", @@ -5424,6 +5880,107 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.1", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools 0.14.0", + "kasuari", + "lru", + "palette", + "serde", + "strum 0.28.0", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "serde", + "strum 0.28.0", + "time", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "rdev" version = "0.5.3" @@ -5446,7 +6003,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -5633,7 +6190,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd4dd0f8c36625202a4ba553c416c19b719947cd2a31d1bda06126e4a5727daf" dependencies = [ "ahash", - "bitflags 2.11.1", + "bitflags 2.13.1", "no-std-compat", "num-traits", "once_cell", @@ -5725,7 +6282,7 @@ version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b3492ea85308705c3a5cc24fb9b9cf77273d30590349070db42991202b214c4" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "fallible-iterator 0.3.0", "fallible-streaming-iterator", "hashlink", @@ -5767,7 +6324,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -6009,7 +6566,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.9.4", "core-foundation-sys 0.8.7", "libc", @@ -6022,7 +6579,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys 0.8.7", "libc", @@ -6135,7 +6692,7 @@ version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27701acc51e68db5281802b709010395bfcbcb128b1d0a4e5873680d3b47ff0c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "sentry-backtrace", "sentry-core", "tracing-core", @@ -6361,6 +6918,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -6533,7 +7111,7 @@ dependencies = [ "lazycell", "libc", "mach2 0.5.0", - "nix", + "nix 0.30.1", "num-traits", "plist", "uom", @@ -6587,7 +7165,16 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -6603,6 +7190,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -6664,7 +7263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" dependencies = [ "bincode", - "fancy-regex", + "fancy-regex 0.16.2", "flate2", "fnv", "once_cell", @@ -6694,7 +7293,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -6739,6 +7338,82 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom 7.1.3", + "phf 0.11.3", + "phf_codegen 0.11.3", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bitflags 2.13.1", + "fancy-regex 0.11.0", + "filedescriptor", + "finl_unicode", + "fixedbitset 0.4.2", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf 0.11.3", + "sha2 0.10.9", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + [[package]] name = "thin-vec" version = "0.2.18" @@ -6816,7 +7491,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -7325,7 +8002,7 @@ version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "bytes", "futures-util", "http 1.4.0", @@ -7563,6 +8240,12 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uiautomation" version = "0.25.0" @@ -7652,6 +8335,17 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools 0.14.0", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "unicode-width" version = "0.2.2" @@ -7796,6 +8490,7 @@ version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ + "atomic", "getrandom 0.4.2", "js-sys", "serde_core", @@ -7820,6 +8515,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "wacore" version = "0.5.0" @@ -7893,7 +8597,7 @@ checksum = "127a3a6e7554ce002092f1711aa927b0efa3d3fb1ee83506525565c626e68834" dependencies = [ "flate2", "phf 0.13.1", - "phf_codegen", + "phf_codegen 0.13.1", "serde", "serde_json", ] @@ -8139,7 +8843,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -8218,6 +8922,78 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2 0.10.9", + "thiserror 1.0.69", + "uuid 1.23.1", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid 0.22.14", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "whatsapp-rust" version = "0.5.0" @@ -8998,7 +9774,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.13.1", "indexmap", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index 8ac6962ac7..aacb8d574b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -274,6 +274,14 @@ pdf-extract = "0.10" # stack) now lives in the tinychannels crate; the `whatsapp-web` feature forwards # to `tinychannels/whatsapp-web`. ppt-rs = "0.2.14" +# Terminal chat UI (`openhuman tui` / `chat`). Exclusive to the default-ON +# `tui` feature (see `[features]` below): a slim / headless build without `tui` +# drops both `ratatui` and `crossterm`. Kept in lockstep — ratatui 0.30 +# re-exports crossterm 0.29, so declaring crossterm directly at the same major +# unifies to a single crossterm in the dep graph. Only compiled into +# `src/openhuman/tui/` behind `#[cfg(feature = "tui")]`. +ratatui = { version = "0.30", optional = true } +crossterm = { version = "0.29", optional = true } # Native-Rust `.docx` writer for the `generate_document` tool (GH #4847). # Pure Rust (no subprocess / managed runtime), MIT-licensed, actively # maintained (2.8M downloads, last release 0.4.20 — Apr 2026). Mirrors the @@ -347,7 +355,7 @@ tokio = { version = "1", features = ["test-util"] } proptest = "1" [features] -default = ["tokenjuice-treesitter", "voice", "web3", "media", "meet", "skills", "flows", "mcp"] +default = ["tokenjuice-treesitter", "voice", "web3", "media", "meet", "skills", "flows", "mcp", "tui"] # 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 = [ @@ -458,6 +466,19 @@ skills = [] # `sanitize::sanitize_for_llm`). The gate follows the real dependency graph, # not the directory name. mcp = [] +# Terminal chat UI: the `openhuman tui` (alias `chat`) CLI subcommand, a +# ratatui/crossterm terminal front-end onto the same `web_chat` surface the +# desktop app drives. Default-ON for the standalone `openhuman-core` binary, but +# INTENTIONALLY NOT forwarded to the desktop shell (the desktop app ships its own +# Tauri UI and never needs a terminal one) — see the allowlist entry in +# `scripts/ci/check-feature-forwarding.mjs`. Slim / headless builds opt out via +# `--no-default-features --features ""`, which drops +# the exclusive `ratatui` + `crossterm` dependencies. The module +# `src/openhuman/tui/` is a facade (always compiled) whose behavioural submodules +# are `#[cfg(feature = "tui")]`; when off, `tui::stub::run_from_cli` returns a +# build-fact "tui feature disabled at compile time" error from the untouched +# `"tui" | "chat"` CLI arm (mirrors the `mcp` stub pattern). +tui = ["dep:ratatui", "dep:crossterm"] sandbox-landlock = ["dep:landlock"] sandbox-bubblewrap = [] peripheral-rpi = ["dep:rppal"] diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index a579f09f68..d68f663fa9 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -599,6 +599,18 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an --- +## 14. Terminal Chat UI (`openhuman tui`) + +### 14.1 TUI Subcommand (feature-gated `tui`) + +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ------ | ------------------------------------------------- | ----- | ----------------------------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------- | +| 14.1.1 | Transcript reducer (deltas, done, error, tools) | RU | `src/openhuman/tui/state.rs` | ✅ | Pure `TranscriptState::apply_event` — client-id filtering, text/thinking split, `chat_done` finalization | +| 14.1.2 | Runner flags + thread resolution + RPC name pins | RU | `src/openhuman/tui/runner.rs`, `src/openhuman/tui/app.rs` | ✅ | `--thread`/`--new` parsing; canonical `openhuman.*` method names resolve via registry | +| 14.1.3 | Render layout (viewport, input, status) | RU | `src/openhuman/tui/render.rs` | ✅ | ratatui TestBackend | +| 14.1.4 | Disabled-build stub (`--no-default-features`) | RU | `src/core/cli_tests.rs` (`tui`/`chat` `*_reports_disabled_build_when_gate_off`) | ✅ | Build-fact error, not `unknown namespace` | +| 14.1.5 | Interactive terminal session (raw mode, streaming) | MS | manual smoke | 🚫 | Needs a real TTY; not driver-automatable | + ## Summary | Status | Count | diff --git a/docs/plans/tui-chat-plan.md b/docs/plans/tui-chat-plan.md new file mode 100644 index 0000000000..76c71356c1 --- /dev/null +++ b/docs/plans/tui-chat-plan.md @@ -0,0 +1,95 @@ +# Plan: `openhuman tui` — feature-gated terminal chat UI + +## Goal + +Running `openhuman-core tui` (alias `chat`) opens a ratatui-based terminal UI that is an +interface into the general chat (the same `web_chat` surface the desktop app uses), +gated behind a Cargo feature `tui`. + +## Architecture decisions (grounded in current code) + +1. **Cargo feature**: `tui = ["dep:ratatui", "dep:crossterm"]`, added to `default` + (repo convention: gates default-ON). It must NOT ship in the desktop app — + add `'tui': 'Terminal UI subcommand; the desktop app ships its own Tauri UI.'` + to `INTENTIONALLY_NOT_FORWARDED` in `scripts/ci/check-feature-forwarding.mjs`. +2. **Module**: new domain dir `src/openhuman/tui/` using the **mcp/voice facade pattern**: + - `mod.rs` always compiled; real submodules `#[cfg(feature = "tui")]`; + `#[cfg(not(feature = "tui"))] mod stub;` exposing the same `run_from_cli`. + - `stub.rs` `run_from_cli` bails with + `"tui feature disabled at compile time … rebuild with --features tui"` + (mirror `src/openhuman/mcp_server/stub.rs:42`). + - No controllers, no agent tools, no `all.rs` changes (leaf client, like `flows`' + philosophy: absence, not degraded registration — but here the only outside + touch-point is the CLI arm, which uses the stub for a build-fact error). +3. **CLI arm**: in `src/core/cli.rs` match (~line 63), add + `"tui" | "chat" => crate::openhuman::tui::run_from_cli(&args[1..])`. + Arm stays **un-cfg'd** (mcp precedent). Add `"tui" | "chat"` to the banner-suppression + `matches!` at lines 48–50 (a TUI must own the terminal). +4. **In-process core, no HTTP**: build a multi-thread tokio runtime with + `AGENT_WORKER_STACK_BYTES` stack (copy `run_server_command` shape, cli.rs:219–311), + then `CoreBuilder::new(HostKind::Cli).domains(DomainSet::full()).services(ServiceSet::none()).build()`. + `channel.web_chat` needs `DomainGroup::Channels`, so `harness()` is not enough. +5. **Chat flow**: + - `client_id = "tui-"`. + - Threads via `runtime.invoke("threads.list"| "threads.create_new", …)`; + CLI flags: `--thread `, `--new` (default: create new thread). + - Send turn: `runtime.invoke("channel.web_chat", {client_id, thread_id, message, …})` + (schema: `src/openhuman/web_chat/schemas.rs:45`; ops entry `ops.rs:1199`/`start_chat` at 391). + - Stream: drain `web_chat::subscribe_web_channel_events()` (broadcast bus, + `src/openhuman/web_chat/event_bus.rs:14`), filter by our `client_id`. + Render `text_delta`/`thinking_delta` (`delta`, `delta_kind` fields on + `WebChannelEvent`, `src/core/socketio.rs:98`), show `tool_call`/`tool_result` + as status lines, finish on `chat_done` (use `full_response` as authoritative + final text) or `chat_error` (show `message`). + - Cancel in-flight turn: `channel.web_cancel` on Esc. +6. **UI v1 scope** (keep it tight): + - Alternate screen + raw mode; transcript viewport with scrollback + (PgUp/PgDn/mouse optional), single-line input box, status bar + (thread id, model/turn state, key hints), spinner while streaming. + - Keys: Enter=send, Esc=cancel turn, Ctrl+N=new thread, Ctrl+C/Ctrl+D=quit. + - Distinguish user / assistant / thinking (dim) / tool activity / errors. + Ocean-ish accent per design tokens is fine but keep it terminal-native. +7. **Terminal hygiene (critical)**: + - Panic hook + Drop guard that restores the terminal (leave raw mode, + LeaveAlternateScreen) before the panic message prints. + - **Logging must not hit stdout/stderr while the TUI owns the terminal** — + inspect how `run_from_cli`/`run_server_command` init logging + (`src/core/logging.rs`) and route core logs to file only (or suppress console) + for the tui arm. Core boot logs corrupting the UI is a bug. +8. **Separation for testability**: pure state module (`transcript.rs` or `state.rs`) + holding a reducer `apply_event(&mut TranscriptState, &WebChannelEvent)` with no + terminal deps — unit-testable. Rendering (`render.rs`) and event loop (`app.rs`) + stay thin. + +## Tests / verification (definition of done) + +- Unit tests for the reducer: text_delta accumulation, thinking vs text separation, + chat_done replaces with full_response, chat_error, ignores other client_ids, + tool_call/result lines. +- `src/core/cli_tests.rs`: `tui_subcommand_reports_disabled_build_when_gate_off` + (+ `chat` alias) under `#[cfg(not(feature = "tui"))]`, mirroring the mcp tests + (assert error contains "tui feature disabled" and NOT "unknown namespace"). +- Builds (Apple Silicon: prefix `GGML_NATIVE=OFF`): + - `cargo check --manifest-path Cargo.toml` + - `cargo check --no-default-features --features tokenjuice-treesitter` (disabled build) + - `cargo test --lib core::cli` and the tui module tests, both feature directions: + `cargo test --lib --no-default-features --features tokenjuice-treesitter core::` +- `node scripts/ci/check-feature-forwarding.mjs` passes with the allowlist entry. +- `cargo fmt` clean. + +## Docs + +- AGENTS.md: add `tui` row to the feature table + a short gate section + (leaf-ish gate, sheds `ratatui`+`crossterm`, intentionally not forwarded to desktop). +- `src/openhuman/about_app/`: add user-facing feature entry for the terminal chat UI. + +## Non-goals (v1) + +- No thread-picker UI beyond `--thread/--new` flags, no markdown rendering, + no image/artifact display, no approval-request interaction (surface as a status + line telling the user to handle it elsewhere), no remote-core (HTTP) mode. + +## Workflow + +Small focused commits on `feat/tui-chat` in this worktree; don't push. +Debug logging with `[tui]` prefix on all state transitions. diff --git a/scripts/ci/check-feature-forwarding.mjs b/scripts/ci/check-feature-forwarding.mjs index 76844cc4fc..a346f4c7dd 100644 --- a/scripts/ci/check-feature-forwarding.mjs +++ b/scripts/ci/check-feature-forwarding.mjs @@ -32,6 +32,7 @@ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); */ const INTENTIONALLY_NOT_FORWARDED = { // 'some-gate': 'Reason it must not ship in the desktop build.', + tui: 'Terminal UI subcommand (openhuman tui/chat); the desktop app ships its own Tauri UI and never runs the ratatui terminal front-end.', }; function usage() { diff --git a/src/core/cli.rs b/src/core/cli.rs index 2d6b55be97..87e67be5c4 100644 --- a/src/core/cli.rs +++ b/src/core/cli.rs @@ -45,7 +45,14 @@ Contribute & Star us on GitHub: https://github.com/tinyhumansai/openhuman /// the subcommand/namespace is unknown. pub fn run_from_cli_args(args: &[String]) -> Result<()> { // Print the welcome banner to stderr to keep stdout clean for JSON output. - if !matches!(args.first().map(String::as_str), Some("mcp" | "mcp-server")) { + // `mcp`/`mcp-server` speak JSON-RPC on stdout; `tui`/`chat` own the whole + // terminal (alternate screen + raw mode) — a banner on either would corrupt + // the stream / the UI, so both suppress it. The `matches!` is on the raw + // string, so it stays valid even when the `tui` feature is compiled out. + if !matches!( + args.first().map(String::as_str), + Some("mcp" | "mcp-server" | "tui" | "chat") + ) { eprint!("{CLI_BANNER}"); } @@ -61,6 +68,11 @@ pub fn run_from_cli_args(args: &[String]) -> Result<()> { match args[0].as_str() { "run" | "serve" => run_server_command(&args[1..]), "mcp" | "mcp-server" => crate::openhuman::mcp_server::run_stdio_from_cli(&args[1..]), + // Terminal chat UI. Un-`#[cfg]`'d on purpose: in a slim build this + // resolves to `tui::stub::run_from_cli`, which bails with a build-fact + // error (see `src/openhuman/tui/stub.rs`) rather than falling through to + // `unknown namespace: tui`. + "tui" | "chat" => crate::openhuman::tui::run_from_cli(&args[1..]), "call" => run_call_command(&args[1..]), // Domain-specific CLI adapters that don't follow the generic namespace pattern. "screen-intelligence" => { @@ -561,6 +573,7 @@ fn print_general_help(grouped: &BTreeMap>) { println!( " openhuman mcp [-v|--verbose] (stdio MCP server; read-only memory tools)" ); + println!(" openhuman tui [--thread |--new] (terminal chat UI, alias: chat)"); println!(" openhuman skills [options] (skill development runtime)"); println!(" openhuman agent [options] (inspect agent definitions & prompts)"); println!(" openhuman voice [--hotkey ] [--mode ] (voice dictation server)"); diff --git a/src/core/cli_tests.rs b/src/core/cli_tests.rs index adf6d7c302..2b8f94d63e 100644 --- a/src/core/cli_tests.rs +++ b/src/core/cli_tests.rs @@ -195,3 +195,55 @@ fn mcp_server_alias_reports_disabled_build_when_gate_off() { "the `mcp-server` alias must give the same build-fact diagnostic as `mcp`" ); } + +// --- `tui` compile-time gate -------------------------------------------- + +/// With the `tui` feature compiled out, `openhuman tui` must fail with a +/// diagnostic that names the BUILD as the cause — not a generic +/// "unknown namespace" error. +/// +/// Same reasoning as the `mcp` gate test above: the naive way to gate the CLI +/// is to delete the `"tui" | "chat"` match arm, which is WRONG — `tui` would +/// fall through to generic namespace resolution and die with `unknown +/// namespace: tui`, reading like a user typo. Instead `cli.rs` is untouched and +/// the arm resolves to `tui::stub::run_from_cli`, which bails with the message +/// asserted below. +#[test] +#[cfg(not(feature = "tui"))] +fn tui_subcommand_reports_disabled_build_when_gate_off() { + let _guard = env_lock(); + + let err = crate::core::cli::run_from_cli_args(&["tui".to_string()]) + .expect_err("`openhuman tui` must fail when the `tui` feature is compiled out"); + let msg = err.to_string(); + + assert!( + msg.contains("tui feature disabled"), + "error must name the compile-time gate as the cause; got: {msg}" + ); + assert!( + msg.contains("--features tui"), + "error must tell the user how to get a working build; got: {msg}" + ); + assert!( + !msg.contains("unknown namespace"), + "must NOT degrade into generic namespace resolution — that reads like a typo, \ + not a build fact; got: {msg}" + ); +} + +/// The `chat` alias must behave identically to `tui` — both arms route to the +/// same stub, so neither can silently regress into the fall-through. +#[test] +#[cfg(not(feature = "tui"))] +fn chat_alias_reports_disabled_build_when_gate_off() { + let _guard = env_lock(); + + let err = crate::core::cli::run_from_cli_args(&["chat".to_string()]) + .expect_err("`openhuman chat` must fail when the `tui` feature is compiled out"); + + assert!( + err.to_string().contains("tui feature disabled"), + "the `chat` alias must give the same build-fact diagnostic as `tui`" + ); +} diff --git a/src/core/logging.rs b/src/core/logging.rs index 323caaefe3..639d247aa7 100644 --- a/src/core/logging.rs +++ b/src/core/logging.rs @@ -319,6 +319,87 @@ pub fn init_for_embedded(data_dir: &Path, verbose: bool) { }); } +/// Initialize logging for the terminal chat UI (`openhuman tui` / `chat`). +/// +/// **File-only, never stderr.** The TUI owns the whole terminal (alternate +/// screen + raw mode); a single `tracing`/`log` line written to stdout or +/// stderr would corrupt the rendered UI. So — unlike [`init_for_cli_run`] +/// (stderr) and [`init_for_embedded`] (stderr + file) — this installs **only** +/// a daily-rotated file appender at `/logs/openhuman-YYYY-MM-DD.log` +/// plus the Sentry layer (which keeps no console handle). Core boot logs and +/// the `[tui]` state-transition logs land in that file for post-mortem +/// debugging without ever touching the screen. +/// +/// Idempotent (`Once`-guarded, shared with the other init entry points). If a +/// subscriber was somehow already installed, this is a no-op and logging keeps +/// whatever destination the first caller chose — still never stderr from *this* +/// path. Returns the resolved log directory on success (for a status line), or +/// `None` when the file appender could not be created. +pub fn init_for_tui(data_dir: &Path, verbose: bool) -> Option { + INIT.call_once(|| { + let scope = CliLogDefault::Global; + seed_rust_log(verbose, scope); + let filter = build_env_filter(verbose, scope); + + let logs_dir = data_dir.join("logs"); + let pending_file: Option<(_, tracing_appender::non_blocking::WorkerGuard, PathBuf)> = + match std::fs::create_dir_all(&logs_dir) { + Ok(()) => match tracing_appender::rolling::Builder::new() + .rotation(tracing_appender::rolling::Rotation::DAILY) + .filename_prefix("openhuman") + .filename_suffix("log") + .max_log_files(7) + .build(&logs_dir) + { + Ok(appender) => { + let (writer, guard) = tracing_appender::non_blocking(appender); + Some((writer, guard, logs_dir.clone())) + } + Err(err) => { + // No tracing subscriber yet, but we deliberately do NOT + // eprintln! here (the TUI is about to take the terminal). + // Losing this one diagnostic is the correct trade. + let _ = err; + None + } + }, + Err(_) => None, + }; + + let file_layer = pending_file.as_ref().map(|(writer, _, _)| { + let constraints = parse_log_file_constraints(); + tracing_subscriber::fmt::layer() + .with_ansi(false) + .event_format(CleanCliFormat) + .with_writer(writer.clone()) + .with_filter(tracing_subscriber::filter::filter_fn(move |meta| { + event_matches_file_constraints(meta, &constraints) + })) + }); + + // NOTE: no stderr layer here — that is the whole point of this entry + // point. Only the file layer + Sentry are attached. + if tracing_subscriber::registry() + .with(filter) + .with(file_layer) + .with(sentry_tracing_layer()) + .try_init() + .is_ok() + { + if let Some((_, guard, dir)) = pending_file { + if let Ok(mut slot) = FILE_GUARD.lock() { + *slot = Some(guard); + } + let _ = LOG_DIR.set(dir); + } + } + + let _ = tracing_log::LogTracer::init(); + }); + + log_directory().map(Path::to_path_buf) +} + /// Path to the active log directory (set by [`init_for_embedded`]). Returns /// `None` if logging hasn't been initialized in embedded mode (e.g. bare /// CLI runs). diff --git a/src/openhuman/about_app/catalog_data.rs b/src/openhuman/about_app/catalog_data.rs index c8f53db1d5..8e48a33347 100644 --- a/src/openhuman/about_app/catalog_data.rs +++ b/src/openhuman/about_app/catalog_data.rs @@ -227,6 +227,23 @@ pub(super) const CAPABILITIES: &[Capability] = &[ status: CapabilityStatus::Stable, privacy: None, }, + Capability { + id: "conversation.terminal_chat", + name: "Terminal Chat (TUI)", + domain: "tui", + category: CapabilityCategory::Conversation, + description: "Chat with the assistant from a terminal instead of the desktop UI. \ + `openhuman tui` (alias `chat`) opens a ratatui full-screen chat onto the \ + same conversation surface the app uses, running the core in-process. \ + Streams replies, thinking, and tool activity live; supports scrollback, \ + cancelling a turn, and starting a new thread. Ships only in the standalone \ + `openhuman-core` binary (the desktop app has its own UI).", + how_to: "Run `openhuman tui` (or `openhuman chat`) from a terminal. Flags: \ + `--thread ` to resume a thread, `--new` for a fresh one. Keys: Enter send, \ + Esc cancel, Ctrl+N new thread, PgUp/PgDn scroll, Ctrl+C quit.", + status: CapabilityStatus::Beta, + privacy: DERIVED_TO_BACKEND, + }, Capability { id: "conversation.suggested_questions", name: "Suggested Questions", diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 8509fcabba..dc26574017 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -142,6 +142,7 @@ pub mod tool_registry; pub mod tool_status; pub mod tool_timeout; pub mod tools; +pub mod tui; pub mod update; pub mod util; pub mod voice; diff --git a/src/openhuman/tui/app.rs b/src/openhuman/tui/app.rs new file mode 100644 index 0000000000..a1c944d5a5 --- /dev/null +++ b/src/openhuman/tui/app.rs @@ -0,0 +1,240 @@ +//! Terminal chat event loop. +//! +//! Bridges three async sources over `tokio::select!`: +//! * **keyboard** — a blocking crossterm reader thread forwards `Event`s over +//! an mpsc channel (crossterm's own async `EventStream` needs the +//! `event-stream` feature; the poll+forward thread keeps the dep surface +//! minimal and exits promptly via the shared `shutdown` flag), +//! * **web-channel broadcast** — the same `web_chat` event stream the desktop +//! app consumes, folded into [`TranscriptState`] by its reducer, +//! * **a spinner ticker** — animates the streaming indicator. +//! +//! All state transitions are logged with the `[tui]` prefix to the file-only +//! subscriber (see `logging::init_for_tui`); nothing is ever `println!`'d. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use serde_json::json; +use tokio::sync::broadcast; + +use crate::core::runtime::CoreRuntime; +use crate::core::socketio::WebChannelEvent; +use crate::openhuman::web_chat; + +use super::render::{self, UiState}; +use super::state::TranscriptState; +use super::terminal::TerminalGuard; + +/// Run the terminal chat loop until the user quits (Ctrl+C / Ctrl+D) or the +/// web-channel bus closes. The [`TerminalGuard`] restores the terminal on every +/// exit path, including panics. +pub async fn run( + runtime: Arc, + client_id: String, + thread_id: String, + mut web_rx: broadcast::Receiver, +) -> anyhow::Result<()> { + let mut guard = TerminalGuard::enter()?; + + let mut state = TranscriptState::new(client_id.clone()); + state.push_system(format!( + "Connected · thread {thread_id}. Type a message and press Enter. Ctrl+C to quit." + )); + let mut ui = UiState::new(thread_id, client_id.clone()); + + // Blocking crossterm reader → async channel. + let (input_tx, mut input_rx) = tokio::sync::mpsc::unbounded_channel::(); + let shutdown = Arc::new(AtomicBool::new(false)); + let reader_shutdown = shutdown.clone(); + let reader = std::thread::spawn(move || { + while !reader_shutdown.load(Ordering::Relaxed) { + match event::poll(Duration::from_millis(100)) { + Ok(true) => match event::read() { + Ok(ev) => { + if input_tx.send(ev).is_err() { + break; + } + } + Err(_) => break, + }, + Ok(false) => {} + Err(_) => break, + } + } + }); + + let mut ticker = tokio::time::interval(Duration::from_millis(120)); + let mut quit = false; + + while !quit { + guard.terminal().draw(|f| render::draw(f, &state, &ui))?; + + tokio::select! { + maybe_ev = input_rx.recv() => match maybe_ev { + Some(Event::Key(key)) => { + if handle_key(key, &runtime, &client_id, &mut state, &mut ui).await { + quit = true; + } + } + Some(_) => {} // resize / mouse / paste — redraw next iteration + None => quit = true, // reader thread gone + }, + recv = web_rx.recv() => match recv { + Ok(ev) => state.apply_event(&ev), + Err(broadcast::error::RecvError::Lagged(n)) => { + log::warn!("[tui] web-channel lagged, dropped {n} events"); + } + Err(broadcast::error::RecvError::Closed) => { + log::warn!("[tui] web-channel closed — exiting"); + quit = true; + } + }, + _ = ticker.tick() => { + ui.spinner_tick = ui.spinner_tick.wrapping_add(1); + } + } + } + + shutdown.store(true, Ordering::Relaxed); + let _ = reader.join(); + log::info!("[tui] event loop exited"); + Ok(()) +} + +/// Handle a key event. Returns `true` when the app should quit. +async fn handle_key( + key: KeyEvent, + runtime: &Arc, + client_id: &str, + state: &mut TranscriptState, + ui: &mut UiState, +) -> bool { + // Ignore key-release events (Windows / kitty report both edges). + if key.kind == KeyEventKind::Release { + return false; + } + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + + match key.code { + KeyCode::Char('c') if ctrl => { + log::info!("[tui] quit via Ctrl+C"); + return true; + } + KeyCode::Char('d') if ctrl => { + log::info!("[tui] quit via Ctrl+D"); + return true; + } + KeyCode::Char('n') if ctrl => new_thread(runtime, state, ui).await, + KeyCode::Esc => cancel_turn(runtime, client_id, &ui.thread_id, state), + KeyCode::PageUp => { + ui.scroll_from_bottom = ui.scroll_from_bottom.saturating_add(5); + } + KeyCode::PageDown => { + ui.scroll_from_bottom = ui.scroll_from_bottom.saturating_sub(5); + } + KeyCode::Enter => send_message(runtime, client_id, state, ui), + KeyCode::Backspace => { + ui.input.pop(); + } + KeyCode::Char(c) if !ctrl => ui.input.push(c), + _ => {} + } + false +} + +/// Queue a chat turn on the current thread. Fire-and-forget: the reply streams +/// back over the web-channel bus and is folded in by the reducer. +fn send_message( + runtime: &Arc, + client_id: &str, + state: &mut TranscriptState, + ui: &mut UiState, +) { + let message = ui.input.trim().to_string(); + if message.is_empty() { + return; + } + ui.input.clear(); + ui.scroll_from_bottom = 0; + state.begin_user_turn(&message); + log::info!( + "[tui] send message len={} thread={}", + message.len(), + ui.thread_id + ); + + let rt = runtime.clone(); + let cid = client_id.to_string(); + let tid = ui.thread_id.clone(); + tokio::spawn(async move { + let params = json!({ + "client_id": cid, + "thread_id": tid, + "message": message, + "source": "tui", + }); + if let Err(e) = rt.invoke("openhuman.channel_web_chat", params).await { + log::error!("[tui] openhuman.channel_web_chat failed: {e}"); + // Surface the failure in-transcript via a synthetic chat_error so + // the reducer clears the streaming state and shows the reason. + web_chat::publish_web_channel_event(WebChannelEvent { + event: "chat_error".to_string(), + client_id: cid, + thread_id: tid, + message: Some(format!("Failed to send: {e}")), + error_type: Some("transport".to_string()), + ..Default::default() + }); + } + }); +} + +/// Cancel the in-flight turn on the current thread. The core emits a +/// `chat_error` ("Cancelled") which the reducer renders. +fn cancel_turn( + runtime: &Arc, + client_id: &str, + thread_id: &str, + state: &TranscriptState, +) { + if !state.is_streaming() { + return; + } + log::info!("[tui] cancel turn thread={thread_id}"); + let rt = runtime.clone(); + let cid = client_id.to_string(); + let tid = thread_id.to_string(); + tokio::spawn(async move { + // Omit `request_id` → stop whatever is running on the thread. + let params = json!({ "client_id": cid, "thread_id": tid }); + if let Err(e) = rt.invoke("openhuman.channel_web_cancel", params).await { + log::error!("[tui] openhuman.channel_web_cancel failed: {e}"); + } + }); +} + +/// Create a fresh thread and switch the UI to it. Awaited inline (fast, local +/// SQLite write) so `ui.thread_id` can be updated with the result. +async fn new_thread(runtime: &Arc, state: &mut TranscriptState, ui: &mut UiState) { + log::info!("[tui] creating new thread"); + match runtime + .invoke("openhuman.threads_create_new", json!({})) + .await + .ok() + .and_then(|v| super::runner::extract_thread_id(&v)) + { + Some(new_id) => { + ui.thread_id = new_id.clone(); + ui.scroll_from_bottom = 0; + state.push_system(format!("Started a new thread · {new_id}")); + log::info!("[tui] switched to new thread {new_id}"); + } + None => { + state.push_system("Could not create a new thread (see logs).".to_string()); + log::error!("[tui] threads.create_new returned no thread id"); + } + } +} diff --git a/src/openhuman/tui/mod.rs b/src/openhuman/tui/mod.rs new file mode 100644 index 0000000000..c684ddfdb3 --- /dev/null +++ b/src/openhuman/tui/mod.rs @@ -0,0 +1,52 @@ +//! Terminal chat UI — the `openhuman tui` (alias `chat`) CLI subcommand. +//! +//! A [ratatui]-based terminal front-end onto the **same `web_chat` surface** +//! the desktop app drives (`openhuman.channel_web_chat` / +//! `openhuman.channel_web_cancel` + +//! [`web_chat::subscribe_web_channel_events`](crate::openhuman::web_chat::subscribe_web_channel_events)). +//! It boots the core in-process — no HTTP, no sockets — via +//! `CoreBuilder::new(HostKind::Cli).domains(DomainSet::full()).services(ServiceSet::none())` +//! and streams a live transcript in the terminal. +//! +//! ## Compile-time gate (`tui` feature) +//! +//! `pub mod tui;` is ALWAYS compiled — it is a facade (mirrors +//! [`mcp_server`](crate::openhuman::mcp_server)). The terminal driver, the +//! renderer, the reducer, and the event loop are gated behind the default-ON +//! `tui` Cargo feature; when it is off, [`stub`] mirrors the one surface an +//! always-compiled caller reaches — [`run_from_cli`] — with a build-fact +//! disabled-error body. +//! +//! The `"tui" | "chat"` arm in `src/core/cli.rs` is deliberately left +//! **un-`#[cfg]`'d**: in a slim build it resolves to [`stub::run_from_cli`], +//! which bails with a message naming the compile-time gate as the cause (so the +//! error reads like a build fact, not `unknown namespace: tui`). This is the +//! same reasoning documented on [`mcp_server::stub::run_stdio_from_cli`]. + +#[cfg(feature = "tui")] +mod app; +#[cfg(feature = "tui")] +mod render; +#[cfg(feature = "tui")] +mod runner; +#[cfg(feature = "tui")] +mod state; +#[cfg(feature = "tui")] +mod terminal; + +#[cfg(feature = "tui")] +pub use runner::run_from_cli; + +// State reducer is behaviour-only but has no terminal deps, so its tests run in +// the default (feature-on) build. Exported for the sibling submodules + tests. +#[cfg(feature = "tui")] +pub use state::{Entry, EntryKind, TranscriptState}; + +// --------------------------------------------------------------------------- +// Disabled facade — compiled only when the `tui` feature is OFF. +// --------------------------------------------------------------------------- + +#[cfg(not(feature = "tui"))] +mod stub; +#[cfg(not(feature = "tui"))] +pub use stub::*; diff --git a/src/openhuman/tui/render.rs b/src/openhuman/tui/render.rs new file mode 100644 index 0000000000..b9e11609b4 --- /dev/null +++ b/src/openhuman/tui/render.rs @@ -0,0 +1,248 @@ +//! Ratatui rendering for the terminal chat UI — pure view over +//! [`TranscriptState`] + [`UiState`]. No state mutation happens here. +//! +//! Layout (top → bottom): +//! * transcript viewport (fills remaining height, wraps + scrolls) +//! * single-line input box (bordered) +//! * status bar (thread id, turn state, key hints) + +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use ratatui::Frame; +use unicode_width::UnicodeWidthStr; + +use super::state::{EntryKind, TranscriptState}; + +/// Ocean accent from the design tokens (`#4A83DD`), kept terminal-native. +const OCEAN: Color = Color::Rgb(0x4A, 0x83, 0xDD); + +const SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +/// View-only UI state owned by the event loop and read by [`draw`]. +pub struct UiState { + /// Current input line contents. + pub input: String, + /// Lines scrolled up from the tail. `0` follows the newest content. + pub scroll_from_bottom: u16, + /// Monotonic tick used to animate the streaming spinner. + pub spinner_tick: usize, + /// The thread id shown in the status bar. + pub thread_id: String, + /// The client stream id (for the status bar, abbreviated). + pub client_id: String, +} + +impl UiState { + pub fn new(thread_id: String, client_id: String) -> Self { + Self { + input: String::new(), + scroll_from_bottom: 0, + spinner_tick: 0, + thread_id, + client_id, + } + } +} + +/// Draw one frame. +pub fn draw(frame: &mut Frame, state: &TranscriptState, ui: &UiState) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(1), // transcript + Constraint::Length(3), // input box + Constraint::Length(1), // status bar + ]) + .split(frame.area()); + + draw_transcript(frame, chunks[0], state, ui); + draw_input(frame, chunks[1], ui); + draw_status(frame, chunks[2], state, ui); +} + +fn draw_transcript(frame: &mut Frame, area: Rect, state: &TranscriptState, ui: &UiState) { + let block = Block::default() + .borders(Borders::ALL) + .title(" OpenHuman chat ") + .border_style(Style::default().fg(OCEAN)); + let inner = block.inner(area); + + let text = transcript_text(state); + // Inner width available for wrapping (borders eat 2 cols). + let wrap_width = inner.width.max(1); + let total_lines: u16 = text + .lines + .iter() + .map(|line| wrapped_line_count(line, wrap_width)) + .sum::(); + let viewport = inner.height.max(1); + let max_scroll = total_lines.saturating_sub(viewport); + let scroll_from_bottom = ui.scroll_from_bottom.min(max_scroll); + let top = max_scroll.saturating_sub(scroll_from_bottom); + + let paragraph = Paragraph::new(text) + .block(block) + .wrap(Wrap { trim: false }) + .scroll((top, 0)); + frame.render_widget(paragraph, area); +} + +fn draw_input(frame: &mut Frame, area: Rect, ui: &UiState) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Message ") + .border_style(Style::default().fg(Color::DarkGray)); + let inner_width = block.inner(area).width.max(1) as usize; + + // Keep the caret end of a long input visible. + let display = tail_to_width(&ui.input, inner_width.saturating_sub(1)); + let line = Line::from(vec![ + Span::styled(display, Style::default().fg(Color::White)), + Span::styled("▏", Style::default().fg(OCEAN)), + ]); + let paragraph = Paragraph::new(line).block(block); + frame.render_widget(paragraph, area); +} + +fn draw_status(frame: &mut Frame, area: Rect, state: &TranscriptState, ui: &UiState) { + let turn = if state.is_streaming() { + let frame_ch = SPINNER_FRAMES[ui.spinner_tick % SPINNER_FRAMES.len()]; + format!("{frame_ch} streaming") + } else { + "idle".to_string() + }; + let thread_short = abbreviate(&ui.thread_id, 24); + + let left = Span::styled( + format!(" thread {thread_short} · {turn} "), + Style::default().fg(Color::Black).bg(OCEAN), + ); + let hints = Span::styled( + " Enter send · Esc cancel · Ctrl+N new · PgUp/PgDn scroll · Ctrl+C quit", + Style::default().fg(Color::DarkGray), + ); + let paragraph = Paragraph::new(Line::from(vec![left, hints])); + frame.render_widget(paragraph, area); +} + +/// Build the styled transcript body from the reducer state. +fn transcript_text(state: &TranscriptState) -> Text<'static> { + let mut lines: Vec = Vec::new(); + for entry in state.entries() { + let (prefix, style) = match entry.kind { + EntryKind::User => ( + "You ", + Style::default().fg(OCEAN).add_modifier(Modifier::BOLD), + ), + EntryKind::Assistant => ("AI ", Style::default().fg(Color::White)), + EntryKind::Thinking => ( + "··· ", + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + ), + EntryKind::Tool => ("tool ", Style::default().fg(Color::Yellow)), + EntryKind::Error => ( + "err ", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + EntryKind::System => (" ", Style::default().fg(Color::DarkGray)), + }; + + let mut first = true; + for raw in entry.text.split('\n') { + let gutter = if first { prefix } else { " " }; + lines.push(Line::from(vec![ + Span::styled(gutter.to_string(), style.add_modifier(Modifier::DIM)), + Span::styled(raw.to_string(), style), + ])); + first = false; + } + // Blank spacer between entries for readability. + lines.push(Line::from("")); + } + Text::from(lines) +} + +/// Approximate the number of visual rows a wrapped line occupies at `width`. +/// ratatui wraps on word boundaries; this display-width estimate is close +/// enough for scroll bookkeeping (off-by-one at most, harmless). +fn wrapped_line_count(line: &Line, width: u16) -> u16 { + let w = width.max(1) as usize; + let content_width: usize = line + .spans + .iter() + .map(|s| UnicodeWidthStr::width(s.content.as_ref())) + .sum(); + if content_width == 0 { + 1 + } else { + content_width.div_ceil(w).max(1) as u16 + } +} + +/// Return the trailing slice of `s` that fits within `width` display columns. +fn tail_to_width(s: &str, width: usize) -> String { + if width == 0 { + return String::new(); + } + let mut out: Vec = Vec::new(); + let mut used = 0usize; + for ch in s.chars().rev() { + let cw = UnicodeWidthStr::width(ch.to_string().as_str()).max(1); + if used + cw > width { + break; + } + used += cw; + out.push(ch); + } + out.into_iter().rev().collect() +} + +/// Middle-truncate an id to `max` columns (`abc…xyz`). +fn abbreviate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + return s.to_string(); + } + let keep = max.saturating_sub(1) / 2; + let head: String = s.chars().take(keep).collect(); + let tail: String = s + .chars() + .rev() + .take(keep) + .collect::>() + .into_iter() + .rev() + .collect(); + format!("{head}…{tail}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tail_to_width_keeps_the_end() { + assert_eq!(tail_to_width("hello world", 5), "world"); + assert_eq!(tail_to_width("hi", 10), "hi"); + assert_eq!(tail_to_width("anything", 0), ""); + } + + #[test] + fn abbreviate_middle_truncates_long_ids() { + let out = abbreviate("thread-0123456789abcdef", 11); + assert!(out.contains('…')); + assert!(out.chars().count() <= 11); + assert_eq!(abbreviate("short", 24), "short"); + } + + #[test] + fn wrapped_line_count_divides_by_width() { + let line = Line::from("a".repeat(25)); + assert_eq!(wrapped_line_count(&line, 10), 3); + let empty = Line::from(""); + assert_eq!(wrapped_line_count(&empty, 10), 1); + } +} diff --git a/src/openhuman/tui/runner.rs b/src/openhuman/tui/runner.rs new file mode 100644 index 0000000000..9fcc88648b --- /dev/null +++ b/src/openhuman/tui/runner.rs @@ -0,0 +1,224 @@ +//! CLI entry point for the terminal chat UI (`openhuman tui` / `chat`). +//! +//! Parses flags, initializes **file-only** logging (the TUI owns the terminal — +//! see `logging::init_for_tui`), boots the core in-process with no transport and +//! no background services, resolves the target thread, and hands off to the +//! event loop in [`super::app`]. + +use std::path::PathBuf; +use std::sync::Arc; + +use serde_json::{json, Value}; + +use crate::core::runtime::{ + CoreBuilder, CoreRuntime, DomainSet, ServiceSet, AGENT_WORKER_STACK_BYTES, +}; +use crate::core::types::HostKind; + +/// Entry point dispatched from the `"tui" | "chat"` arm in `src/core/cli.rs`. +/// +/// Flags: +/// * `--thread ` — attach to an existing thread. +/// * `--new` — force a brand-new thread (default when `--thread` is absent). +/// * `-v` / `--verbose` — debug-level file logging. +pub fn run_from_cli(args: &[String]) -> anyhow::Result<()> { + let mut thread_id: Option = None; + let mut force_new = false; + let mut verbose = false; + + let mut i = 0usize; + while i < args.len() { + match args[i].as_str() { + "--thread" => { + thread_id = Some( + args.get(i + 1) + .ok_or_else(|| anyhow::anyhow!("missing value for --thread"))? + .clone(), + ); + i += 2; + } + "--new" => { + force_new = true; + i += 1; + } + "-v" | "--verbose" => { + verbose = true; + i += 1; + } + "-h" | "--help" => { + print_help(); + return Ok(()); + } + other => return Err(anyhow::anyhow!("unknown tui arg: {other}")), + } + } + + // File-only logging — never stderr while the TUI owns the terminal. + let data_dir = resolve_data_dir(); + let log_dir = crate::core::logging::init_for_tui(&data_dir, verbose); + log::info!( + "[tui] starting terminal chat UI (thread={:?} new={} logs={:?})", + thread_id, + force_new, + log_dir + ); + + // A chat turn is a large async state machine that can delegate to + // sub-agents; give the tokio workers the same roomy stack the server uses + // so a nested turn cannot overflow the default 2 MiB stack. + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_stack_size(AGENT_WORKER_STACK_BYTES) + .build()?; + rt.block_on(async_main(thread_id, force_new)) +} + +async fn async_main(thread_flag: Option, force_new: bool) -> anyhow::Result<()> { + // In-process core: full domains (channel.web_chat needs DomainGroup::Channels, + // so harness() is not enough), no transport, no background services. + let runtime = Arc::new( + CoreBuilder::new(HostKind::Cli) + .domains(DomainSet::full()) + .services(ServiceSet::none()) + .build() + .await?, + ); + log::info!("[tui] core built (DomainSet::full, ServiceSet::none)"); + + let client_id = format!("tui-{}", short_hex()); + let thread_id = resolve_thread(&runtime, thread_flag, force_new).await?; + log::info!("[tui] resolved thread={thread_id} client_id={client_id}"); + + // Subscribe BEFORE the first turn so no streamed event is missed. + let web_rx = crate::openhuman::web_chat::subscribe_web_channel_events(); + + super::app::run(runtime, client_id, thread_id, web_rx).await +} + +/// Resolve the thread to open: the `--thread` id (unless `--new`), otherwise a +/// freshly created thread. +async fn resolve_thread( + runtime: &CoreRuntime, + thread_flag: Option, + force_new: bool, +) -> anyhow::Result { + if let (Some(id), false) = (thread_flag.as_ref(), force_new) { + log::debug!("[tui] attaching to existing thread {id}"); + return Ok(id.clone()); + } + + let created = runtime + .invoke("openhuman.threads_create_new", json!({})) + .await + .map_err(|e| anyhow::anyhow!("openhuman.threads_create_new failed: {e}"))?; + extract_thread_id(&created).ok_or_else(|| { + anyhow::anyhow!("openhuman.threads_create_new returned no thread id: {created}") + }) +} + +/// Pull a thread id out of a `threads.create_new` / `threads.list` response, +/// tolerant of the `RpcOutcome` log-envelope wrapping (`{result, logs}`) and the +/// `ApiEnvelope` data wrapping (`{data, meta}`). +pub(super) fn extract_thread_id(value: &Value) -> Option { + // Unwrap the optional `{result, logs}` log envelope first. + let inner = value.get("result").unwrap_or(value); + // Then the optional `{data, meta}` ApiEnvelope. + let payload = inner.get("data").unwrap_or(inner); + payload + .get("id") + .and_then(Value::as_str) + .map(str::to_string) +} + +/// Resolve the OpenHuman data dir (host of `logs/`), mirroring the shell's +/// resolution: `OPENHUMAN_WORKSPACE` override, else `~/.openhuman`, else a temp +/// fallback. No `eprintln!` — the TUI is about to take the terminal. +fn resolve_data_dir() -> PathBuf { + if let Ok(workspace) = std::env::var("OPENHUMAN_WORKSPACE") { + if !workspace.is_empty() { + return PathBuf::from(workspace); + } + } + crate::openhuman::config::default_root_openhuman_dir() + .unwrap_or_else(|_| std::env::temp_dir().join("openhuman")) +} + +/// 12 hex chars of randomness for the client stream id. +fn short_hex() -> String { + let u = uuid::Uuid::new_v4(); + u.simple().to_string()[..12].to_string() +} + +fn print_help() { + println!("Usage: openhuman tui [--thread ] [--new] [-v|--verbose]"); + println!(" openhuman chat [--thread ] [--new] [-v|--verbose]"); + println!(); + println!("Open a terminal chat UI onto the general-chat surface (the same one the"); + println!("desktop app uses). Runs the core in-process — no server, no ports."); + println!(); + println!(" --thread Attach to an existing conversation thread."); + println!(" --new Force a new thread (default when --thread is omitted)."); + println!(" -v, --verbose Debug-level logging (written to the log file, never the UI)."); + println!(); + println!("Keys: Enter send · Esc cancel turn · Ctrl+N new thread · PgUp/PgDn scroll ·"); + println!(" Ctrl+C / Ctrl+D quit."); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_thread_id_handles_bare_summary() { + let v = json!({ "id": "thread-1", "title": "x" }); + assert_eq!(extract_thread_id(&v).as_deref(), Some("thread-1")); + } + + #[test] + fn extract_thread_id_handles_api_envelope() { + let v = json!({ "data": { "id": "thread-2" }, "meta": {} }); + assert_eq!(extract_thread_id(&v).as_deref(), Some("thread-2")); + } + + #[test] + fn extract_thread_id_handles_log_envelope_around_api_envelope() { + let v = json!({ + "result": { "data": { "id": "thread-3" }, "meta": {} }, + "logs": ["created"] + }); + assert_eq!(extract_thread_id(&v).as_deref(), Some("thread-3")); + } + + #[test] + fn extract_thread_id_missing_returns_none() { + let v = json!({ "meta": {} }); + assert_eq!(extract_thread_id(&v), None); + } + + #[test] + fn short_hex_is_twelve_chars() { + assert_eq!(short_hex().len(), 12); + } + + /// Regression guard: the RPC method names the TUI invokes must be the + /// canonical `openhuman._` form that the registry + /// resolves — NOT the dotted `namespace.function` short form, which + /// `schema_for_rpc_method` does not recognise and which would make every + /// turn (and the launch-time thread creation) fail with "unknown method". + /// The dispatcher only rewrites a fixed set of legacy aliases; none of + /// these three are in that table, so the short form never resolves. + #[test] + fn tui_invokes_use_canonical_registered_rpc_method_names() { + for method in [ + "openhuman.channel_web_chat", + "openhuman.channel_web_cancel", + "openhuman.threads_create_new", + ] { + assert!( + crate::core::all::schema_for_rpc_method(method).is_some(), + "TUI invokes `{method}`, but it is not a registered RPC method — \ + the terminal chat UI would fail with `unknown method: {method}`" + ); + } + } +} diff --git a/src/openhuman/tui/state.rs b/src/openhuman/tui/state.rs new file mode 100644 index 0000000000..1758ec4135 --- /dev/null +++ b/src/openhuman/tui/state.rs @@ -0,0 +1,491 @@ +//! Pure, terminal-free transcript reducer for the terminal chat UI. +//! +//! [`TranscriptState`] is a plain data structure with **no ratatui / crossterm / +//! IO dependencies** — the renderer ([`super::render`]) reads it and the event +//! loop ([`super::app`]) mutates it, but the state transitions themselves live +//! here so they can be unit-tested without a terminal. +//! +//! The single entry point is [`TranscriptState::apply_event`], which folds a +//! [`WebChannelEvent`] (the same struct the desktop app receives over Socket.IO) +//! into the transcript. Events for a different `client_id` are ignored, so a +//! process-wide broadcast bus can be drained safely. + +use crate::core::socketio::WebChannelEvent; + +/// The kind of a transcript entry — drives colour / prefix in the renderer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EntryKind { + /// A message the local user sent. + User, + /// The assistant's streamed / final reply text. + Assistant, + /// The assistant's "thinking" (reasoning) stream — rendered dimmed. + Thinking, + /// A tool-call / tool-result status line. + Tool, + /// A terminal error (`chat_error`). + Error, + /// A local status/system note (never produced by `apply_event`). + System, +} + +/// One line-group in the transcript. `text` accumulates across streaming deltas. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entry { + pub kind: EntryKind, + pub text: String, +} + +impl Entry { + fn new(kind: EntryKind, text: impl Into) -> Self { + Self { + kind, + text: text.into(), + } + } +} + +/// Accumulated transcript + streaming status for one chat client stream. +#[derive(Debug, Clone)] +pub struct TranscriptState { + /// Our stream identity. Events whose `client_id` differs are ignored. + client_id: String, + /// The rendered transcript, oldest first. + entries: Vec, + /// True while a turn is in flight (between send and `chat_done`/`chat_error`). + streaming: bool, + /// Index into `entries` of the assistant entry currently accumulating text + /// deltas for the in-flight turn, if any. + cur_assistant: Option, + /// Index into `entries` of the thinking entry currently accumulating + /// thinking deltas for the in-flight turn, if any. + cur_thinking: Option, +} + +impl TranscriptState { + /// Create an empty transcript bound to `client_id`. + pub fn new(client_id: impl Into) -> Self { + Self { + client_id: client_id.into(), + entries: Vec::new(), + streaming: false, + cur_assistant: None, + cur_thinking: None, + } + } + + /// The transcript entries, oldest first. + pub fn entries(&self) -> &[Entry] { + &self.entries + } + + /// Whether a turn is currently streaming. + pub fn is_streaming(&self) -> bool { + self.streaming + } + + /// Our client stream id. + pub fn client_id(&self) -> &str { + &self.client_id + } + + /// Record a locally-sent user message and begin a new turn. + /// + /// Resets the streaming cursors so the next `text_delta` / `thinking_delta` + /// opens fresh assistant / thinking entries for this turn. + pub fn begin_user_turn(&mut self, message: impl Into) { + let text = message.into(); + log::debug!("[tui] state: begin_user_turn len={}", text.len()); + self.entries.push(Entry::new(EntryKind::User, text)); + self.cur_assistant = None; + self.cur_thinking = None; + self.streaming = true; + } + + /// Push a local system/status note (e.g. "Cancelled", connection info). + pub fn push_system(&mut self, text: impl Into) { + let text = text.into(); + log::debug!("[tui] state: push_system len={}", text.len()); + self.entries.push(Entry::new(EntryKind::System, text)); + } + + /// Fold one [`WebChannelEvent`] into the transcript. + /// + /// Events whose `client_id` does not match ours are ignored (the web-channel + /// bus is process-wide). Returns nothing; inspect [`Self::entries`] / + /// [`Self::is_streaming`] afterwards. + pub fn apply_event(&mut self, ev: &WebChannelEvent) { + if ev.client_id != self.client_id { + log::trace!( + "[tui] state: ignoring event={} for other client_id={}", + ev.event, + ev.client_id + ); + return; + } + + match ev.event.as_str() { + "text_delta" => { + if let Some(delta) = ev.delta.as_deref() { + self.append_assistant(delta); + } + } + "thinking_delta" => { + if let Some(delta) = ev.delta.as_deref() { + self.append_thinking(delta); + } + } + "tool_call" => { + let name = ev.tool_name.as_deref().unwrap_or("tool"); + let args = ev.args.as_ref().map(summarize_json).unwrap_or_default(); + log::debug!("[tui] state: tool_call {name}"); + self.entries + .push(Entry::new(EntryKind::Tool, format!("→ {name}{args}"))); + } + "tool_result" => { + let name = ev.tool_name.as_deref().unwrap_or("tool"); + let ok = ev.success.unwrap_or(true); + let marker = if ok { "✓" } else { "✗" }; + let detail = ev + .output + .as_deref() + .map(truncate_line) + .filter(|s| !s.is_empty()) + .map(|s| format!(" — {s}")) + .unwrap_or_default(); + log::debug!("[tui] state: tool_result {name} ok={ok}"); + self.entries.push(Entry::new( + EntryKind::Tool, + format!("{marker} {name}{detail}"), + )); + } + "chat_done" => { + log::debug!( + "[tui] state: chat_done full_response={}", + ev.full_response.is_some() + ); + // `full_response` is authoritative — it replaces whatever the + // streamed text deltas accumulated (they can lag / be partial). + if let Some(full) = ev.full_response.as_deref() { + match self.cur_assistant { + Some(idx) => self.entries[idx].text = full.to_string(), + None => self + .entries + .push(Entry::new(EntryKind::Assistant, full.to_string())), + } + } + self.finish_turn(); + } + "chat_error" => { + let msg = ev.message.as_deref().unwrap_or("Unknown error").to_string(); + log::debug!("[tui] state: chat_error {msg}"); + self.entries.push(Entry::new(EntryKind::Error, msg)); + self.finish_turn(); + } + other => { + log::trace!("[tui] state: unhandled event={other}"); + } + } + } + + fn append_assistant(&mut self, delta: &str) { + match self.cur_assistant { + Some(idx) => self.entries[idx].text.push_str(delta), + None => { + self.entries + .push(Entry::new(EntryKind::Assistant, delta.to_string())); + self.cur_assistant = Some(self.entries.len() - 1); + } + } + } + + fn append_thinking(&mut self, delta: &str) { + match self.cur_thinking { + Some(idx) => self.entries[idx].text.push_str(delta), + None => { + self.entries + .push(Entry::new(EntryKind::Thinking, delta.to_string())); + self.cur_thinking = Some(self.entries.len() - 1); + } + } + } + + fn finish_turn(&mut self) { + self.streaming = false; + self.cur_assistant = None; + self.cur_thinking = None; + } +} + +/// One-line, length-capped summary of a JSON value for tool-call args display. +fn summarize_json(value: &serde_json::Value) -> String { + let rendered = match value { + serde_json::Value::Object(_) | serde_json::Value::Array(_) => value.to_string(), + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + }; + format!("({})", truncate_line(&rendered)) +} + +/// Collapse to a single line and cap length so a rogue tool output can't blow +/// up the transcript width. +fn truncate_line(s: &str) -> String { + const MAX: usize = 120; + let single = s.replace(['\n', '\r'], " "); + let trimmed = single.trim(); + if trimmed.chars().count() > MAX { + let cut: String = trimmed.chars().take(MAX).collect(); + format!("{cut}…") + } else { + trimmed.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const CLIENT: &str = "tui-abc123"; + + fn ev(event: &str) -> WebChannelEvent { + WebChannelEvent { + event: event.to_string(), + client_id: CLIENT.to_string(), + thread_id: "thread-1".to_string(), + ..Default::default() + } + } + + fn text_delta(delta: &str) -> WebChannelEvent { + WebChannelEvent { + delta: Some(delta.to_string()), + delta_kind: Some("text".to_string()), + ..ev("text_delta") + } + } + + fn thinking_delta(delta: &str) -> WebChannelEvent { + WebChannelEvent { + delta: Some(delta.to_string()), + delta_kind: Some("thinking".to_string()), + ..ev("thinking_delta") + } + } + + #[test] + fn text_deltas_accumulate_into_one_assistant_entry() { + let mut s = TranscriptState::new(CLIENT); + s.begin_user_turn("hi"); + s.apply_event(&text_delta("Hel")); + s.apply_event(&text_delta("lo ")); + s.apply_event(&text_delta("world")); + + let assistant: Vec<_> = s + .entries() + .iter() + .filter(|e| e.kind == EntryKind::Assistant) + .collect(); + assert_eq!(assistant.len(), 1, "deltas must fold into a single entry"); + assert_eq!(assistant[0].text, "Hello world"); + assert!(s.is_streaming(), "still streaming before chat_done"); + } + + #[test] + fn thinking_and_text_are_separate_entries() { + let mut s = TranscriptState::new(CLIENT); + s.begin_user_turn("q"); + s.apply_event(&thinking_delta("let me think")); + s.apply_event(&text_delta("answer")); + + let kinds: Vec<_> = s.entries().iter().map(|e| e.kind).collect(); + assert_eq!( + kinds, + vec![EntryKind::User, EntryKind::Thinking, EntryKind::Assistant] + ); + let thinking = s + .entries() + .iter() + .find(|e| e.kind == EntryKind::Thinking) + .unwrap(); + assert_eq!(thinking.text, "let me think"); + } + + #[test] + fn thinking_deltas_accumulate_separately_from_text() { + let mut s = TranscriptState::new(CLIENT); + s.begin_user_turn("q"); + s.apply_event(&thinking_delta("a")); + s.apply_event(&thinking_delta("b")); + s.apply_event(&text_delta("x")); + s.apply_event(&thinking_delta("c")); // interleaved — same thinking entry + let thinking: Vec<_> = s + .entries() + .iter() + .filter(|e| e.kind == EntryKind::Thinking) + .collect(); + assert_eq!(thinking.len(), 1); + assert_eq!(thinking[0].text, "abc"); + } + + #[test] + fn chat_done_replaces_streamed_text_with_full_response() { + let mut s = TranscriptState::new(CLIENT); + s.begin_user_turn("hi"); + s.apply_event(&text_delta("Hel")); // partial / laggy stream + let done = WebChannelEvent { + full_response: Some("Hello, world!".to_string()), + ..ev("chat_done") + }; + s.apply_event(&done); + + let assistant: Vec<_> = s + .entries() + .iter() + .filter(|e| e.kind == EntryKind::Assistant) + .collect(); + assert_eq!(assistant.len(), 1); + assert_eq!( + assistant[0].text, "Hello, world!", + "full_response is authoritative and replaces the streamed text" + ); + assert!(!s.is_streaming(), "chat_done ends the turn"); + } + + #[test] + fn chat_done_without_prior_deltas_still_shows_full_response() { + let mut s = TranscriptState::new(CLIENT); + s.begin_user_turn("hi"); + let done = WebChannelEvent { + full_response: Some("Direct answer".to_string()), + ..ev("chat_done") + }; + s.apply_event(&done); + let assistant = s + .entries() + .iter() + .find(|e| e.kind == EntryKind::Assistant) + .expect("chat_done with full_response must produce an assistant entry"); + assert_eq!(assistant.text, "Direct answer"); + assert!(!s.is_streaming()); + } + + #[test] + fn chat_error_pushes_error_entry_and_ends_stream() { + let mut s = TranscriptState::new(CLIENT); + s.begin_user_turn("hi"); + let err = WebChannelEvent { + message: Some("rate limited".to_string()), + error_type: Some("rate_limit".to_string()), + ..ev("chat_error") + }; + s.apply_event(&err); + let error = s + .entries() + .iter() + .find(|e| e.kind == EntryKind::Error) + .expect("chat_error must produce an error entry"); + assert_eq!(error.text, "rate limited"); + assert!(!s.is_streaming(), "chat_error ends the turn"); + } + + #[test] + fn events_for_other_client_id_are_ignored() { + let mut s = TranscriptState::new(CLIENT); + s.begin_user_turn("hi"); + let before = s.entries().len(); + let foreign = WebChannelEvent { + client_id: "tui-someone-else".to_string(), + delta: Some("not mine".to_string()), + ..WebChannelEvent { + event: "text_delta".to_string(), + thread_id: "thread-1".to_string(), + ..Default::default() + } + }; + s.apply_event(&foreign); + assert_eq!( + s.entries().len(), + before, + "foreign client_id events must not mutate our transcript" + ); + } + + #[test] + fn tool_call_and_result_produce_tool_entries() { + let mut s = TranscriptState::new(CLIENT); + s.begin_user_turn("do it"); + let call = WebChannelEvent { + tool_name: Some("web_search".to_string()), + args: Some(serde_json::json!({"query": "rust ratatui"})), + ..ev("tool_call") + }; + s.apply_event(&call); + let result = WebChannelEvent { + tool_name: Some("web_search".to_string()), + success: Some(true), + output: Some("3 results".to_string()), + ..ev("tool_result") + }; + s.apply_event(&result); + + let tools: Vec<_> = s + .entries() + .iter() + .filter(|e| e.kind == EntryKind::Tool) + .collect(); + assert_eq!(tools.len(), 2); + assert!(tools[0].text.starts_with("→ web_search")); + assert!(tools[0].text.contains("rust ratatui")); + assert!(tools[1].text.starts_with("✓ web_search")); + assert!(tools[1].text.contains("3 results")); + } + + #[test] + fn failed_tool_result_uses_cross_marker() { + let mut s = TranscriptState::new(CLIENT); + s.begin_user_turn("do it"); + let result = WebChannelEvent { + tool_name: Some("run_shell".to_string()), + success: Some(false), + output: Some("exit code 1".to_string()), + ..ev("tool_result") + }; + s.apply_event(&result); + let tool = s + .entries() + .iter() + .find(|e| e.kind == EntryKind::Tool) + .unwrap(); + assert!(tool.text.starts_with("✗ run_shell")); + } + + #[test] + fn second_turn_opens_fresh_assistant_entry() { + let mut s = TranscriptState::new(CLIENT); + s.begin_user_turn("first"); + s.apply_event(&text_delta("one")); + s.apply_event(&WebChannelEvent { + full_response: Some("one".to_string()), + ..ev("chat_done") + }); + s.begin_user_turn("second"); + s.apply_event(&text_delta("two")); + + let assistant: Vec<_> = s + .entries() + .iter() + .filter(|e| e.kind == EntryKind::Assistant) + .collect(); + assert_eq!(assistant.len(), 2, "each turn gets its own assistant entry"); + assert_eq!(assistant[0].text, "one"); + assert_eq!(assistant[1].text, "two"); + } + + #[test] + fn truncate_line_collapses_newlines_and_caps_length() { + let long = "a\nb\n".repeat(200); + let out = truncate_line(&long); + assert!(!out.contains('\n')); + assert!(out.chars().count() <= 121, "capped to MAX + ellipsis"); + } +} diff --git a/src/openhuman/tui/stub.rs b/src/openhuman/tui/stub.rs new file mode 100644 index 0000000000..2e0355c5e7 --- /dev/null +++ b/src/openhuman/tui/stub.rs @@ -0,0 +1,37 @@ +//! Disabled-`tui` facade for [`super`] (the terminal chat UI). +//! +//! Compiled only when the `tui` Cargo feature is OFF (see the gate in +//! [`super`]). It mirrors the one public symbol always-compiled callers reach — +//! [`run_from_cli`] — with a disabled-error body. +//! +//! The signature MUST match the real one exactly (`&[String] -> anyhow::Result<()>`). +//! The disabled build +//! (`cargo check --no-default-features --features tokenjuice-treesitter`) is the +//! only thing that catches drift. + +/// Error text returned by the disabled path. Shared so callers / log-greps see +/// one stable string, and asserted by the disabled-build CLI tests. +const DISABLED_MSG: &str = "tui feature disabled at compile time"; + +/// Fails with a build-fact diagnostic instead of opening the terminal UI. +/// +/// This is deliberately a stub rather than a `#[cfg]` on the `"tui" | "chat"` +/// match arm in `src/core/cli.rs`. Deleting the arm is the naive move and is +/// WRONG: the `tui` / `chat` token would fall through to generic namespace +/// resolution and die with `unknown namespace: tui`, which reads like the user +/// typo'd a command rather than like a deliberate property of this build. +/// Keeping the arm and failing here means the user gets a non-zero exit and a +/// one-line stderr diagnostic naming the fix, and `cli.rs` needs no `#[cfg]` at +/// all — the gate stays invisible to the transport layer. +/// +/// Banner suppression in `cli.rs` is a `matches!` on the raw string, so it +/// keeps working here without touching a gated symbol. +pub fn run_from_cli(_args: &[String]) -> anyhow::Result<()> { + log::warn!( + "[tui] {DISABLED_MSG} — `openhuman tui`/`chat` rejected; rebuild with `--features tui`" + ); + anyhow::bail!( + "{DISABLED_MSG}: this build was compiled without the `tui` feature, so the terminal \ + chat UI is unavailable. Rebuild with `--features tui`." + ) +} diff --git a/src/openhuman/tui/terminal.rs b/src/openhuman/tui/terminal.rs new file mode 100644 index 0000000000..e2c85c0c55 --- /dev/null +++ b/src/openhuman/tui/terminal.rs @@ -0,0 +1,83 @@ +//! Terminal setup / teardown with panic-safe restoration. +//! +//! Owning the terminal means switching to the alternate screen and enabling raw +//! mode; both **must** be undone on every exit path — normal return, `?` +//! propagation, and panic — or the user's shell is left in a broken state +//! (no echo, no line editing, stuck on the alternate screen). [`TerminalGuard`] +//! restores on `Drop`, and [`install_panic_hook`] chains a restore ahead of the +//! previous panic hook so the panic message prints to a sane terminal. + +use std::io::{self, Stdout}; + +use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; + +/// The concrete ratatui terminal type used by the TUI. +pub type Tui = Terminal>; + +/// RAII guard that enters the alternate screen + raw mode on construction and +/// restores the terminal on drop. +pub struct TerminalGuard { + terminal: Tui, +} + +impl TerminalGuard { + /// Enter the alternate screen, enable raw mode, install the panic hook, and + /// return a ready-to-draw terminal wrapped in a restoring guard. + pub fn enter() -> io::Result { + install_panic_hook(); + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(io::stdout()); + let terminal = Terminal::new(backend)?; + log::debug!("[tui] terminal: entered alternate screen + raw mode"); + Ok(Self { terminal }) + } + + /// Mutable access to the underlying terminal for drawing. + pub fn terminal(&mut self) -> &mut Tui { + &mut self.terminal + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + if let Err(e) = restore() { + // The subscriber writes to a file (never the terminal), so this is + // safe to log here. + log::warn!("[tui] terminal: restore on drop failed: {e}"); + } else { + log::debug!("[tui] terminal: restored on drop"); + } + } +} + +/// Undo everything [`TerminalGuard::enter`] did. Best-effort — each step is +/// attempted even if an earlier one fails, so a partial setup still gets torn +/// down as far as possible. +fn restore() -> io::Result<()> { + let mut stdout = io::stdout(); + let _ = execute!(stdout, LeaveAlternateScreen, DisableMouseCapture); + disable_raw_mode() +} + +/// Chain a terminal-restoring step in front of the process panic hook, so a +/// panic inside the render loop leaves the user with a usable terminal and a +/// readable backtrace instead of a garbled alternate screen. +/// +/// Idempotent in effect: called once from [`TerminalGuard::enter`]. If it were +/// ever called twice, the second restore would simply be a no-op on an +/// already-restored terminal. +fn install_panic_hook() { + let original = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = restore(); + original(info); + })); +} From 49a0d3b1d59c153afbabafe9ddf76f80bf258974 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:14:16 +0300 Subject: [PATCH 08/56] fix: daemon-health identity race + Orchestration sub-tab follow-ups (#5085) --- app/src/AppRoutes.tsx | 18 ++++- .../components/LocalAIDownloadSnackbar.tsx | 9 ++- .../hooks/__tests__/useSubconscious.test.ts | 15 ++++ app/src/hooks/useSubconscious.ts | 13 +++- app/src/pages/Brain.tsx | 20 +++++- app/src/pages/__tests__/Brain.test.tsx | 13 ++++ .../__tests__/OrchestrationRedirect.test.tsx | 72 +++++++++++++++++++ app/src/providers/CoreStateProvider.tsx | 11 +-- app/src/services/daemonHealthService.ts | 48 ++++++++++--- tests/json_rpc_e2e.rs | 4 ++ 10 files changed, 202 insertions(+), 21 deletions(-) create mode 100644 app/src/pages/__tests__/OrchestrationRedirect.test.tsx diff --git a/app/src/AppRoutes.tsx b/app/src/AppRoutes.tsx index a62f7bc15f..836b227fe0 100644 --- a/app/src/AppRoutes.tsx +++ b/app/src/AppRoutes.tsx @@ -46,24 +46,40 @@ interface AppRoutesProps { const NETWORK_SUBS = ['connections', 'discover', 'usage']; const ORCH_VIEWS = ['medulla', 'agent', 'overview', 'tasks', 'network']; -function OrchestrationRedirect() { +export function OrchestrationRedirect() { const { search } = useLocation(); const legacy = new URLSearchParams(search); const tab = legacy.get('tab'); + // Privacy-safe: only the allowlisted branch id and whether a session param was + // present are logged — never the session value or any raw query string. + console.debug( + '[routes] orchestration-redirect: entry tab=%s', + tab && ORCH_VIEWS.includes(tab) ? tab : tab && NETWORK_SUBS.includes(tab) ? tab : '' + ); const next = new URLSearchParams(); next.set('tab', 'orchestration'); + let branch: string; if (tab && NETWORK_SUBS.includes(tab)) { next.set('ov', 'network'); next.set('sub', tab); + branch = 'network-sub'; } else { if (tab && ORCH_VIEWS.includes(tab)) next.set('ov', tab); const sub = legacy.get('sub'); if (sub && NETWORK_SUBS.includes(sub)) next.set('sub', sub); + branch = tab && ORCH_VIEWS.includes(tab) ? 'view' : 'default'; } const session = legacy.get('session'); if (session) next.set('session', session); + console.debug( + '[routes] orchestration-redirect: exit branch=%s ov=%s hasSub=%s hasSession=%s', + branch, + next.get('ov') ?? '', + next.has('sub'), + next.has('session') + ); return ; } diff --git a/app/src/components/LocalAIDownloadSnackbar.tsx b/app/src/components/LocalAIDownloadSnackbar.tsx index 3ac116e970..5d62a0d393 100644 --- a/app/src/components/LocalAIDownloadSnackbar.tsx +++ b/app/src/components/LocalAIDownloadSnackbar.tsx @@ -105,7 +105,14 @@ const LocalAIDownloadSnackbar = () => { if (downloadsRes.result) setDownloads(downloadsRes.result); // The download reached a terminal state — stop the fast poll early // rather than waiting for core state to catch up (it lags up to ~5s). - settled = !isDownloadInFlight(statusRes.result ?? null, downloadsRes.result ?? null); + // A successful response that carries no `result` (a soft failure, not a + // thrown error) must be treated as transient, not as "download + // complete" — otherwise one empty blip would permanently freeze the + // fast poll for the rest of the download (same failure mode the catch + // below guards against for thrown errors). + if (statusRes.result || downloadsRes.result) { + settled = !isDownloadInFlight(statusRes.result ?? null, downloadsRes.result ?? null); + } } catch (err) { // Transient RPC failure (core may be busy). Do NOT treat this as // settled: keep polling while core state still reports the download diff --git a/app/src/hooks/__tests__/useSubconscious.test.ts b/app/src/hooks/__tests__/useSubconscious.test.ts index 9f86ce28dd..81d5282678 100644 --- a/app/src/hooks/__tests__/useSubconscious.test.ts +++ b/app/src/hooks/__tests__/useSubconscious.test.ts @@ -81,6 +81,21 @@ describe('useSubconscious', () => { expect(result.current.status).not.toBeNull(); }); + it('does not fetch or poll when disabled', async () => { + const { subconsciousStatus, openhumanHeartbeatSettingsGet } = + await import('../../utils/tauriCommands'); + const { result } = renderHook(() => useSubconscious(false)); + + await act(async () => { + // Advance well past the 5s poll interval — still no RPCs. + await vi.advanceTimersByTimeAsync(12000); + }); + + expect(subconsciousStatus).not.toHaveBeenCalled(); + expect(openhumanHeartbeatSettingsGet).not.toHaveBeenCalled(); + expect(result.current.status).toBeNull(); + }); + it('setMode calls heartbeat settings set', async () => { const { openhumanHeartbeatSettingsSet } = await import('../../utils/tauriCommands'); const { result } = renderHook(() => useSubconscious()); diff --git a/app/src/hooks/useSubconscious.ts b/app/src/hooks/useSubconscious.ts index 3849c90055..77d5e231ca 100644 --- a/app/src/hooks/useSubconscious.ts +++ b/app/src/hooks/useSubconscious.ts @@ -51,7 +51,15 @@ function deriveInstances(status: SubconsciousStatus | null): SubconsciousInstanc return [{ ...row, instance: row.instance ?? 'memory' }]; } -export function useSubconscious(): UseSubconsciousResult { +/** + * @param enabled When `false`, the hook skips its initial fetch and the 5s + * status poll (the imperative actions — `refresh`, `triggerTick`, `setMode`, + * `setIntervalMinutes` — still work if called). Callers that only render the + * subconscious surface on a specific tab pass `enabled` so opening an + * unrelated view (e.g. Brain's Orchestration sub-tab) doesn't keep unrelated + * heartbeat/subconscious RPCs polling. Defaults to `true` for back-compat. + */ +export function useSubconscious(enabled: boolean = true): UseSubconsciousResult { const [status, setStatus] = useState(null); const [mode, setModeState] = useState('off'); const [intervalMinutes, setIntervalState] = useState(30); @@ -149,13 +157,14 @@ export function useSubconscious(): UseSubconsciousResult { }, []); useEffect(() => { + if (!enabled) return; refresh(); const interval = setInterval(refresh, 5000); return () => { clearInterval(interval); fetchingRef.current = false; }; - }, [refresh]); + }, [refresh, enabled]); const isTriggering = useCallback( (kind: TriggerKind) => triggeringKinds.has(kind), diff --git a/app/src/pages/Brain.tsx b/app/src/pages/Brain.tsx index 344f30f158..b51d4e43c2 100644 --- a/app/src/pages/Brain.tsx +++ b/app/src/pages/Brain.tsx @@ -121,7 +121,10 @@ export default function Brain() { const { snapshot } = useCoreState(); const authUserId = snapshot.auth.userId; - const sub = useSubconscious(); + // Only poll subconscious/heartbeat status while its own tab is showing — the + // data is consumed nowhere else, so other tabs (incl. the folded-in + // Orchestration sub-tab) shouldn't keep those RPCs running. + const sub = useSubconscious(activeTab === 'subconscious'); const addToast = useCallback((toast: Omit) => { setToasts(prev => [...prev, { ...toast, id: `toast-${Date.now()}-${Math.random()}` }]); @@ -132,6 +135,15 @@ export default function Brain() { const refresh = useCallback(() => setRefreshKey(k => k + 1), []); useEffect(() => { + // Orchestration is a full-bleed sub-tab that never renders the memory graph, + // so skip the export RPC + memory-tree listener entirely while it's active. + // Without this, folding Orchestration under Brain would fire an unrelated + // graph load on every `/brain?tab=orchestration` (and redirected + // `/orchestration`) visit, which the old standalone page never did. + if (activeTab === 'orchestration') { + console.debug('[brain] graph fetch: skipped (orchestration tab)'); + return; + } let cancelled = false; const load = async () => { console.debug('[brain] graph fetch: entry mode=%s', mode); @@ -163,8 +175,10 @@ export default function Brain() { }; // `authUserId` is a dependency so a logout→login (identity becomes // available again) re-pulls the persisted graph instead of leaving the - // signed-out empty state on screen (#4149). - }, [mode, refreshKey, authUserId]); + // signed-out empty state on screen (#4149). `activeTab` gates the fetch off + // on the Orchestration sub-tab (and re-runs it when returning to a + // graph-bearing tab). + }, [mode, refreshKey, authUserId, activeTab]); const cardClass = 'rounded-lg border border-line bg-surface p-4'; diff --git a/app/src/pages/__tests__/Brain.test.tsx b/app/src/pages/__tests__/Brain.test.tsx index 2a7de73e2f..c81e7f5cef 100644 --- a/app/src/pages/__tests__/Brain.test.tsx +++ b/app/src/pages/__tests__/Brain.test.tsx @@ -195,6 +195,19 @@ describe('Brain page', () => { }); }); + it('does not fetch the memory graph on the orchestration tab', async () => { + graphExportMock.mockResolvedValue(makeGraph(0)); + await act(async () => { + renderWithProviders(, { initialEntries: ['/?tab=orchestration'] }); + }); + await waitFor(() => { + expect(screen.getByTestId('brain-orchestration')).toBeInTheDocument(); + }); + // Folding Orchestration under Brain must not trigger the unrelated + // memoryTreeGraphExport RPC that the standalone page never issued. + expect(graphExportMock).not.toHaveBeenCalled(); + }); + it('redirects the legacy tinyplace-orchestration deep link to the orchestration tab', async () => { graphExportMock.mockResolvedValue(makeGraph(0)); await act(async () => { diff --git a/app/src/pages/__tests__/OrchestrationRedirect.test.tsx b/app/src/pages/__tests__/OrchestrationRedirect.test.tsx new file mode 100644 index 0000000000..f35fa3bff1 --- /dev/null +++ b/app/src/pages/__tests__/OrchestrationRedirect.test.tsx @@ -0,0 +1,72 @@ +/** + * OrchestrationRedirect maps the retired `/orchestration` route (and its legacy + * `?tab=`/`?sub=`/`?session=` query) onto Brain's `/brain?tab=orchestration` + * with the `?ov=`/`?sub=` scheme. Rendered under a MemoryRouter with a `/brain` + * sink route that reports the resolved URL so each mapping branch is asserted. + */ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { describe, expect, it } from 'vitest'; + +import { OrchestrationRedirect } from '../../AppRoutes'; + +function BrainProbe() { + const { pathname, search } = useLocation(); + return
{`${pathname}${search}`}
; +} + +const resolve = (from: string): string => { + render( + + + } /> + } /> + + + ); + const target = screen.getByTestId('brain').textContent ?? ''; + const params = new URLSearchParams(target.split('?')[1] ?? ''); + return `${target.split('?')[0]}?${params.toString()}`; +}; + +describe('OrchestrationRedirect', () => { + it('bare /orchestration → the orchestration tab (no view override)', () => { + const params = new URLSearchParams(resolve('/orchestration').split('?')[1]); + expect(params.get('tab')).toBe('orchestration'); + expect(params.get('ov')).toBeNull(); + }); + + it.each(['agent', 'overview', 'tasks', 'network', 'medulla'])('legacy ?tab=%s → ?ov=%s', view => { + const params = new URLSearchParams(resolve(`/orchestration?tab=${view}`).split('?')[1]); + expect(params.get('tab')).toBe('orchestration'); + expect(params.get('ov')).toBe(view); + }); + + it.each(['connections', 'discover', 'usage'])('legacy ?tab=%s → ?ov=network&sub=%s', sub => { + const params = new URLSearchParams(resolve(`/orchestration?tab=${sub}`).split('?')[1]); + expect(params.get('ov')).toBe('network'); + expect(params.get('sub')).toBe(sub); + }); + + it('preserves a network ?sub= when landing on ?tab=network', () => { + const params = new URLSearchParams( + resolve('/orchestration?tab=network&sub=usage').split('?')[1] + ); + expect(params.get('ov')).toBe('network'); + expect(params.get('sub')).toBe('usage'); + }); + + it('preserves ?session= for the agent chat', () => { + const params = new URLSearchParams( + resolve('/orchestration?tab=agent&session=sess-1').split('?')[1] + ); + expect(params.get('ov')).toBe('agent'); + expect(params.get('session')).toBe('sess-1'); + }); + + it('ignores an unknown ?tab= (falls back to the orchestration tab)', () => { + const params = new URLSearchParams(resolve('/orchestration?tab=bogus').split('?')[1]); + expect(params.get('tab')).toBe('orchestration'); + expect(params.get('ov')).toBeNull(); + }); +}); diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx index e6274ac594..a60be32034 100644 --- a/app/src/providers/CoreStateProvider.tsx +++ b/app/src/providers/CoreStateProvider.tsx @@ -353,10 +353,11 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) // Feed the folded health payload to the daemon-health store (replaces the // former standalone health_snapshot poll). Done AFTER the commit and only - // when this refresh is still current, so `daemonHealthService` resolves the - // freshly-committed identity — not a stale/pre-commit or superseded token, - // which during a login/identity flip would write health under the prior or - // `__pending__` user. + // when this refresh is still current. The resolved `sessionToken` for THIS + // refresh is passed explicitly: `commitState` writes the non-React snapshot + // store inside a (deferred) React `setState` updater, so reading identity + // from that store here would still see the pre-commit token and, during a + // login/identity flip, write health under the prior or `__pending__` user. if (requestId === snapshotRequestIdRef.current) { // Privacy-safe: log presence/shape only, never the payload/tokens. log( @@ -366,7 +367,7 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) rawSnapshot.health != null, rawSnapshot.health ? Object.keys(rawSnapshot.health.components ?? {}).length : 0 ); - daemonHealthService.ingestHealthSnapshot(rawSnapshot.health); + daemonHealthService.ingestHealthSnapshot(rawSnapshot.health, nextSnapshot.sessionToken); } else { log( 'health ingest skipped: superseded refresh requestId=%d current=%d', diff --git a/app/src/services/daemonHealthService.ts b/app/src/services/daemonHealthService.ts index f1d0be0ff5..f6a80d19f2 100644 --- a/app/src/services/daemonHealthService.ts +++ b/app/src/services/daemonHealthService.ts @@ -40,7 +40,10 @@ export class DaemonHealthService { */ ensureWatchdogArmed(): void { if (this.healthTimeoutId === null) { + console.debug('[DaemonHealth] watchdog: initial arm (no existing timer)'); this.startHealthTimeout(); + } else { + console.debug('[DaemonHealth] watchdog: already armed, no-op'); } } @@ -53,13 +56,29 @@ export class DaemonHealthService { * otherwise a live-but-health-less core would eventually be marked * `disconnected`. The daemon store is only updated when a valid health * snapshot is present; otherwise it keeps its last-known state. + * + * `sessionToken` is the token resolved by the *current* snapshot refresh. + * Passing it explicitly is load-bearing: `CoreStateProvider` commits the new + * identity via React `setState`, whose updater (and the non-React + * `setCoreStateSnapshot` it calls) runs *deferred*, so at ingest time the + * store still holds the previous token. Deriving the user from the store here + * would file this health payload under the prior / `__pending__` user during a + * login/identity flip. When omitted (e.g. the hook's baseline arm), we fall + * back to the store token. */ - ingestHealthSnapshot(payload: unknown): void { + ingestHealthSnapshot(payload: unknown, sessionToken?: string | null): void { // Called by CoreStateProvider only after a successful snapshot → liveness. - this.startHealthTimeout(); + this.startHealthTimeout(sessionToken); const healthSnapshot = this.parseHealthSnapshot(payload); + console.debug( + '[DaemonHealth] ingest: hasPayload=%s parsed=%s components=%d storeUpdate=%s', + payload != null, + healthSnapshot != null, + healthSnapshot ? Object.keys(healthSnapshot.components).length : 0, + healthSnapshot != null + ); if (healthSnapshot) { - this.updateDaemonStoreFromHealth(healthSnapshot); + this.updateDaemonStoreFromHealth(healthSnapshot, sessionToken); } } @@ -129,20 +148,23 @@ export class DaemonHealthService { } } - private updateDaemonStoreFromHealth(snapshot: HealthSnapshot): void { + private updateDaemonStoreFromHealth( + snapshot: HealthSnapshot, + sessionToken?: string | null + ): void { try { - updateHealthSnapshot(this.getUserId(), snapshot); + updateHealthSnapshot(this.getUserId(sessionToken), snapshot); } catch (error) { console.error('[DaemonHealth] Error updating daemon store from health:', error); } } - private startHealthTimeout(): void { + private startHealthTimeout(sessionToken?: string | null): void { if (this.healthTimeoutId) { clearTimeout(this.healthTimeoutId); } - const userId = this.getUserId(); + const userId = this.getUserId(sessionToken); this.healthTimeoutId = setTimeout(() => { console.warn('[DaemonHealth] Health timeout reached - setting status to disconnected'); setDaemonStatus(userId, 'disconnected'); @@ -150,8 +172,16 @@ export class DaemonHealthService { }, this.HEALTH_TIMEOUT_MS); } - private getUserId(): string { - const token = getCoreStateSnapshot().snapshot.sessionToken; + /** + * Resolve the destination user for health writes. When the caller supplies the + * refresh's own `sessionToken` (even `null`, meaning signed-out), it wins over + * the store — see {@link ingestHealthSnapshot} for why the store token is + * stale during an identity flip. `undefined` means "no override", so fall back + * to the store. + */ + private getUserId(tokenOverride?: string | null): string { + const token = + tokenOverride !== undefined ? tokenOverride : getCoreStateSnapshot().snapshot.sessionToken; if (!token) { return '__pending__'; } diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index bba393e0ef..28f82bf034 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -6084,6 +6084,10 @@ async fn json_rpc_app_state_snapshot_returns_runtime_shape() { health.get("pid").and_then(Value::as_u64).is_some(), "expected health.pid: {health}" ); + assert!( + health.get("updated_at").and_then(Value::as_str).is_some(), + "expected health.updated_at (snake_case): {health}" + ); assert!( health .get("uptime_seconds") From 00846fdfd052eee4a0ca54112537ecbc6c0eb5c9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:03:01 +0300 Subject: [PATCH 09/56] =?UTF-8?q?feat(transcript):=20derived=20transcript?= =?UTF-8?q?=20view=20=E2=80=94=20append-only=20session=20log=20as=20source?= =?UTF-8?q?=20of=20truth=20(#5086)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/conversations/Conversations.tsx | 10 +- .../derived/derivedRestore.render.test.tsx | 78 ++ .../derived/mapDisplayItems.test.ts | 305 +++++ .../conversations/derived/mapDisplayItems.ts | 371 ++++++ app/src/providers/ChatRuntimeProvider.tsx | 11 +- app/src/services/api/threadApi.test.ts | 35 + app/src/services/api/threadApi.ts | 25 + .../chatRuntimeSlice.derived.thunk.test.ts | 141 +++ app/src/store/chatRuntimeSlice.ts | 170 +++ app/src/test/setup.ts | 1 + app/src/types/derivedTranscript.ts | 158 +++ app/src/utils/config.ts | 16 + docs/plans/transcript-derived-view.md | 106 ++ .../agent/harness/session/builder/setters.rs | 1 + src/openhuman/agent/harness/session/tests.rs | 75 ++ .../agent/harness/session/transcript.rs | 1000 +++++++++++++---- .../agent/harness/session/transcript_tests.rs | 364 ++++++ .../agent/harness/session/turn/core.rs | 67 +- .../agent/harness/session/turn/session_io.rs | 24 +- src/openhuman/agent/harness/session/types.rs | 9 +- src/openhuman/agent/turn_origin.rs | 12 + src/openhuman/threads/mod.rs | 1 + src/openhuman/threads/ops.rs | 51 + src/openhuman/threads/schemas.rs | 44 + src/openhuman/threads/schemas_tests.rs | 1 + .../threads/transcript_view/cache.rs | 152 +++ .../threads/transcript_view/cache_tests.rs | 74 ++ src/openhuman/threads/transcript_view/mod.rs | 105 ++ .../threads/transcript_view/project.rs | 499 ++++++++ .../threads/transcript_view/tests.rs | 435 +++++++ .../threads/transcript_view/types.rs | 139 +++ src/openhuman/threads/turn_state/mirror.rs | 63 ++ .../threads/turn_state/mirror_tests.rs | 135 +++ src/openhuman/threads/turn_state/store.rs | 7 + tests/json_rpc_e2e.rs | 218 ++++ 35 files changed, 4697 insertions(+), 206 deletions(-) create mode 100644 app/src/features/conversations/derived/derivedRestore.render.test.tsx create mode 100644 app/src/features/conversations/derived/mapDisplayItems.test.ts create mode 100644 app/src/features/conversations/derived/mapDisplayItems.ts create mode 100644 app/src/store/__tests__/chatRuntimeSlice.derived.thunk.test.ts create mode 100644 app/src/types/derivedTranscript.ts create mode 100644 docs/plans/transcript-derived-view.md create mode 100644 src/openhuman/threads/transcript_view/cache.rs create mode 100644 src/openhuman/threads/transcript_view/cache_tests.rs create mode 100644 src/openhuman/threads/transcript_view/mod.rs create mode 100644 src/openhuman/threads/transcript_view/project.rs create mode 100644 src/openhuman/threads/transcript_view/tests.rs create mode 100644 src/openhuman/threads/transcript_view/types.rs diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index b5a74e1dd3..7f0334071f 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -103,7 +103,7 @@ import { clearRuntimeForThread, clearThreadSendPending, enqueueFollowup, - fetchAndHydrateTurnHistory, + fetchAndHydrateDerivedTranscript, fetchAndHydrateTurnState, hydrateThreadUsage, markSubagentCancelled, @@ -776,8 +776,12 @@ const Conversations = ({ if (selectedThreadId) { void dispatch(loadThreadMessages(selectedThreadId)); void dispatch(fetchAndHydrateTurnState(selectedThreadId)); - // Per-turn history: each past answer's own process trail (Phase 5). - void dispatch(fetchAndHydrateTurnHistory(selectedThreadId)); + // Per-turn history: each past answer's own process trail. Phase C derives + // this from the append-only transcript projection + // (`threads_transcript_get`), auto-falling back to the legacy + // `turn_state_history` hydration when the derived path is off, errors, or + // the thread has no persisted transcript (legacy thread). + void dispatch(fetchAndHydrateDerivedTranscript(selectedThreadId)); void threadApi .getTaskBoard(selectedThreadId) .then(board => { diff --git a/app/src/features/conversations/derived/derivedRestore.render.test.tsx b/app/src/features/conversations/derived/derivedRestore.render.test.tsx new file mode 100644 index 0000000000..02490e8ec1 --- /dev/null +++ b/app/src/features/conversations/derived/derivedRestore.render.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { describe, expect, it } from 'vitest'; + +import { store } from '../../../store'; +import type { DerivedDisplayItem } from '../../../types/derivedTranscript'; +import { PastTurnInsights } from '../components/PastTurnInsights'; +import { mapDisplayItems } from './mapDisplayItems'; + +function renderInStore(ui: React.ReactNode) { + return render({ui}); +} + +/** Newest-first page from chronological items (as the RPC returns). */ +function newestFirst(chronological: DerivedDisplayItem[]): DerivedDisplayItem[] { + return [...chronological].reverse(); +} + +describe('derived transcript restore (mapper → PastTurnInsights)', () => { + it('renders a restored turn with reasoning, tool rows, and a sub-agent trail', () => { + // A settled turn's projected display items, exactly as `threads_transcript_get` + // returns them (newest-first). This is a NON-newest turn so the mapper keeps it. + const chronological: DerivedDisplayItem[] = [ + { kind: 'turnBoundary', requestId: 'req-1' }, + { kind: 'userMessage', content: 'research this', requestId: 'req-1' }, + { kind: 'reasoning', text: 'planning the research' }, + { kind: 'assistantMessage', content: 'searching now', interim: true, requestId: 'req-1' }, + { + kind: 'toolCall', + callId: 'c1', + name: 'read_file', + args: { path: 'notes.md' }, + result: 'ok', + status: 'success', + }, + { + kind: 'subagent', + id: 'researcher', + items: [ + { kind: 'reasoning', text: 'child reasoning trail' }, + { + kind: 'toolCall', + callId: 'child-1', + name: 'web_search', + args: { q: 'topic' }, + result: 'hits', + status: 'success', + }, + ], + }, + { kind: 'assistantMessage', content: 'the final answer', requestId: 'req-1' }, + // A later turn so req-1 is not the newest (which the mapper would skip). + { kind: 'turnBoundary', requestId: 'req-2' }, + { kind: 'reasoning', text: 'newest turn thought' }, + ]; + + const { timelines, transcripts } = mapDisplayItems(newestFirst(chronological)); + + renderInStore( + + ); + + // Reasoning replays. + expect(screen.getByTestId('processing-thinking').textContent).toContain( + 'planning the research' + ); + // Interim narration replays (not the final answer — that renders from the message). + expect(screen.getByTestId('processing-transcript').textContent).toContain('searching now'); + expect(screen.getByTestId('processing-transcript').textContent).not.toContain( + 'the final answer' + ); + // Tool rows render. + expect(screen.getAllByTestId('processing-tool-row').length).toBeGreaterThan(0); + // The sub-agent's own reasoning trail renders beneath. + const subagents = screen.getByTestId('past-turn-subagents'); + expect(subagents.textContent).toContain('child reasoning trail'); + }); +}); diff --git a/app/src/features/conversations/derived/mapDisplayItems.test.ts b/app/src/features/conversations/derived/mapDisplayItems.test.ts new file mode 100644 index 0000000000..881af64dcb --- /dev/null +++ b/app/src/features/conversations/derived/mapDisplayItems.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, it } from 'vitest'; + +import type { DerivedDisplayItem } from '../../../types/derivedTranscript'; +import { mapDisplayItems } from './mapDisplayItems'; + +/** + * Build a newest-first page (as the RPC returns) from chronological items — the + * mapper is responsible for reversing back to display order. + */ +function newestFirst(chronological: DerivedDisplayItem[]): DerivedDisplayItem[] { + return [...chronological].reverse(); +} + +describe('mapDisplayItems', () => { + it('projects reasoning + interim narration + tool call for one turn, skipping the final answer', () => { + const chronological: DerivedDisplayItem[] = [ + { kind: 'turnBoundary', requestId: 'req-1' }, + { kind: 'userMessage', content: 'hello', requestId: 'req-1' }, + { kind: 'reasoning', text: 'let me think' }, + { kind: 'assistantMessage', content: 'looking it up', interim: true, requestId: 'req-1' }, + { + kind: 'toolCall', + callId: 'call-a', + name: 'shell', + args: { cmd: 'ls' }, + result: 'file.txt', + status: 'success', + }, + { kind: 'assistantMessage', content: 'here is the answer', requestId: 'req-1' }, + ]; + + const { timelines, transcripts, interrupted } = mapDisplayItems(newestFirst(chronological)); + + // The final (non-interim) answer and the user text are NOT emitted — they + // render from the thread message list. + expect(interrupted).toEqual([]); + expect(Object.keys(transcripts)).toEqual(['req-1']); + expect(transcripts['req-1']).toEqual([ + { kind: 'thinking', round: 0, seq: 0, text: 'let me think' }, + { kind: 'narration', round: 0, seq: 1, text: 'looking it up' }, + { kind: 'toolCall', round: 0, seq: 2, callId: 'call-a' }, + ]); + expect(timelines['req-1']).toEqual([ + expect.objectContaining({ + id: 'call-a', + name: 'shell', + seq: 2, + status: 'success', + argsBuffer: JSON.stringify({ cmd: 'ls' }), + result: 'file.txt', + }), + ]); + }); + + it('preserves chronological (issue) order when reversing a newest-first page across turns', () => { + const chronological: DerivedDisplayItem[] = [ + { kind: 'turnBoundary', requestId: 'req-1' }, + { kind: 'reasoning', text: 'turn one thought' }, + { kind: 'turnBoundary', requestId: 'req-2' }, + { kind: 'reasoning', text: 'turn two thought' }, + ]; + + const { transcripts } = mapDisplayItems(newestFirst(chronological)); + + expect(Object.keys(transcripts).sort()).toEqual(['req-1', 'req-2']); + expect(transcripts['req-1']).toEqual([ + { kind: 'thinking', round: 0, seq: 0, text: 'turn one thought' }, + ]); + expect(transcripts['req-2']).toEqual([ + { kind: 'thinking', round: 0, seq: 0, text: 'turn two thought' }, + ]); + }); + + it('maps a running (unpaired) tool call to a settled cancelled row', () => { + const chronological: DerivedDisplayItem[] = [ + { kind: 'turnBoundary', requestId: 'req-1' }, + { kind: 'toolCall', callId: 'call-x', name: 'shell', status: 'running' }, + ]; + + const { timelines } = mapDisplayItems(newestFirst(chronological)); + + expect(timelines['req-1'][0]).toEqual( + expect.objectContaining({ id: 'call-x', status: 'cancelled' }) + ); + }); + + it('maps an error tool call to an error row', () => { + const chronological: DerivedDisplayItem[] = [ + { kind: 'turnBoundary', requestId: 'req-1' }, + { kind: 'toolCall', callId: 'call-e', name: 'shell', status: 'error', result: 'boom' }, + ]; + + const { timelines } = mapDisplayItems(newestFirst(chronological)); + + expect(timelines['req-1'][0]).toEqual( + expect.objectContaining({ id: 'call-e', status: 'error', result: 'boom' }) + ); + }); + + it('maps a failed tool call onto a ToolFailureExplanation for ToolFailureLines', () => { + const chronological: DerivedDisplayItem[] = [ + { kind: 'turnBoundary', requestId: 'req-1' }, + { + kind: 'toolCall', + callId: 'call-e', + name: 'shell', + status: 'error', + result: 'boom', + failure: { detail: 'exit 1: command not found' }, + }, + ]; + + const { timelines } = mapDisplayItems(newestFirst(chronological)); + const row = timelines['req-1'][0]; + + expect(row.status).toBe('error'); + expect(row.failure).toBeDefined(); + // The wire detail becomes the `causePlain` the ToolFailureLines renderer + // shows for an unrecognised failure class. + expect(row.failure?.causePlain).toBe('exit 1: command not found'); + expect(typeof row.failure?.class).toBe('string'); + expect(typeof row.failure?.nextAction).toBe('string'); + }); + + it('falls back to the tool result as failure cause when no detail was captured', () => { + const chronological: DerivedDisplayItem[] = [ + { kind: 'turnBoundary', requestId: 'req-1' }, + { + kind: 'toolCall', + callId: 'call-e', + name: 'shell', + status: 'error', + result: 'raw error text', + failure: {}, + }, + ]; + + const { timelines } = mapDisplayItems(newestFirst(chronological)); + + expect(timelines['req-1'][0].failure?.causePlain).toBe('raw error text'); + }); + + it('derives displayName/detail for a tool row (parity with turn_state rows)', () => { + const chronological: DerivedDisplayItem[] = [ + { kind: 'turnBoundary', requestId: 'req-1' }, + { + kind: 'toolCall', + callId: 'c1', + name: 'shell', + args: { command: 'ls -la' }, + result: 'ok', + status: 'success', + }, + ]; + + const { timelines } = mapDisplayItems(newestFirst(chronological)); + const row = timelines['req-1'][0]; + + expect(typeof row.displayName).toBe('string'); + expect(row.displayName?.length ?? 0).toBeGreaterThan(0); + }); + + it('anchors a subagent to its own requestId, not the current turn cursor', () => { + // The subagent item is appended after both turns (as the projection emits + // it) but belongs to req-1 via its core-derived requestId. + const chronological: DerivedDisplayItem[] = [ + { kind: 'turnBoundary', requestId: 'req-1' }, + { kind: 'reasoning', text: 'turn one' }, + { kind: 'turnBoundary', requestId: 'req-2' }, + { kind: 'reasoning', text: 'turn two' }, + { + kind: 'subagent', + id: 'coder', + requestId: 'req-1', + items: [{ kind: 'assistantMessage', content: 'sub done', iteration: 1 }], + }, + ]; + + const { timelines } = mapDisplayItems(newestFirst(chronological)); + + expect(timelines['req-1']?.some(e => e.name === 'subagent:coder')).toBe(true); + expect(timelines['req-2']?.some(e => e.name === 'subagent:coder')).toBeFalsy(); + }); + + it('projects a subagent item into a timeline row carrying its activity + transcript', () => { + const chronological: DerivedDisplayItem[] = [ + { kind: 'turnBoundary', requestId: 'req-1' }, + { + kind: 'subagent', + id: 'researcher', + items: [ + { kind: 'reasoning', text: 'child thinking' }, + { kind: 'assistantMessage', content: 'child answer', iteration: 1 }, + { + kind: 'toolCall', + callId: 'child-call', + name: 'web_search', + args: { q: 'x' }, + result: 'hits', + status: 'success', + }, + ], + }, + ]; + + const { timelines } = mapDisplayItems(newestFirst(chronological)); + const row = timelines['req-1'][0]; + + expect(row.name).toBe('subagent:researcher'); + expect(row.subagent).toBeDefined(); + expect(row.subagent?.agentId).toBe('researcher'); + expect(row.subagent?.toolCalls).toEqual([ + expect.objectContaining({ callId: 'child-call', toolName: 'web_search', status: 'success' }), + ]); + expect(row.subagent?.transcript).toEqual([ + { kind: 'thinking', text: 'child thinking' }, + { kind: 'text', iteration: 1, text: 'child answer' }, + expect.objectContaining({ kind: 'tool', callId: 'child-call', toolName: 'web_search' }), + ]); + }); + + it('collects interrupted partials by requestId and never emits them as trail items', () => { + const chronological: DerivedDisplayItem[] = [ + { kind: 'turnBoundary', requestId: 'req-1' }, + { kind: 'interruptedPartial', text: 'half an ans', thinking: 'mid thought' }, + ]; + + const { transcripts, timelines, interrupted } = mapDisplayItems(newestFirst(chronological)); + + expect(interrupted).toEqual([ + { requestId: 'req-1', content: 'half an ans', thinking: 'mid thought' }, + ]); + expect(transcripts['req-1']).toBeUndefined(); + expect(timelines['req-1']).toBeUndefined(); + }); + + it('drops compaction markers (no settled-turn renderer)', () => { + const chronological: DerivedDisplayItem[] = [ + { kind: 'turnBoundary', requestId: 'req-1' }, + { kind: 'compaction', replacedCount: 3, keptCount: 1 }, + { kind: 'reasoning', text: 'after compaction' }, + ]; + + const { transcripts } = mapDisplayItems(newestFirst(chronological)); + + expect(transcripts['req-1']).toEqual([ + { kind: 'thinking', round: 0, seq: 0, text: 'after compaction' }, + ]); + }); + + it('does not emit the final assistant or user text as trail items (dedupe vs thread messages)', () => { + const chronological: DerivedDisplayItem[] = [ + { kind: 'turnBoundary', requestId: 'req-1' }, + { + kind: 'userMessage', + content: 'a question', + displayContent: 'a question', + requestId: 'req-1', + }, + { kind: 'assistantMessage', content: 'a final answer', requestId: 'req-1' }, + ]; + + const { transcripts, timelines } = mapDisplayItems(newestFirst(chronological)); + + expect(transcripts['req-1']).toBeUndefined(); + expect(timelines['req-1']).toBeUndefined(); + }); + + it('omits skipped request ids entirely', () => { + const chronological: DerivedDisplayItem[] = [ + { kind: 'turnBoundary', requestId: 'req-old' }, + { kind: 'reasoning', text: 'old thought' }, + { kind: 'turnBoundary', requestId: 'req-live' }, + { kind: 'reasoning', text: 'live thought' }, + ]; + + const { transcripts } = mapDisplayItems(newestFirst(chronological), { + skipRequestIds: new Set(['req-live']), + }); + + expect(Object.keys(transcripts)).toEqual(['req-old']); + expect(transcripts['req-live']).toBeUndefined(); + }); + + it('carries the assistant iteration onto the turn round for its items', () => { + const chronological: DerivedDisplayItem[] = [ + { kind: 'turnBoundary', requestId: 'req-1' }, + { + kind: 'assistantMessage', + content: 'step', + interim: true, + iteration: 2, + requestId: 'req-1', + }, + { kind: 'toolCall', callId: 'c1', name: 'shell', status: 'success' }, + ]; + + const { transcripts, timelines } = mapDisplayItems(newestFirst(chronological)); + + expect(transcripts['req-1'][0]).toEqual( + expect.objectContaining({ kind: 'narration', round: 2 }) + ); + expect(timelines['req-1'][0].round).toBe(2); + }); +}); diff --git a/app/src/features/conversations/derived/mapDisplayItems.ts b/app/src/features/conversations/derived/mapDisplayItems.ts new file mode 100644 index 0000000000..3e9e9adef5 --- /dev/null +++ b/app/src/features/conversations/derived/mapDisplayItems.ts @@ -0,0 +1,371 @@ +/** + * Phase C mapper: project the transcript-derived RPC's newest-first + * {@link DerivedDisplayItem}s onto the **existing** settled-turn renderer + * models, keyed by producing `requestId` — the exact shapes + * `fetchAndHydrateTurnHistory` produces from the legacy `turn_state_history` + * snapshot ring, so `PastTurnInsights` / `ProcessingTranscriptView` / + * `ToolTimelineBlock` / `SubagentActivityBlock` are reused unchanged. + * + * Division of labour (matches how `turnTimelinesByThread` / `PastTurnInsights` + * anchor today): + * - **Final assistant text and user text are NOT emitted here** — they stay + * rendered from the thread message list (`threads_messages_list`). This + * mapper only produces the *process trail* (reasoning, interim narration, + * tool cards, sub-agent trails) that renders above a past answer. + * - Items are grouped per `requestId` (from `turnBoundary` markers and each + * message's own `requestId`) so the caller can anchor a turn's trail to its + * first agent message by `requestId`. + * + * Live streaming is untouched: the caller skips the live/most-recent turn's + * `requestId` so derived data never fights socket-fed `chatRuntimeSlice` state. + */ +import debug from 'debug'; + +import type { + ProcessingTranscriptItem, + SubagentActivity, + SubagentToolCallEntry, + SubagentTranscriptItem, + ToolFailureExplanation, + ToolTimelineEntry, + ToolTimelineEntryStatus, +} from '../../../store/chatRuntimeSlice'; +import type { + DerivedDisplayItem, + DerivedToolCall, + DerivedToolCallStatus, + DerivedToolFailure, +} from '../../../types/derivedTranscript'; +import { formatTimelineEntry } from '../../../utils/toolTimelineFormatting'; + +const log = debug('conversations.derived.mapDisplayItems'); + +/** A partial assistant answer left behind by an interrupted turn. */ +export interface DerivedInterruptedAnswer { + requestId: string; + content: string; + thinking: string; +} + +/** + * Per-thread settled-turn process trails derived from the transcript + * projection, ready to feed `setTurnTimelinesForThread` (timelines + + * transcripts) and, for completeness, any interrupted partials found. + */ +export interface MappedTranscript { + /** `requestId -> tool timeline rows` for each settled turn. */ + timelines: Record; + /** `requestId -> processing transcript` (narration / thinking / tool ptr). */ + transcripts: Record; + /** + * Interrupted partials found in the derived data, in chronological order. + * Exposed for a future phase; the hydration thunk deliberately leaves the + * live/current-turn interrupted partial owned by the `turn_state` snapshot + * path so it never fights live state. + */ + interrupted: DerivedInterruptedAnswer[]; +} + +/** Options controlling {@link mapDisplayItems}. */ +export interface MapDisplayItemsOptions { + /** + * Request ids to omit from the output entirely — the live/most-recent turn + * (rendered from socket-fed state / the turn_state snapshot) and any turn + * currently streaming. Their trails must not double-render against the live + * anchor. + */ + skipRequestIds?: ReadonlySet; +} + +/** Map the Rust `ToolCallStatus` onto the timeline status vocabulary. A settled + * turn whose tool row is still `running` had no result line paired (the turn + * was interrupted before completion) — settle it to `cancelled` (terminal, + * muted, non-pulsing), mirroring `settleOrphanedTimelineEntry`. */ +function timelineStatusFromDerived(status: DerivedToolCallStatus): ToolTimelineEntryStatus { + switch (status) { + case 'success': + return 'success'; + case 'error': + return 'error'; + case 'running': + default: + return 'cancelled'; + } +} + +/** Same mapping for a sub-agent child tool call. */ +function subagentToolStatus(status: DerivedToolCallStatus): ToolTimelineEntryStatus { + return timelineStatusFromDerived(status); +} + +/** + * Expand the minimal wire {@link DerivedToolFailure} into the richer + * {@link ToolFailureExplanation} the `ToolFailureLines` renderer consumes, + * matching `turn_state`'s `PersistedToolFailure` shape. The projection only + * records that a call failed plus an optional short reason, so we synthesise an + * unlocalized `Unknown`/`Recoverable` explanation whose `causePlain` carries the + * captured detail (or the tool's error output) — `ToolFailureLines` falls back + * to `causePlain`/`nextAction` for unrecognised classes. + */ +function toFailureExplanation( + failure: DerivedToolFailure | undefined, + result: string | undefined +): ToolFailureExplanation | undefined { + if (!failure) return undefined; + const causePlain = failure.detail?.trim() || result?.trim() || 'The tool reported an error.'; + return { + class: 'Unknown', + category: 'Recoverable', + recoverable: true, + causePlain, + nextAction: 'Review the tool output and try again.', + }; +} + +function stringifyArgs(args: unknown): string | undefined { + if (args === undefined || args === null) return undefined; + if (typeof args === 'string') return args; + try { + return JSON.stringify(args); + } catch { + return undefined; + } +} + +/** + * Build a {@link SubagentActivity} from a `subagent` display item's nested + * items. The nested vocabulary (reasoning / assistantMessage / toolCall) + * projects onto the sub-agent transcript (`thinking` / `text` / `tool`) plus a + * flat `toolCalls` list — exactly what `SubagentActivityBlock` reads. + */ +function buildSubagentActivity(id: string, items: DerivedDisplayItem[]): SubagentActivity { + const toolCalls: SubagentToolCallEntry[] = []; + const transcript: SubagentTranscriptItem[] = []; + + for (const item of items) { + switch (item.kind) { + case 'reasoning': + if (item.text.trim()) { + transcript.push({ kind: 'thinking', text: item.text }); + } + break; + case 'assistantMessage': + // A sub-agent's own answer text is part of its inline trail (there is + // no separate message bubble for a delegated worker). + if (item.content.trim()) { + transcript.push({ kind: 'text', iteration: item.iteration, text: item.content }); + } + break; + case 'toolCall': { + const status = subagentToolStatus(item.status); + toolCalls.push({ + callId: item.callId, + toolName: item.name, + status, + args: item.args, + result: item.result, + }); + transcript.push({ + kind: 'tool', + callId: item.callId, + toolName: item.name, + status, + args: item.args, + result: item.result, + }); + break; + } + // Nested sub-agents, turn boundaries, interrupted partials and compaction + // markers do not surface inside a sub-agent block — the projection nests + // deeper sub-agents as their own top-level items. + default: + break; + } + } + + return { taskId: id, agentId: id, status: 'completed', toolCalls, transcript }; +} + +/** Mutable per-turn accumulator. */ +interface TurnAccumulator { + entries: ToolTimelineEntry[]; + transcript: ProcessingTranscriptItem[]; + seq: number; + round: number; +} + +function ensureTurn(turns: Map, requestId: string): TurnAccumulator { + let turn = turns.get(requestId); + if (!turn) { + turn = { entries: [], transcript: [], seq: 0, round: 0 }; + turns.set(requestId, turn); + } + return turn; +} + +/** + * Map a newest-first page of derived display items into per-`requestId` + * settled-turn trails. Returns empty maps when nothing anchors to a `requestId` + * (e.g. a legacy thread whose lines carry no `requestId`). + */ +export function mapDisplayItems( + items: DerivedDisplayItem[], + options: MapDisplayItemsOptions = {} +): MappedTranscript { + const skip = options.skipRequestIds ?? new Set(); + const turns = new Map(); + const interrupted: DerivedInterruptedAnswer[] = []; + + // The RPC returns items newest-first; walk chronologically so `seq` reflects + // issue order and turn boundaries advance forward. + const chronological = [...items].reverse(); + let currentRequestId: string | undefined; + + const skipped = new Set(); + + for (const item of chronological) { + switch (item.kind) { + case 'turnBoundary': + currentRequestId = item.requestId; + break; + + case 'userMessage': + // User text renders from the thread message list; only advance the + // turn cursor so following items anchor to this turn. + if (item.requestId) currentRequestId = item.requestId; + break; + + case 'assistantMessage': { + if (item.requestId) currentRequestId = item.requestId; + if (item.iteration !== undefined && currentRequestId && !skip.has(currentRequestId)) { + ensureTurn(turns, currentRequestId).round = item.iteration; + } + // Final (non-interim) answer renders from the thread message; only + // interim narration belongs to the process trail. + if (!item.interim) break; + if (!currentRequestId || skip.has(currentRequestId)) { + if (currentRequestId) skipped.add(currentRequestId); + break; + } + const text = item.content.trim(); + if (!text) break; + const turn = ensureTurn(turns, currentRequestId); + turn.transcript.push({ + kind: 'narration', + round: turn.round, + seq: turn.seq++, + text: item.content, + }); + break; + } + + case 'reasoning': { + if (!currentRequestId || skip.has(currentRequestId)) { + if (currentRequestId) skipped.add(currentRequestId); + break; + } + if (!item.text.trim()) break; + const turn = ensureTurn(turns, currentRequestId); + turn.transcript.push({ + kind: 'thinking', + round: turn.round, + seq: turn.seq++, + text: item.text, + }); + break; + } + + case 'toolCall': { + if (!currentRequestId || skip.has(currentRequestId)) { + if (currentRequestId) skipped.add(currentRequestId); + break; + } + const turn = ensureTurn(turns, currentRequestId); + pushToolCall(turn, item); + break; + } + + case 'subagent': { + // Anchor to the turn the sub-agent was spawned in (core-derived + // `requestId`), not the current cursor — sub-agent items are appended + // after all root items, so the cursor is the last turn by then. + const anchorRequestId = item.requestId ?? currentRequestId; + if (!anchorRequestId || skip.has(anchorRequestId)) { + if (anchorRequestId) skipped.add(anchorRequestId); + break; + } + const turn = ensureTurn(turns, anchorRequestId); + const activity = buildSubagentActivity(item.id, item.items); + turn.entries.push({ + id: `subagent:${item.id}`, + name: `subagent:${item.id}`, + round: turn.round, + seq: turn.seq++, + status: 'success', + subagent: activity, + }); + break; + } + + case 'interruptedPartial': + if (currentRequestId) { + interrupted.push({ + requestId: currentRequestId, + content: item.text, + thinking: item.thinking ?? '', + }); + } + break; + + // Compaction markers have no settled-turn renderer — drop them. + case 'compaction': + default: + break; + } + } + + const timelines: Record = {}; + const transcripts: Record = {}; + for (const [requestId, turn] of turns) { + if (turn.entries.length > 0) timelines[requestId] = turn.entries; + if (turn.transcript.length > 0) transcripts[requestId] = turn.transcript; + } + + log( + 'mapped turns=%d timelines=%d transcripts=%d interrupted=%d skipped=%d', + turns.size, + Object.keys(timelines).length, + Object.keys(transcripts).length, + interrupted.length, + skipped.size + ); + + return { timelines, transcripts, interrupted }; +} + +/** Push a tool-call display item as both a timeline entry and a transcript + * pointer sharing one `seq`, so the tool row orders consistently among the + * turn's narration / thinking. */ +function pushToolCall(turn: TurnAccumulator, item: DerivedToolCall): void { + const seq = turn.seq++; + const entry: ToolTimelineEntry = { + id: item.callId, + name: item.name, + round: turn.round, + seq, + status: timelineStatusFromDerived(item.status), + argsBuffer: stringifyArgs(item.args), + result: item.result, + }; + // A failed tool renders its "why / next" explanation via `ToolFailureLines`. + const failure = toFailureExplanation(item.failure, item.result); + if (failure) entry.failure = failure; + // Derive the human label + detail from tool name + args (the same TS + // formatter the live path runs), so settled rows carry `displayName`/`detail` + // at parity with `turn_state` rows instead of being unlabelled. + const formatted = formatTimelineEntry(entry); + entry.displayName = formatted.title; + if (formatted.detail !== undefined) entry.detail = formatted.detail; + turn.entries.push(entry); + turn.transcript.push({ kind: 'toolCall', round: turn.round, seq, callId: item.callId }); +} diff --git a/app/src/providers/ChatRuntimeProvider.tsx b/app/src/providers/ChatRuntimeProvider.tsx index d8c7fc0e57..da86437903 100644 --- a/app/src/providers/ChatRuntimeProvider.tsx +++ b/app/src/providers/ChatRuntimeProvider.tsx @@ -39,6 +39,7 @@ import { clearProcessingForThread, clearStreamingAssistantForThread, endInferenceTurn, + fetchAndHydrateDerivedTranscript, markInferenceTurnStreaming, parseToolFailure, recordChatTurnUsage, @@ -76,7 +77,7 @@ import { setActiveThread, setSelectedThread, } from '../store/threadSlice'; -import { IS_PROD } from '../utils/config'; +import { DERIVED_TRANSCRIPT_ENABLED, IS_PROD } from '../utils/config'; const logChatRuntime = debug('openhuman:chat-runtime'); const USER_FACING_AGENT_ERROR_MESSAGE = @@ -443,6 +444,14 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => { await flushQueuedFollowups(event.thread_id); dispatch(endInferenceTurn({ threadId: event.thread_id })); dispatch(clearThreadInferenceActive(event.thread_id)); + // Live-turn seam: the turn just settled and its line was appended to the + // append-only transcript. Invalidate/refresh the thread's derived + // settled-turn trails so the next reopen is fresh. The just-finished turn + // is the newest, so the derived hydration skips it — this never fights the + // live anchor, and it does not touch any socket delta handler. + if (DERIVED_TRANSCRIPT_ENABLED) { + void dispatch(fetchAndHydrateDerivedTranscript(event.thread_id)); + } }; rtLog('subscribe_chat_events', { socket: socketStatus }); diff --git a/app/src/services/api/threadApi.test.ts b/app/src/services/api/threadApi.test.ts index 605d78f1d6..67e8baeaa9 100644 --- a/app/src/services/api/threadApi.test.ts +++ b/app/src/services/api/threadApi.test.ts @@ -221,4 +221,39 @@ describe('threadApi', () => { params: { runId: 'sub-1', afterSequence: 0 }, }); }); + + it('fetches the derived transcript page with pagination controls', async () => { + const pageData = { + threadId: 'thread-1', + items: [{ kind: 'turnBoundary', requestId: 'req-1' }], + total: 1, + hasMore: false, + hasTranscript: true, + }; + mockCallCoreRpc.mockResolvedValueOnce({ data: pageData }); + + const { threadApi } = await import('./threadApi'); + const result = await threadApi.getDerivedTranscript('thread-1', { cursor: '10', limit: 50 }); + + expect(mockCallCoreRpc).toHaveBeenLastCalledWith({ + method: 'openhuman.threads_transcript_get', + params: { thread_id: 'thread-1', cursor: '10', limit: 50 }, + }); + expect(result).toEqual(pageData); + }); + + it('fetches the derived transcript with default (undefined) pagination when omitted', async () => { + mockCallCoreRpc.mockResolvedValueOnce({ + data: { threadId: 'thread-1', items: [], total: 0, hasMore: false, hasTranscript: false }, + }); + + const { threadApi } = await import('./threadApi'); + const result = await threadApi.getDerivedTranscript('thread-1'); + + expect(mockCallCoreRpc).toHaveBeenLastCalledWith({ + method: 'openhuman.threads_transcript_get', + params: { thread_id: 'thread-1', cursor: undefined, limit: undefined }, + }); + expect(result.hasTranscript).toBe(false); + }); }); diff --git a/app/src/services/api/threadApi.ts b/app/src/services/api/threadApi.ts index bedb5f5b81..bf01d34bcf 100644 --- a/app/src/services/api/threadApi.ts +++ b/app/src/services/api/threadApi.ts @@ -1,5 +1,9 @@ import debug from 'debug'; +import type { + DerivedTranscriptGetOptions, + DerivedTranscriptPage, +} from '../../types/derivedTranscript'; import type { PurgeResultData, Thread, @@ -271,4 +275,25 @@ export const threadApi = { }); return unwrapEnvelope(response); }, + + /** + * Transcript-derived view (Phase B/C): project the thread's append-only + * `session_raw/*.jsonl` source of truth into typed display items for the + * settled-turn restore path. Newest-first paginated; `cursor` comes from a + * prior page's `nextCursor`, `limit` defaults to 50 (core-clamped to 500). + * + * A thread with no persisted transcript yet returns an empty page with + * `hasTranscript: false` (not an error) — the caller then falls back to the + * legacy `turn_state_history` hydration. + */ + getDerivedTranscript: async ( + threadId: string, + options?: DerivedTranscriptGetOptions + ): Promise => { + const response = await callCoreRpc>({ + method: 'openhuman.threads_transcript_get', + params: { thread_id: threadId, cursor: options?.cursor, limit: options?.limit }, + }); + return unwrapEnvelope(response); + }, }; diff --git a/app/src/store/__tests__/chatRuntimeSlice.derived.thunk.test.ts b/app/src/store/__tests__/chatRuntimeSlice.derived.thunk.test.ts new file mode 100644 index 0000000000..07bf2c63e8 --- /dev/null +++ b/app/src/store/__tests__/chatRuntimeSlice.derived.thunk.test.ts @@ -0,0 +1,141 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { DerivedDisplayItem, DerivedTranscriptPage } from '../../types/derivedTranscript'; +import reducer, { + fetchAndHydrateDerivedTranscript, + setStreamingAssistantForThread, +} from '../chatRuntimeSlice'; + +const { mockThreadApi, flag } = vi.hoisted(() => ({ + mockThreadApi: { getDerivedTranscript: vi.fn(), getTurnStateHistory: vi.fn() }, + flag: { enabled: true }, +})); + +vi.mock('../../services/api/threadApi', () => ({ threadApi: mockThreadApi })); +vi.mock('../../utils/config', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + get DERIVED_TRANSCRIPT_ENABLED() { + return flag.enabled; + }, + }; +}); + +function page(items: DerivedDisplayItem[], overrides: Partial = {}) { + return { + threadId: 'thread-1', + items, + total: items.length, + hasMore: false, + hasTranscript: true, + ...overrides, + } satisfies DerivedTranscriptPage; +} + +/** Newest-first page from chronological items (as the RPC returns). */ +function newestFirst(chronological: DerivedDisplayItem[]): DerivedDisplayItem[] { + return [...chronological].reverse(); +} + +beforeEach(() => { + flag.enabled = true; + mockThreadApi.getDerivedTranscript.mockReset(); + mockThreadApi.getTurnStateHistory.mockReset(); +}); + +describe('fetchAndHydrateDerivedTranscript', () => { + it('hydrates settled-turn trails from the projection, skipping the newest turn', async () => { + const store = configureStore({ reducer }); + mockThreadApi.getDerivedTranscript.mockResolvedValueOnce( + page( + newestFirst([ + { kind: 'turnBoundary', requestId: 'req-old' }, + { kind: 'reasoning', text: 'old thought' }, + { kind: 'toolCall', callId: 'c1', name: 'shell', status: 'success' }, + { kind: 'turnBoundary', requestId: 'req-new' }, + { kind: 'reasoning', text: 'newest thought' }, + ]) + ) + ); + + await store.dispatch(fetchAndHydrateDerivedTranscript('thread-1')); + + expect(mockThreadApi.getDerivedTranscript).toHaveBeenCalledWith('thread-1', { limit: 500 }); + expect(mockThreadApi.getTurnStateHistory).not.toHaveBeenCalled(); + const timelines = store.getState().turnTimelinesByThread['thread-1']; + const transcripts = store.getState().turnTranscriptsByThread['thread-1']; + // Newest turn (req-new) is skipped — rendered by the live anchor. + expect(Object.keys(transcripts)).toEqual(['req-old']); + expect(timelines['req-old']).toHaveLength(1); + expect(transcripts['req-new']).toBeUndefined(); + expect(timelines['req-new']).toBeUndefined(); + }); + + it('falls back to turn_state history when the flag is off', async () => { + flag.enabled = false; + const store = configureStore({ reducer }); + mockThreadApi.getTurnStateHistory.mockResolvedValueOnce([]); + + await store.dispatch(fetchAndHydrateDerivedTranscript('thread-1')); + + expect(mockThreadApi.getDerivedTranscript).not.toHaveBeenCalled(); + expect(mockThreadApi.getTurnStateHistory).toHaveBeenCalledWith('thread-1'); + }); + + it('falls back to turn_state history when the RPC errors', async () => { + const store = configureStore({ reducer }); + mockThreadApi.getDerivedTranscript.mockRejectedValueOnce(new Error('boom')); + mockThreadApi.getTurnStateHistory.mockResolvedValueOnce([]); + + await expect( + store.dispatch(fetchAndHydrateDerivedTranscript('thread-1')) + ).resolves.toBeDefined(); + + expect(mockThreadApi.getTurnStateHistory).toHaveBeenCalledWith('thread-1'); + }); + + it('falls back to turn_state history when the thread has no persisted transcript (legacy)', async () => { + const store = configureStore({ reducer }); + mockThreadApi.getDerivedTranscript.mockResolvedValueOnce(page([], { hasTranscript: false })); + mockThreadApi.getTurnStateHistory.mockResolvedValueOnce([]); + + await store.dispatch(fetchAndHydrateDerivedTranscript('thread-1')); + + expect(mockThreadApi.getTurnStateHistory).toHaveBeenCalledWith('thread-1'); + // The legacy fallback ran with empty history — no derived trails installed. + expect(store.getState().turnTimelinesByThread['thread-1']).toEqual({}); + }); + + it('skips a turn that is currently streaming (live-turn requestId skip)', async () => { + const store = configureStore({ reducer }); + // Seed a live stream on a NON-newest turn (req-mid). + store.dispatch( + setStreamingAssistantForThread({ + threadId: 'thread-1', + streaming: { requestId: 'req-mid', content: 'streaming...', thinking: '' }, + }) + ); + mockThreadApi.getDerivedTranscript.mockResolvedValueOnce( + page( + newestFirst([ + { kind: 'turnBoundary', requestId: 'req-old' }, + { kind: 'reasoning', text: 'old thought' }, + { kind: 'turnBoundary', requestId: 'req-mid' }, + { kind: 'reasoning', text: 'mid thought' }, + { kind: 'turnBoundary', requestId: 'req-new' }, + { kind: 'reasoning', text: 'new thought' }, + ]) + ) + ); + + await store.dispatch(fetchAndHydrateDerivedTranscript('thread-1')); + + const transcripts = store.getState().turnTranscriptsByThread['thread-1']; + // req-new skipped (newest), req-mid skipped (streaming), req-old kept. + expect(Object.keys(transcripts)).toEqual(['req-old']); + expect(transcripts['req-mid']).toBeUndefined(); + expect(transcripts['req-new']).toBeUndefined(); + }); +}); diff --git a/app/src/store/chatRuntimeSlice.ts b/app/src/store/chatRuntimeSlice.ts index 40122ac0a3..05c61d8b8f 100644 --- a/app/src/store/chatRuntimeSlice.ts +++ b/app/src/store/chatRuntimeSlice.ts @@ -1,7 +1,9 @@ import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit'; import debug from 'debug'; +import { mapDisplayItems } from '../features/conversations/derived/mapDisplayItems'; import { threadApi } from '../services/api/threadApi'; +import type { DerivedDisplayItem, DerivedTranscriptPage } from '../types/derivedTranscript'; import type { ThreadMessage } from '../types/thread'; import type { AgentRun, @@ -13,6 +15,7 @@ import type { PersistedTurnState, TaskBoard, } from '../types/turnState'; +import { DERIVED_TRANSCRIPT_ENABLED } from '../utils/config'; import { formatTimelineEntry, isKnownClientTool, @@ -2403,4 +2406,171 @@ export const fetchAndHydrateTurnHistory = createAsyncThunk( } ); +/** + * Initial derived-transcript page size. Sized generously (the core clamps to + * 500) so a reopened thread's visible turns all carry their process trail + * without a second round-trip. Older turns beyond this window load lazily via + * {@link loadOlderDerivedTranscript}. + */ +const DERIVED_TRANSCRIPT_INITIAL_LIMIT = 500; + +const derivedLog = debug('chatRuntime.derivedTranscript'); + +/** + * Read the {@link ChatRuntimeState} out of an arbitrary redux root, tolerating + * both the app store (`state.chatRuntime`) and a bare test store whose root IS + * the slice state. Used only to read live-turn request ids for the skip set. + */ +function readChatRuntimeState(state: unknown): ChatRuntimeState | undefined { + if (!state || typeof state !== 'object') return undefined; + const root = state as Record; + if ('chatRuntime' in root && root.chatRuntime && typeof root.chatRuntime === 'object') { + return root.chatRuntime as ChatRuntimeState; + } + if ('streamingAssistantByThread' in root) { + return root as unknown as ChatRuntimeState; + } + return undefined; +} + +/** + * The request ids whose derived trail must NOT be hydrated: the newest turn + * (rendered as the live "agent insights" anchor from `toolTimelineByThread` / + * the socket stream, or the `turn_state` snapshot via + * {@link fetchAndHydrateTurnState}) and any turn currently streaming. Mirrors + * `fetchAndHydrateTurnHistory`'s `history.slice(1)` newest-turn skip. + */ +function liveRequestIdsToSkip( + state: unknown, + threadId: string, + items: DerivedDisplayItem[] +): Set { + const skip = new Set(); + // Newest turn = first request id encountered walking newest-first. + for (const item of items) { + const rid = + item.kind === 'turnBoundary' + ? item.requestId + : 'requestId' in item + ? item.requestId + : undefined; + if (rid) { + skip.add(rid); + break; + } + } + const runtime = readChatRuntimeState(state); + const streamingRid = runtime?.streamingAssistantByThread[threadId]?.requestId; + if (streamingRid) skip.add(streamingRid); + for (const [rid, mappedThread] of Object.entries(runtime?.parallelRequestThreads ?? {})) { + if (mappedThread === threadId) skip.add(rid); + } + return skip; +} + +/** + * Phase C settled-turn restore: hydrate past-turn process trails from the + * transcript-derived projection (`openhuman.threads_transcript_get`) instead of + * the legacy `turn_state_history` snapshot ring. Populates the SAME + * {@link ChatRuntimeState.turnTimelinesByThread} / + * {@link ChatRuntimeState.turnTranscriptsByThread} the legacy path did, so the + * renderers are reused unchanged. The live/most-recent turn is skipped so + * derived data never fights socket-fed live state. + * + * Automatic fallback to {@link fetchAndHydrateTurnHistory} when the flag is + * off, the RPC errors, or the thread has no persisted transcript (legacy + * thread). Failures never block navigation. + */ +export const fetchAndHydrateDerivedTranscript = createAsyncThunk( + 'chatRuntime/fetchAndHydrateDerivedTranscript', + async (threadId: string, { dispatch, getState }) => { + if (!DERIVED_TRANSCRIPT_ENABLED) { + derivedLog('disabled thread=%s -> turn_state history', threadId); + await dispatch(fetchAndHydrateTurnHistory(threadId)); + return null; + } + let page: DerivedTranscriptPage; + try { + page = await threadApi.getDerivedTranscript(threadId, { + limit: DERIVED_TRANSCRIPT_INITIAL_LIMIT, + }); + } catch (error) { + derivedLog('rpc failed thread=%s err=%O -> turn_state history fallback', threadId, error); + await dispatch(fetchAndHydrateTurnHistory(threadId)); + return null; + } + if (!page.hasTranscript) { + derivedLog('no transcript thread=%s -> turn_state history fallback (legacy)', threadId); + await dispatch(fetchAndHydrateTurnHistory(threadId)); + return null; + } + const skipRequestIds = liveRequestIdsToSkip(getState(), threadId, page.items); + const { timelines, transcripts } = mapDisplayItems(page.items, { skipRequestIds }); + derivedLog( + 'hydrated thread=%s items=%d timelines=%d transcripts=%d skip=%d hasMore=%s', + threadId, + page.items.length, + Object.keys(timelines).length, + Object.keys(transcripts).length, + skipRequestIds.size, + page.hasMore + ); + dispatch(setTurnTimelinesForThread({ threadId, timelines, transcripts })); + // TODO(pagination): when `page.hasMore`, an insights "load older" affordance + // should call `loadOlderDerivedTranscript` with `page.nextCursor`. No UI + // surfaces older past-turn trails yet, so the first (generous) page is all + // we hydrate today. + return { timelines, transcripts, nextCursor: page.nextCursor ?? null, hasMore: page.hasMore }; + } +); + +/** + * Load-older hook (Phase C, pagination): fetch the next (older) derived page + * for a thread by `cursor` and MERGE its trails into the already-hydrated + * {@link ChatRuntimeState.turnTimelinesByThread} / `turnTranscriptsByThread` + * (existing turns win — the newer page is authoritative). Wired but currently + * uncalled: no UI exposes a "load older insights" affordance yet. + */ +export const loadOlderDerivedTranscript = createAsyncThunk( + 'chatRuntime/loadOlderDerivedTranscript', + async (arg: { threadId: string; cursor: string }, { dispatch, getState }) => { + if (!DERIVED_TRANSCRIPT_ENABLED) return null; + const { threadId, cursor } = arg; + let page: DerivedTranscriptPage; + try { + page = await threadApi.getDerivedTranscript(threadId, { + cursor, + limit: DERIVED_TRANSCRIPT_INITIAL_LIMIT, + }); + } catch (error) { + derivedLog('load-older rpc failed thread=%s err=%O', threadId, error); + return null; + } + if (!page.hasTranscript) return null; + const skipRequestIds = liveRequestIdsToSkip(getState(), threadId, page.items); + const { timelines, transcripts } = mapDisplayItems(page.items, { skipRequestIds }); + const runtime = readChatRuntimeState(getState()); + const mergedTimelines = { ...timelines, ...(runtime?.turnTimelinesByThread[threadId] ?? {}) }; + const mergedTranscripts = { + ...transcripts, + ...(runtime?.turnTranscriptsByThread[threadId] ?? {}), + }; + derivedLog( + 'load-older merged thread=%s added_timelines=%d added_transcripts=%d hasMore=%s', + threadId, + Object.keys(timelines).length, + Object.keys(transcripts).length, + page.hasMore + ); + dispatch( + setTurnTimelinesForThread({ + threadId, + timelines: mergedTimelines, + transcripts: mergedTranscripts, + }) + ); + return { nextCursor: page.nextCursor ?? null, hasMore: page.hasMore }; + } +); + export default chatRuntimeSlice.reducer; diff --git a/app/src/test/setup.ts b/app/src/test/setup.ts index 76009c30a9..fab9a65f5c 100644 --- a/app/src/test/setup.ts +++ b/app/src/test/setup.ts @@ -202,6 +202,7 @@ vi.mock('../utils/config', () => ({ E2E_RESTART_APP_AS_RELOAD: false, DEV_FORCE_ONBOARDING: false, CHAT_ATTACHMENTS_ENABLED: true, + DERIVED_TRANSCRIPT_ENABLED: true, SKILLS_GITHUB_REPO: 'test/skills', GA_MEASUREMENT_ID: undefined, OPENPANEL_API_URL: 'https://panel.tinyhumans.ai/api', diff --git a/app/src/types/derivedTranscript.ts b/app/src/types/derivedTranscript.ts new file mode 100644 index 0000000000..e683ee6985 --- /dev/null +++ b/app/src/types/derivedTranscript.ts @@ -0,0 +1,158 @@ +/** + * Wire shape of the transcript-derived view RPC + * (`openhuman.threads_transcript_get`, Phase B — see + * `src/openhuman/threads/transcript_view/types.rs`). + * + * The Rust core projects the append-only `session_raw/*.jsonl` source of truth + * into typed **display items** in the frontend's chat vocabulary. Phase C maps + * these onto the existing settled-turn renderers (`PastTurnInsights` / + * `ProcessingTranscriptView` / `ToolTimelineBlock` / `SubagentActivityBlock`) + * via `features/conversations/derived/mapDisplayItems.ts`. + * + * Serde is camelCase on the wire, so every field here is camelCase and mirrors + * the Rust `DisplayItem` / `TranscriptPage` serialization exactly. + */ + +/** + * Terminal state of a projected tool call. Mirrors the Rust `ToolCallStatus` + * (snake_case on the wire) and the live timeline's `ToolTimelineStatus` + * vocabulary so the settled projection and the live stream render identically. + */ +export type DerivedToolCallStatus = 'running' | 'success' | 'error'; + +/** + * One item in a projected transcript, in the frontend's display vocabulary. + * Discriminated by `kind` (camelCase discriminator from the Rust + * `#[serde(tag = "kind")]` enum). + */ +export type DerivedDisplayItem = + | DerivedUserMessage + | DerivedAssistantMessage + | DerivedReasoning + | DerivedToolCall + | DerivedSubagent + | DerivedTurnBoundary + | DerivedInterruptedPartial + | DerivedCompaction; + +/** + * A user prompt. `content` is the raw persisted content (may carry an injected + * `Current Date & Time:` scaffolding line); `displayContent` is the sanitized + * version to show, present only when it differs from raw — prefer it. + */ +export interface DerivedUserMessage { + kind: 'userMessage'; + content: string; + displayContent?: string; + requestId?: string; +} + +/** + * An assistant answer. `interim: true` marks a non-terminal tool-calling step + * within a multi-iteration turn (narration between tool calls), **not** the + * final answer bubble — the final (non-interim) answer stays rendered from the + * thread message list. + */ +export interface DerivedAssistantMessage { + kind: 'assistantMessage'; + content: string; + interim?: boolean; + requestId?: string; + model?: string; + iteration?: number; +} + +/** The model's reasoning/thinking that preceded an assistant message. */ +export interface DerivedReasoning { + kind: 'reasoning'; + text: string; +} + +/** + * Failure payload attached to an errored tool call. Minimal on the wire (the + * persisted transcript only records the failure plus an optional short reason); + * the mapper expands it into the richer `ToolFailureExplanation` shape the + * `ToolFailureLines` renderer consumes. + */ +export interface DerivedToolFailure { + /** Short, single-line reason for the failure, when the writer captured one. */ + detail?: string; +} + +/** A tool invocation with its paired result, when available. */ +export interface DerivedToolCall { + kind: 'toolCall'; + callId: string; + name: string; + args?: unknown; + result?: string; + status: DerivedToolCallStatus; + /** Present only when `status` is `'error'`. */ + failure?: DerivedToolFailure; +} + +/** + * A delegated sub-agent run, with its own nested projected items. `requestId` + * anchors the whole trail to the parent turn that spawned it (derived core-side + * from the sub-agent's spawn timestamp vs. the parent turns' timestamp ranges); + * absent for legacy/CLI transcripts with no `requestId`. + */ +export interface DerivedSubagent { + kind: 'subagent'; + id: string; + requestId?: string; + items: DerivedDisplayItem[]; +} + +/** A turn boundary — emitted when the `requestId` changes between lines. */ +export interface DerivedTurnBoundary { + kind: 'turnBoundary'; + requestId: string; +} + +/** A partial assistant answer captured when a turn was interrupted. */ +export interface DerivedInterruptedPartial { + kind: 'interruptedPartial'; + text: string; + thinking?: string; +} + +/** + * A context-compaction marker: the reduced set replaced everything before it. + * Carried for completeness; the settled-turn renderers have no compaction + * concept, so the Phase C mapper currently drops it. + */ +export interface DerivedCompaction { + kind: 'compaction'; + replacedCount: number; + keptCount: number; + ts?: string; + requestId?: string; +} + +/** + * One newest-first page of a thread's projected transcript. `hasTranscript` is + * `false` when the thread has no persisted transcript yet (legacy thread / + * brand-new) — the caller then falls back to the turn_state hydration path. + */ +export interface DerivedTranscriptPage { + threadId: string; + /** Display items for this page, **newest-first**. */ + items: DerivedDisplayItem[]; + /** Total top-level items available for the thread. */ + total: number; + /** Opaque cursor to pass back for the next (older) page; absent at the end. */ + nextCursor?: string; + /** `true` when more (older) items remain beyond this page. */ + hasMore: boolean; + /** `false` when the thread has no persisted transcript yet (empty page). */ + hasTranscript: boolean; +} + +/** Optional pagination controls for {@link DerivedTranscriptPage} fetches. */ +export interface DerivedTranscriptGetOptions { + /** Opaque token from a prior page's `nextCursor`. */ + cursor?: string; + /** Page size; core default 50, clamped to 500. */ + limit?: number; +} diff --git a/app/src/utils/config.ts b/app/src/utils/config.ts index 6557e9a44f..793d3745ed 100644 --- a/app/src/utils/config.ts +++ b/app/src/utils/config.ts @@ -102,6 +102,22 @@ export const CHAT_ATTACHMENTS_ENABLED = import.meta.env.VITE_CHAT_ATTACHMENTS != export const SKILLS_GITHUB_REPO = import.meta.env.VITE_SKILLS_GITHUB_REPO || 'tinyhumansai/openhuman-skills'; +/** + * Transcript-derived restore path (Phase C, `docs/plans/transcript-derived-view.md`). + * + * When **on** (default), the settled-turn process trails on thread open are + * hydrated from the `openhuman.threads_transcript_get` projection of the + * append-only `session_raw/*.jsonl` source of truth, instead of the legacy + * `turn_state_history` snapshot ring. Live token streaming is untouched either + * way — in-flight turns still render from socket-fed `chatRuntimeSlice` state. + * + * Automatic fallback to the legacy `turn_state_history` hydration when the RPC + * errors or reports `hasTranscript: false` (legacy threads), so the old path + * stays fully working. Hard-disable the whole derived path for a build with + * `VITE_DERIVED_TRANSCRIPT=false`. + */ +export const DERIVED_TRANSCRIPT_ENABLED = import.meta.env.VITE_DERIVED_TRANSCRIPT !== 'false'; + /** Google Analytics 4 Measurement ID. Leave blank to disable GA. */ export const GA_MEASUREMENT_ID = import.meta.env.VITE_GA_MEASUREMENT_ID as string | undefined; diff --git a/docs/plans/transcript-derived-view.md b/docs/plans/transcript-derived-view.md new file mode 100644 index 0000000000..47da181ba2 --- /dev/null +++ b/docs/plans/transcript-derived-view.md @@ -0,0 +1,106 @@ +# Transcript-Derived View — Raw Session Files as Source of Truth + +Status: **draft — approved direction, phased implementation** +Branch: `feat/transcript-derived-view` (stacked on `fix/transcript-restore-fidelity`) +Companion: [`conversations-timeline-refactor.md`](conversations-timeline-refactor.md) (Phases 1–2, 4–5 landed; this plan supersedes its Phase 5 hydration story for settled turns) + +## Goal + +Stop maintaining chat state that must be *synced* with what is live. Derive the settled +transcript from the raw session files (`session_raw/*.jsonl`), and demote every other +store to a cache over that file. Live token streaming is untouched: in-flight turns +render from ephemeral socket-fed state exactly as today; the file is authoritative only +for **settled** turns. + +This is the Codex rollout model (one JSONL replayed into both model context and UI, +with an explicit persistence policy) adapted to our layout, plus hermes-agent's +soft-compaction lesson (history is never destroyed, only superseded). + +## Why derivation is unsafe today (must fix first) + +1. **Destructive compaction.** `agent/harness/session/transcript.rs::write_transcript` + full-rewrites the file so context reduction deletes earlier turns from disk. +2. **Display data missing from the file**: interrupted partial answers, `request_id` + turn boundaries, narration items. They exist only in `turn_state` snapshots. +3. **Internal scaffolding in message content**: channel-context prefixes on user + messages, tool-policy preamble in system content — must be sanitized (or tagged) at + projection, never shown raw. + +## Architecture + +``` + live turn (unchanged) +socket events ──► chatRuntimeSlice (ephemeral) ──► renderer + │ chat_done + ▼ invalidate +settled turns: +session_raw/{root}.jsonl ─┐ +session_raw/{root}__sub-*.jsonl ─┴─► core projection (threads.transcript_get) + │ mtime-keyed cache + ▼ + typed display items ──► renderer (same components) +``` + +- **Model context** keeps reading the same file via the existing loader, now replaying + compaction records instead of trusting a rewritten file. +- **`turn_state`** shrinks to live-turn crash recovery (interrupted `streamingText` + until the interrupted line is appended to the file, then only the in-flight turn). +- The 20-turn retention cap stops being user-visible loss: history comes from the file. + +## Phases + +### Phase A — append-only transcript (Rust, prerequisite) +`transcript.rs` + harness call sites: +- Replace full-rewrite with **append-only** line writes. Context reduction appends a + `compaction` record `{ kind: "compaction", replacement_ids | replacement_history }`; + the model-context loader (`read_transcript` path) replays records to reconstruct the + post-compaction context; a new display reader returns *all* records. +- Stamp `request_id` on every line of a turn (turn boundary markers); keep `iteration`, + `ts`, `seq` alignment with the progress-bridge envelope. +- On turn abort/interrupt, append the partial assistant line flagged + `{ interrupted: true }` so the partial answer is in the file, not only in turn_state. +- Migration: existing files are valid append-only files with zero compaction records — + no migration needed. Old cores reading new files must skip unknown `kind` lines + (verify the `_extra` flatten tolerates this; add a version field to `_meta`). +- Tests: compaction round-trip (model context reduced, display history complete), + interrupted-partial append, request_id stamping, legacy-file read. + +### Phase B — projection RPC (Rust) +New `threads.transcript_get(thread_id, {cursor?, limit?})` in the threads domain +(canonical module shape: ops/schemas): +- Resolve root transcript via `find_root_transcript_for_thread`; discover + `__sub-*.jsonl` children; project into typed display items: + `user_message | assistant_message | reasoning | tool_call {args, result, status} | + subagent {id, items} | turn_boundary {request_id} | interrupted_partial`. +- Sanitize scaffolding (channel-context prefix, tool-policy preamble) at projection; + tag rather than mutate where ambiguity exists. +- Cache: per-thread projection keyed on (file paths, mtimes, lengths); invalidated + implicitly by key change. No cache writes to disk — pure memory cache. +- Pagination newest-first with cursor; default window sized for one screen. +- Tests: JSON-RPC E2E (`tests/json_rpc_e2e.rs`) — write file, call RPC, assert items; + subagent merge; sanitization; cache-key invalidation. + +### Phase C — frontend switch (TS) +- Thread-open restore path: replace `turn_state_history` hydration for settled turns + with `transcript_get`; map items onto the existing renderers + (`PastTurnInsights`/`ToolTimelineBlock`/`ProcessingTranscriptView`, bubbles). +- Live turn: untouched (socket → chatRuntimeSlice). On `chat_done`, drop the live + turn's ephemeral state and refetch/append the settled projection. +- Keep the turn_state hydration path as fallback behind a flag for one release. +- Tests: restore renders identical item sequence to live for a scripted turn; + interrupted partial visible; legacy thread (no request_id) fallback. + +### Phase D — cleanup +- Demote/remove `turn_state` ring retention (keep live-turn snapshot only), drop the + `.md` mirror or mark derived-only, delete the fallback flag. + +## Non-goals +- No change to the socket streaming protocol or delta handling. +- No SQLite consolidation (sessions.db stays an index; separate discussion). +- Phase 3 of the timeline refactor (dedup consolidation) remains its own track. + +## Risks +- **Old-core / new-file compat** — unknown `kind` lines must be skipped, not fatal. +- **File growth** — append-only grows; mitigate later with Codex-style zstd of old + sessions; out of scope here. +- **Sanitization false positives** — prefer tagging + frontend hiding over deletion. diff --git a/src/openhuman/agent/harness/session/builder/setters.rs b/src/openhuman/agent/harness/session/builder/setters.rs index b3cea8dc09..0ebd629e7a 100644 --- a/src/openhuman/agent/harness/session/builder/setters.rs +++ b/src/openhuman/agent/harness/session/builder/setters.rs @@ -511,6 +511,7 @@ impl AgentBuilder { // `subagents` declaration against the global registry. agent_definition_id: agent_definition_name.clone(), session_transcript_path: None, + persisted_transcript_messages: Vec::new(), session_key: { let unix_ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/src/openhuman/agent/harness/session/tests.rs b/src/openhuman/agent/harness/session/tests.rs index fba8213a77..c7dd7614e5 100644 --- a/src/openhuman/agent/harness/session/tests.rs +++ b/src/openhuman/agent/harness/session/tests.rs @@ -1481,6 +1481,81 @@ fn seed_resume_from_thread_transcript_preserves_tool_calls_and_reasoning() { ); } +/// Cold-boot resume over an **append-only** transcript that carries a +/// compaction record: the resumed model context must equal the REDUCED set the +/// compaction installed (byte-identical to what the old full-rewrite produced), +/// not the full pre-compaction history. +#[test] +fn seed_resume_replays_compaction_to_reduced_context() { + use super::transcript::{self, TranscriptMeta}; + use crate::openhuman::inference::provider::ChatMessage; + + let ws = tempfile::TempDir::new().expect("temp workspace"); + let wsp = ws.path().to_path_buf(); + let thread_id = "thr_compaction_resume"; + + let meta = TranscriptMeta { + agent_name: "orchestrator_thread-compact".to_string(), + agent_id: Some("orchestrator".to_string()), + agent_type: Some("root".to_string()), + dispatcher: "native".to_string(), + provider: None, + model: None, + created: "2026-01-01T00:00:00Z".to_string(), + updated: "2026-01-01T00:00:00Z".to_string(), + turn_count: 2, + input_tokens: 0, + output_tokens: 0, + cached_input_tokens: 0, + charged_amount_usd: 0.0, + thread_id: Some(thread_id.to_string()), + task_id: None, + }; + let path = transcript::resolve_keyed_transcript_path(&wsp, "1700000000_orchestrator") + .expect("resolve transcript path"); + + // Turn 1: a full exchange. Turn 2: a context reduction (not a prefix) that + // must land as a compaction record. + let full = vec![ + ChatMessage::system("system prompt"), + ChatMessage::user("q1"), + ChatMessage::assistant("a1"), + ChatMessage::user("q2"), + ChatMessage::assistant("a2"), + ]; + transcript::append_transcript_turn(&path, &[], &full, &meta, None, None) + .expect("append turn 1"); + let reduced = vec![ + ChatMessage::system("system prompt"), + ChatMessage::assistant("[summary] q1/q2"), + ChatMessage::user("q3"), + ChatMessage::assistant("a3"), + ]; + transcript::append_transcript_turn(&path, &full, &reduced, &meta, None, None) + .expect("append turn 2 (compaction)"); + + let mut agent = build_minimal_agent_with_definition_name(Some("some_other_agent_name")); + agent.workspace_dir = wsp.clone(); + + let loaded = agent.seed_resume_from_thread_transcript(thread_id); + assert!( + loaded, + "cold-boot resume must load the compacted transcript" + ); + let cached = agent + .cached_transcript_messages + .as_ref() + .expect("cached transcript populated"); + assert_eq!( + cached + .iter() + .map(|m| m.content.as_str()) + .collect::>(), + vec!["system prompt", "[summary] q1/q2", "q3", "a3"], + "resumed context must be the reduced set the compaction installed" + ); +} + /// When no root transcript exists for the thread, the transcript resume is a /// no-op returning `false` so the caller falls back to prose-pair seeding. #[test] diff --git a/src/openhuman/agent/harness/session/transcript.rs b/src/openhuman/agent/harness/session/transcript.rs index 4e293e3826..fd73cd211f 100644 --- a/src/openhuman/agent/harness/session/transcript.rs +++ b/src/openhuman/agent/harness/session/transcript.rs @@ -3,11 +3,53 @@ //! **Source of truth**: `session_raw/{stem}.jsonl` — a *flat* directory. //! //! Each JSONL file starts with a single metadata line (identified by an -//! `_meta` key) followed by one JSON object per `ChatMessage`. On every +//! `_meta` key) followed by one JSON object per record. On every //! write the companion `.md` file is re-rendered for human readability //! under `sessions/{YYYY_MM_DD}/{stem}.md`; it is **never** read back — //! all round-trip / resume logic uses the JSONL. //! +//! ## Append-only log (Phase A — transcript-derived view) +//! +//! The JSONL is an **append-only event log**. [`write_transcript`] still +//! full-rewrites (used by one-shot writers: migrations, sub-agent runners, +//! tests), but the incremental session persistence path +//! (`persist_session_transcript`) uses [`append_transcript_turn`], which +//! never rewrites existing lines. It classifies the incoming logical +//! message set against what was last persisted: +//! +//! - **Pure extension** (previously-persisted messages are a prefix of the +//! new set): only the new tail message lines are appended. +//! - **Reduction / rewrite** (context reduction dropped or replaced earlier +//! turns — the previously-persisted set is *not* a prefix): a single +//! `{"kind":"compaction","replacement":[…]}` record is appended carrying +//! the full reduced message set. Earlier turns stay on disk untouched. +//! +//! Cumulative `_meta` totals are kept fresh without rewriting the file by +//! appending a fresh `{"_meta":{…}}` line each turn; readers take the **last** +//! `_meta` line as authoritative (line 1 remains a valid fallback for old +//! cores that only read the header). +//! +//! ### Two read paths +//! +//! - **Model context** ([`read_transcript`] / `read_transcript_jsonl`) replays +//! the log: message lines accumulate, a `compaction` record **replaces** the +//! accumulator with its `replacement`, and `interrupted:true` partial lines +//! are **skipped** (they never entered the model's context). The result is +//! byte-identical to what the old full-rewrite approach produced. +//! - **Display** ([`read_transcript_display`]) returns **every** record in file +//! order — pre-compaction history, compaction markers, and interrupted +//! partials — so the UI projection (Phase B) can render the full timeline. +//! +//! ### Compatibility +//! +//! Existing files (zero compaction records, no `version`) read identically. +//! New record kinds and fields are additive: [`MessageLine`] carries a +//! `#[serde(flatten)] _extra` catch-all and `MetaPayload` does not set +//! `deny_unknown_fields`, so an **old** core reading a **new** file skips +//! unknown-kind lines (a compaction record fails the `role`/`content` +//! requirement and is logged+skipped) rather than crashing. The `_meta` +//! carries a `version` field (`TRANSCRIPT_SCHEMA_VERSION`) for future readers. +//! //! ## Storage layout //! //! ```text @@ -100,6 +142,84 @@ pub struct TurnUsage { const TURN_USAGE_METADATA_KEY: &str = "openhuman_turn_usage"; +/// `extra_metadata` key carrying a tool-result message's failure marker. The +/// harness folds a tool result into a `role:"tool"` message that drops the +/// per-call failure flag (`ToolResult::is_error`), so the turn loop re-attaches +/// the outcome here — from the captured `ToolCallOutcome` side-channel — before +/// persistence. `extra_metadata` is `#[serde(skip_serializing)]` on +/// [`ChatMessage`], so this never reaches the provider; the transcript writer +/// lifts it onto the additive [`MessageLine::failure`] / `failure_detail` line +/// fields and strips it from the persisted `extra_metadata`. +const TOOL_FAILURE_METADATA_KEY: &str = "openhuman_tool_failure"; + +/// Stamp a tool-result [`ChatMessage`] with its failure outcome so the +/// transcript writer can persist an explicit failure flag. `detail` is an +/// optional short, single-line reason (e.g. the head of the error output). +/// No-op semantics: pass this only for genuinely failed tool calls. +pub(crate) fn attach_tool_failure_metadata(message: &mut ChatMessage, detail: Option<&str>) { + let mut payload = serde_json::Map::new(); + payload.insert("failure".to_string(), serde_json::Value::Bool(true)); + if let Some(detail) = detail.map(str::trim).filter(|s| !s.is_empty()) { + payload.insert( + "detail".to_string(), + serde_json::Value::String(detail.to_string()), + ); + } + let marker = serde_json::Value::Object(payload); + + match message.extra_metadata.take() { + Some(serde_json::Value::Object(mut map)) => { + map.insert(TOOL_FAILURE_METADATA_KEY.to_string(), marker); + message.extra_metadata = Some(serde_json::Value::Object(map)); + } + Some(existing) => { + let mut map = serde_json::Map::new(); + map.insert("value".to_string(), existing); + map.insert(TOOL_FAILURE_METADATA_KEY.to_string(), marker); + message.extra_metadata = Some(serde_json::Value::Object(map)); + } + None => { + let mut map = serde_json::Map::new(); + map.insert(TOOL_FAILURE_METADATA_KEY.to_string(), marker); + message.extra_metadata = Some(serde_json::Value::Object(map)); + } + } +} + +/// Pop the tool-failure marker out of a cloned `extra_metadata` map, returning +/// `Some((true, detail))` when it was present. Strips the key so it is not +/// duplicated into the persisted `extra_metadata` alongside the top-level +/// `failure` line field. Legacy lines without the marker return `None`. +fn take_tool_failure(extra: &mut Option) -> Option<(bool, Option)> { + let serde_json::Value::Object(map) = extra.as_mut()? else { + return None; + }; + let marker = map.remove(TOOL_FAILURE_METADATA_KEY)?; + // If removing the marker emptied the object, drop `extra_metadata` entirely + // so a legacy-identical line stays legacy-identical. + if map.is_empty() { + *extra = None; + } + let detail = marker + .get("detail") + .and_then(|d| d.as_str()) + .map(str::to_string); + Some((true, detail)) +} + +/// Schema version stamped on the `_meta` header line. Bumped when the JSONL +/// record shape changes in a way future readers may need to branch on. `0` +/// (absent) denotes pre-append-only files written before this field existed. +pub const TRANSCRIPT_SCHEMA_VERSION: u32 = 1; + +/// Discriminator value for a compaction record's `kind` field. +const COMPACTION_KIND: &str = "compaction"; + +#[allow(clippy::trivially_copy_pass_by_ref)] +fn is_false(b: &bool) -> bool { + !*b +} + pub(crate) fn attach_turn_usage_metadata(message: &mut ChatMessage, turn_usage: &TurnUsage) { let Ok(payload) = serde_json::to_value(turn_usage) else { log::warn!("[transcript] failed to serialize turn usage metadata"); @@ -199,6 +319,11 @@ struct MetaLine { #[derive(Serialize, Deserialize)] struct MetaPayload { + /// Schema version of the transcript record format (see + /// [`TRANSCRIPT_SCHEMA_VERSION`]). Absent (deserialises to `0`) on files + /// written before the append-only migration. + #[serde(default)] + version: u32, agent: String, #[serde(default, skip_serializing_if = "Option::is_none")] agent_id: Option, @@ -247,19 +372,222 @@ struct MessageLine { iteration: Option, #[serde(skip_serializing_if = "Option::is_none")] ts: Option, + /// Turn boundary marker: the web-chat `request_id` this message belongs to, + /// when available. Stamped on every line of a turn so the display projection + /// can group a turn's messages. Absent for CLI / non-request-scoped runs. + #[serde(default, skip_serializing_if = "Option::is_none")] + request_id: Option, + /// `true` when this line is a *partial* assistant answer captured because + /// the turn was interrupted/cancelled mid-stream. Present for **display + /// only** — the model-context reader skips these so a resumed context never + /// carries a truncated answer. + #[serde(default, skip_serializing_if = "is_false")] + interrupted: bool, + /// `true` when this tool-result line's tool call **failed** + /// (`ToolResult::is_error`). Additive + optional: legacy lines and every + /// non-tool line omit it and default to success. Lifted from the tool + /// message's failure metadata by [`build_message_line`]; consumed by the + /// display projection to render an error tool row instead of success. + #[serde(default, skip_serializing_if = "is_false")] + failure: bool, + /// Optional short, single-line reason for a failed tool call (the head of + /// the error output). Present only alongside `failure: true`. + #[serde(default, skip_serializing_if = "Option::is_none")] + failure_detail: Option, /// Absorb any unknown fields so forward-compat reads don't error. #[serde(flatten)] _extra: HashMap, } +/// A compaction record: `{"kind":"compaction","replacement":[…]}`. +/// +/// Appended when the harness reduces context (post-compaction / trim) so the +/// model-context reader can reconstruct the reduced set without the file being +/// destructively rewritten. `replacement` is the **full** logical message set +/// that supersedes everything before it — an explicit replacement list +/// (mirroring Codex's `Compacted { replacement_history }`) rather than +/// surviving-message ids, because our writer already holds the reduced +/// `messages` slice on each persist call and message ids are optional, so an +/// id-reference scheme would be less robust for no gain. +#[derive(Serialize, Deserialize)] +struct CompactionLine { + kind: String, + replacement: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + ts: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + request_id: Option, + #[serde(flatten)] + _extra: HashMap, +} + +// ── Display read types ─────────────────────────────────────────────── + +/// One message in a display projection, carrying the turn-boundary + partial +/// flags the model-context [`SessionTranscript`] discards. +#[derive(Debug, Clone)] +pub struct DisplayMessage { + pub message: ChatMessage, + /// `true` when this is an interrupted partial answer (display only). + pub interrupted: bool, + /// Turn boundary marker (`request_id`), when stamped. + pub request_id: Option, + pub iteration: Option, + pub ts: Option, + /// Usage/provenance for assistant messages that carried it. + pub turn_usage: Option, + /// Raw reasoning/thinking captured for this line, when present. Mirrors the + /// line's `reasoning_content` directly so it survives even on lines without + /// full turn-usage provenance (e.g. an interrupted partial, which carries no + /// provider/model/usage). Prefer this over digging into [`Self::turn_usage`] + /// for display: it is populated from `turn_usage.reasoning_content` too. + pub reasoning_content: Option, + /// `true` when this is a **failed** tool-result line (`ToolResult::is_error` + /// at execution time). The display projection renders an error tool row + /// instead of success. Always `false` for non-tool lines and legacy files. + pub failure: bool, + /// Optional short reason for a failed tool call (present only with + /// `failure: true`). + pub failure_detail: Option, +} + +/// A compaction marker in a display projection. +#[derive(Debug, Clone)] +pub struct CompactionMarker { + /// The reduced message set this compaction installed as the new context. + pub replacement: Vec, + pub ts: Option, + pub request_id: Option, +} + +/// One record in a display projection, in file order. +#[derive(Debug, Clone)] +pub enum DisplayRecord { + Message(DisplayMessage), + Compaction(CompactionMarker), +} + +/// A display projection of a transcript: **all** records, including +/// pre-compaction history, compaction markers, and interrupted partials. +#[derive(Debug, Clone)] +pub struct DisplaySessionTranscript { + pub meta: TranscriptMeta, + pub records: Vec, +} + // ── Write ───────────────────────────────────────────────────────────── +/// Build the serialised `_meta` header line for `meta`, stamping the current +/// [`TRANSCRIPT_SCHEMA_VERSION`]. +fn meta_payload_from(meta: &TranscriptMeta) -> MetaPayload { + MetaPayload { + version: TRANSCRIPT_SCHEMA_VERSION, + agent: meta.agent_name.clone(), + agent_id: meta.agent_id.clone(), + agent_type: meta.agent_type.clone(), + dispatcher: meta.dispatcher.clone(), + provider: meta.provider.clone(), + model: meta.model.clone(), + created: meta.created.clone(), + updated: meta.updated.clone(), + turn_count: meta.turn_count, + input_tokens: meta.input_tokens, + output_tokens: meta.output_tokens, + cached_input_tokens: meta.cached_input_tokens, + charged_amount_usd: meta.charged_amount_usd, + thread_id: meta.thread_id.clone(), + task_id: meta.task_id.clone(), + } +} + +fn meta_line_json(meta: &TranscriptMeta) -> Result { + let meta_line = MetaLine { + meta: meta_payload_from(meta), + }; + serde_json::to_string(&meta_line).context("serialise transcript meta header") +} + +/// Build a [`MessageLine`] for `msg`, folding in `turn_usage` (assistant rows) +/// and stamping the `request_id` turn boundary when supplied. +fn build_message_line( + msg: &ChatMessage, + turn_usage: Option<&TurnUsage>, + request_id: Option<&str>, + interrupted: bool, +) -> MessageLine { + let assistant_usage = if msg.role == "assistant" { + turn_usage + } else { + None + }; + // Lift any tool-failure marker off a cloned `extra_metadata` onto the + // additive top-level `failure` / `failure_detail` line fields, stripping it + // so it is not persisted twice. + let mut extra_metadata = msg.extra_metadata.clone(); + let (failure, failure_detail) = match take_tool_failure(&mut extra_metadata) { + Some((failed, detail)) => (failed, detail), + None => (false, None), + }; + MessageLine { + id: msg.id.clone(), + role: msg.role.clone(), + content: msg.content.clone(), + extra_metadata, + provider: assistant_usage.map(|tu| tu.provider.clone()), + model: assistant_usage.map(|tu| tu.model.clone()), + usage: assistant_usage.map(|tu| tu.usage.clone()), + reasoning_content: assistant_usage.and_then(|tu| tu.reasoning_content.clone()), + tool_calls: assistant_usage.and_then(|tu| { + if tu.tool_calls.is_empty() { + None + } else { + Some(tu.tool_calls.clone()) + } + }), + iteration: assistant_usage.map(|tu| tu.iteration), + ts: assistant_usage.map(|tu| tu.ts.clone()), + request_id: request_id.map(str::to_string), + interrupted, + failure, + failure_detail, + _extra: HashMap::new(), + } +} + +/// Serialise `messages` into JSONL message lines, attributing +/// `last_assistant_turn_usage` (or per-message embedded usage) to the last +/// assistant row and stamping `request_id` on every line. +fn serialise_message_lines( + messages: &[ChatMessage], + last_assistant_turn_usage: Option<&TurnUsage>, + request_id: Option<&str>, + buf: &mut String, +) -> Result<()> { + let last_assistant_idx = messages.iter().rposition(|m| m.role == "assistant"); + for (i, msg) in messages.iter().enumerate() { + let turn_usage = if Some(i) == last_assistant_idx { + last_assistant_turn_usage + .cloned() + .or_else(|| turn_usage_from_metadata(msg)) + } else { + turn_usage_from_metadata(msg) + }; + let line = build_message_line(msg, turn_usage.as_ref(), request_id, false); + let line_json = + serde_json::to_string(&line).with_context(|| format!("serialise message line {i}"))?; + buf.push_str(&line_json); + buf.push('\n'); + } + Ok(()) +} + /// Write JSONL as source of truth **and** re-render the companion `.md`. /// /// `jsonl_path` must end in `.jsonl`; the `.md` companion is derived by -/// swapping the extension. Full rewrite on every call (not append) so -/// that context-reduction that removed earlier messages is reflected -/// immediately. +/// swapping the extension. **Full rewrite** on every call — this is the +/// one-shot writer used by migrations, the sub-agent runners, and tests. +/// The incremental session-persistence path uses [`append_transcript_turn`] +/// instead, which never rewrites existing lines. pub fn write_transcript( jsonl_path: &Path, messages: &[ChatMessage], @@ -273,116 +601,219 @@ pub fn write_transcript( // ── JSONL ──────────────────────────────────────────────────────── let mut jsonl_buf = String::new(); - - // Line 1: meta header. - let meta_line = MetaLine { - meta: MetaPayload { - agent: meta.agent_name.clone(), - agent_id: meta.agent_id.clone(), - agent_type: meta.agent_type.clone(), - dispatcher: meta.dispatcher.clone(), - provider: meta.provider.clone(), - model: meta.model.clone(), - created: meta.created.clone(), - updated: meta.updated.clone(), - turn_count: meta.turn_count, - input_tokens: meta.input_tokens, - output_tokens: meta.output_tokens, - cached_input_tokens: meta.cached_input_tokens, - charged_amount_usd: meta.charged_amount_usd, - thread_id: meta.thread_id.clone(), - task_id: meta.task_id.clone(), - }, - }; - let meta_json = - serde_json::to_string(&meta_line).context("serialise transcript meta header")?; - jsonl_buf.push_str(&meta_json); + jsonl_buf.push_str(&meta_line_json(meta)?); jsonl_buf.push('\n'); + serialise_message_lines(messages, last_assistant_turn_usage, None, &mut jsonl_buf)?; - // Identify the index of the last assistant message so older call sites can - // still attach per-turn usage without embedding it on the message. - let last_assistant_idx = messages.iter().rposition(|m| m.role == "assistant"); + fs::write(jsonl_path, jsonl_buf.as_bytes()) + .with_context(|| format!("write transcript {}", jsonl_path.display()))?; - for (i, msg) in messages.iter().enumerate() { - let turn_usage = if Some(i) == last_assistant_idx { - last_assistant_turn_usage - .cloned() - .or_else(|| turn_usage_from_metadata(msg)) - } else { - turn_usage_from_metadata(msg) - }; + log::debug!( + "[transcript] wrote {} messages (jsonl, full rewrite) to {}", + messages.len(), + jsonl_path.display() + ); - let line = if msg.role == "assistant" { - if let Some(tu) = turn_usage.as_ref() { - MessageLine { - id: msg.id.clone(), - role: msg.role.clone(), - content: msg.content.clone(), - extra_metadata: msg.extra_metadata.clone(), - provider: Some(tu.provider.clone()), - model: Some(tu.model.clone()), - usage: Some(tu.usage.clone()), - reasoning_content: tu.reasoning_content.clone(), - tool_calls: if tu.tool_calls.is_empty() { - None - } else { - Some(tu.tool_calls.clone()) - }, - iteration: Some(tu.iteration), - ts: Some(tu.ts.clone()), - _extra: HashMap::new(), - } - } else { - MessageLine { - id: msg.id.clone(), - role: msg.role.clone(), - content: msg.content.clone(), - extra_metadata: msg.extra_metadata.clone(), - provider: None, - model: None, - usage: None, - reasoning_content: None, - tool_calls: None, - iteration: None, - ts: None, - _extra: HashMap::new(), - } - } - } else { - MessageLine { - id: msg.id.clone(), - role: msg.role.clone(), - content: msg.content.clone(), - extra_metadata: msg.extra_metadata.clone(), - provider: None, - model: None, - usage: None, - reasoning_content: None, - tool_calls: None, - iteration: None, - ts: None, - _extra: HashMap::new(), - } - }; + render_md_companion(jsonl_path, messages, meta, last_assistant_turn_usage); + Ok(()) +} - let line_json = - serde_json::to_string(&line).with_context(|| format!("serialise message line {i}"))?; - jsonl_buf.push_str(&line_json); - jsonl_buf.push('\n'); +/// Append this turn's delta to an **append-only** transcript, never rewriting +/// existing lines. +/// +/// `prev_persisted` is the logical message set the previous call left on disk +/// (empty on the first call for a fresh file). The incoming `messages` is the +/// current full logical set for this turn: +/// +/// - **Pure extension** (`prev_persisted` is a prefix of `messages`): only the +/// new tail is appended as message lines. +/// - **Reduction / rewrite** (context reduction changed or dropped earlier +/// turns): a single `compaction` record carrying the full reduced +/// `messages` is appended; earlier lines are left untouched on disk. +/// +/// A fresh `_meta` line is appended so cumulative totals stay current without a +/// full rewrite. The `.md` companion is re-rendered from `messages` (derived +/// view — always the reduced/current set). Returns nothing; the caller updates +/// its tracked `prev_persisted` to `messages` on success. +/// +/// `request_id` (when available from the web-chat path) is stamped on every +/// appended line as a turn boundary marker. +pub fn append_transcript_turn( + jsonl_path: &Path, + prev_persisted: &[ChatMessage], + messages: &[ChatMessage], + meta: &TranscriptMeta, + turn_usage: Option<&TurnUsage>, + request_id: Option<&str>, +) -> Result<()> { + if let Some(parent) = jsonl_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create transcript dir {}", parent.display()))?; } - fs::write(jsonl_path, jsonl_buf.as_bytes()) - .with_context(|| format!("write transcript {}", jsonl_path.display()))?; + let file_exists = jsonl_path.exists(); + + // First write for this file: create it with meta + all message lines. + if !file_exists { + let mut buf = String::new(); + buf.push_str(&meta_line_json(meta)?); + buf.push('\n'); + serialise_message_lines(messages, turn_usage, request_id, &mut buf)?; + fs::write(jsonl_path, buf.as_bytes()) + .with_context(|| format!("create transcript {}", jsonl_path.display()))?; + log::debug!( + "[transcript] created append-only transcript with {} message(s) at {}", + messages.len(), + jsonl_path.display() + ); + render_md_companion(jsonl_path, messages, meta, turn_usage); + return Ok(()); + } + // Subsequent writes: diff against the previously-persisted logical set. + let common = common_prefix_len(prev_persisted, messages); + let mut buf = String::new(); + + if common == prev_persisted.len() { + // Pure extension — append only the new tail. + let tail = &messages[common..]; + log::debug!( + "[transcript] append: extending on-disk set (prev={}, new={}, appending {} tail line(s)) {}", + prev_persisted.len(), + messages.len(), + tail.len(), + jsonl_path.display() + ); + serialise_message_lines(tail, turn_usage, request_id, &mut buf)?; + } else { + // Reduction / rewrite — the on-disk set is no longer a prefix. Append a + // compaction record carrying the full reduced context so the + // model-context reader can replay it, without destroying earlier lines. + log::debug!( + "[transcript] append: context reduced (prev={}, new={}, common_prefix={}) — writing compaction record {}", + prev_persisted.len(), + messages.len(), + common, + jsonl_path.display() + ); + let last_assistant_idx = messages.iter().rposition(|m| m.role == "assistant"); + let replacement: Vec = messages + .iter() + .enumerate() + .map(|(i, msg)| { + let tu = if Some(i) == last_assistant_idx { + turn_usage + .cloned() + .or_else(|| turn_usage_from_metadata(msg)) + } else { + turn_usage_from_metadata(msg) + }; + build_message_line(msg, tu.as_ref(), request_id, false) + }) + .collect(); + let compaction = CompactionLine { + kind: COMPACTION_KIND.to_string(), + replacement, + ts: Some(chrono::Utc::now().to_rfc3339()), + request_id: request_id.map(str::to_string), + _extra: HashMap::new(), + }; + let line = serde_json::to_string(&compaction).context("serialise compaction record")?; + buf.push_str(&line); + buf.push('\n'); + } + + // Refresh cumulative meta by appending a new `_meta` line (readers take the + // last one). Keeps append-only + O(1)-per-turn (no full-file rewrite). + buf.push_str(&meta_line_json(meta)?); + buf.push('\n'); + + append_bytes(jsonl_path, buf.as_bytes())?; + render_md_companion(jsonl_path, messages, meta, turn_usage); + Ok(()) +} + +/// Append a partial assistant answer, flagged `interrupted: true`, captured +/// when a streaming turn was cancelled/interrupted before completion. +/// +/// **Display only**: the model-context reader skips interrupted lines, so a +/// resumed context never carries a truncated answer. Does not affect the +/// caller's tracked `prev_persisted` (nothing about the logical model context +/// changed). No-op when `partial_content` is empty. +pub fn append_interrupted_partial( + jsonl_path: &Path, + partial_content: &str, + request_id: Option<&str>, + iteration: Option, + reasoning_content: Option<&str>, +) -> Result<()> { + if partial_content.is_empty() { + return Ok(()); + } + if let Some(parent) = jsonl_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create transcript dir {}", parent.display()))?; + } + let mut line = build_message_line( + &ChatMessage::assistant(partial_content), + None, + request_id, + true, + ); + line.iteration = iteration; + line.reasoning_content = reasoning_content + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + line.ts = Some(chrono::Utc::now().to_rfc3339()); + let mut buf = serde_json::to_string(&line).context("serialise interrupted partial line")?; + buf.push('\n'); + append_bytes(jsonl_path, buf.as_bytes())?; log::debug!( - "[transcript] wrote {} messages (jsonl) to {}", - messages.len(), + "[transcript] appended interrupted partial ({} chars, request_id={:?}) to {}", + partial_content.len(), + request_id, jsonl_path.display() ); + Ok(()) +} + +/// Longest common prefix length between two message slices, comparing on the +/// stable, serialised fields (`role`, `content`, `id`). `ChatMessage` does not +/// derive `PartialEq`, and `extra_metadata` is intentionally excluded because +/// it is enriched (turn usage) between the in-memory history and the persisted +/// line, which must not count as a divergence. +fn common_prefix_len(a: &[ChatMessage], b: &[ChatMessage]) -> usize { + a.iter() + .zip(b.iter()) + .take_while(|(x, y)| x.role == y.role && x.content == y.content && x.id == y.id) + .count() +} - // ── Companion .md ──────────────────────────────────────────────── - // Build per-message usage index for the renderer. Embedded metadata keeps - // older assistant iterations visible when the full JSONL is rewritten. +/// Append raw bytes to a file, opening in append mode (O(1), no read-back). +fn append_bytes(path: &Path, bytes: &[u8]) -> Result<()> { + use std::io::Write; + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .with_context(|| format!("open transcript for append {}", path.display()))?; + file.write_all(bytes) + .with_context(|| format!("append transcript {}", path.display()))?; + Ok(()) +} + +/// Re-render the derived `.md` companion from the current (reduced) message set. +/// +/// Best-effort — the JSONL is the source of truth; a companion write failure is +/// logged and swallowed so it can never take down state persistence. +fn render_md_companion( + jsonl_path: &Path, + messages: &[ChatMessage], + meta: &TranscriptMeta, + last_assistant_turn_usage: Option<&TurnUsage>, +) { + let last_assistant_idx = messages.iter().rposition(|m| m.role == "assistant"); let mut owned_usage: Vec<(usize, TurnUsage)> = Vec::new(); for (idx, msg) in messages.iter().enumerate() { let usage = if Some(idx) == last_assistant_idx { @@ -401,10 +832,6 @@ pub fn write_transcript( .map(|(idx, usage)| (*idx, usage)) .collect(); - // The .md companion is a *derived* view — the JSONL above is the - // source of truth. Failures here must not propagate: a readable-log - // hiccup shouldn't take down the session's state persistence. Log - // and move on. let md_path = md_companion_path(jsonl_path); if let Some(parent) = md_path.parent() { if let Err(err) = fs::create_dir_all(parent) { @@ -412,7 +839,7 @@ pub fn write_transcript( "[transcript] failed to create md companion dir {}: {err}", parent.display() ); - return Ok(()); + return; } } let md = render_markdown(messages, meta, &per_msg_usage); @@ -421,15 +848,12 @@ pub fn write_transcript( "[transcript] failed to write markdown companion {}: {err}", md_path.display() ); - return Ok(()); + return; } - log::debug!( "[transcript] wrote markdown companion to {}", md_path.display() ); - - Ok(()) } // ── Read ───────────────────────────────────────────────────────────── @@ -471,24 +895,112 @@ pub fn read_transcript(path: &Path) -> Result { } } +/// Convert a parsed `MetaPayload` into the public [`TranscriptMeta`]. +fn meta_from_payload(mp: MetaPayload) -> TranscriptMeta { + TranscriptMeta { + agent_name: mp.agent, + agent_id: mp.agent_id, + agent_type: mp.agent_type, + dispatcher: mp.dispatcher, + provider: mp.provider, + model: mp.model, + created: mp.created, + updated: mp.updated, + turn_count: mp.turn_count, + input_tokens: mp.input_tokens, + output_tokens: mp.output_tokens, + cached_input_tokens: mp.cached_input_tokens, + charged_amount_usd: mp.charged_amount_usd, + thread_id: mp.thread_id, + task_id: mp.task_id, + } +} + +/// Recover the [`TurnUsage`] a message line carried (assistant rows only). +fn turn_usage_from_line(ml: &MessageLine) -> Option { + match ( + ml.provider.clone(), + ml.model.clone(), + ml.usage.clone(), + ml.ts.clone(), + ) { + (Some(provider), Some(model), Some(usage), Some(ts)) if ml.role == "assistant" => { + Some(TurnUsage { + provider, + model, + usage, + ts, + reasoning_content: ml.reasoning_content.clone(), + tool_calls: ml.tool_calls.clone().unwrap_or_default(), + iteration: ml.iteration.unwrap_or_default(), + }) + } + _ => None, + } +} + +/// Reconstruct a [`ChatMessage`] from a message line, re-attaching turn-usage +/// metadata so the round-trip is lossless for the model-context path. +fn message_from_line(ml: MessageLine) -> ChatMessage { + let turn_usage = turn_usage_from_line(&ml); + let mut message = ChatMessage { + id: ml.id, + role: ml.role, + content: ml.content, + extra_metadata: ml.extra_metadata, + }; + if let Some(turn_usage) = turn_usage.as_ref() { + attach_turn_usage_metadata(&mut message, turn_usage); + } + message +} + +/// Classification of one non-empty JSONL line. +enum LineKind { + Meta(MetaLine), + Compaction(CompactionLine), + Message(MessageLine), +} + +/// Classify a raw line: a `_meta` header/update, a `compaction` record, or a +/// message line. Returns `Err` only when the line is malformed for its +/// apparent kind; the caller decides whether that is fatal (first line) or a +/// skippable warning (later lines). +fn classify_line(line: &str) -> Result { + // Cheap structural peek. Unknown/other shapes fall through to MessageLine, + // whose required `role`/`content` gate rejects genuinely foreign lines. + let value: serde_json::Value = serde_json::from_str(line)?; + if value.get("_meta").is_some() { + return serde_json::from_str::(line).map(LineKind::Meta); + } + if value.get("kind").and_then(|k| k.as_str()) == Some(COMPACTION_KIND) { + return serde_json::from_str::(line).map(LineKind::Compaction); + } + serde_json::from_str::(line).map(LineKind::Message) +} + fn read_transcript_jsonl(path: &Path) -> Result { let raw = fs::read_to_string(path) .with_context(|| format!("read transcript jsonl {}", path.display()))?; let mut meta: Option = None; let mut messages: Vec = Vec::new(); - - // The JSONL format is positional: line 1 (the first non-empty line) - // is the `_meta` header; every subsequent non-empty line is a message. - // This avoids a substring check that could false-positive if message - // content contains `"_meta"`. + let mut compactions_replayed = 0usize; + let mut interrupted_skipped = 0usize; + + // Append-only log replay (Phase A): the first non-empty line MUST be the + // `_meta` header; subsequent lines are messages, `compaction` records + // (which *replace* the accumulated context), interrupted partials (skipped + // for the model-context path), or refreshed `_meta` lines (last wins). + let mut seen_first = false; for (line_no, line) in raw.lines().enumerate() { let line = line.trim(); if line.is_empty() { continue; } - if meta.is_none() { + if !seen_first { + seen_first = true; let ml: MetaLine = serde_json::from_str(line).map_err(|err| { anyhow::anyhow!( "first non-empty line of {} (line {}) is not a valid _meta object: {err}", @@ -496,65 +1008,47 @@ fn read_transcript_jsonl(path: &Path) -> Result { line_no + 1, ) })?; - let mp = ml.meta; - meta = Some(TranscriptMeta { - agent_name: mp.agent, - agent_id: mp.agent_id, - agent_type: mp.agent_type, - dispatcher: mp.dispatcher, - provider: mp.provider, - model: mp.model, - created: mp.created, - updated: mp.updated, - turn_count: mp.turn_count, - input_tokens: mp.input_tokens, - output_tokens: mp.output_tokens, - cached_input_tokens: mp.cached_input_tokens, - charged_amount_usd: mp.charged_amount_usd, - thread_id: mp.thread_id, - task_id: mp.task_id, - }); + meta = Some(meta_from_payload(ml.meta)); continue; } - // Message line. - match serde_json::from_str::(line) { - Ok(ml) => { - let turn_usage = match ( - ml.provider.clone(), - ml.model.clone(), - ml.usage.clone(), - ml.ts.clone(), - ) { - (Some(provider), Some(model), Some(usage), Some(ts)) - if ml.role == "assistant" => - { - Some(TurnUsage { - provider, - model, - usage, - ts, - reasoning_content: ml.reasoning_content.clone(), - tool_calls: ml.tool_calls.clone().unwrap_or_default(), - iteration: ml.iteration.unwrap_or_default(), - }) - } - _ => None, - }; - let mut message = ChatMessage { - id: ml.id, - role: ml.role, - content: ml.content, - extra_metadata: ml.extra_metadata, - }; - if let Some(turn_usage) = turn_usage.as_ref() { - attach_turn_usage_metadata(&mut message, turn_usage); + match classify_line(line) { + Ok(LineKind::Meta(ml)) => { + // Refreshed cumulative meta — last one wins. + meta = Some(meta_from_payload(ml.meta)); + } + Ok(LineKind::Compaction(cl)) => { + // Reduction record: the reduced context REPLACES everything + // accumulated so far, exactly reproducing the old full-rewrite. + let replacement: Vec = + cl.replacement.into_iter().map(message_from_line).collect(); + log::debug!( + "[transcript] replay: compaction at line {} replaces {} accumulated message(s) with {} (request_id={:?}) in {}", + line_no + 1, + messages.len(), + replacement.len(), + cl.request_id, + path.display() + ); + messages = replacement; + compactions_replayed += 1; + } + Ok(LineKind::Message(ml)) => { + if ml.interrupted { + // Display-only partial — never part of the model context. + interrupted_skipped += 1; + log::debug!( + "[transcript] replay: skipping interrupted partial line {} (display only) in {}", + line_no + 1, + path.display() + ); + continue; } - messages.push(message); + messages.push(message_from_line(ml)); } Err(err) => { log::warn!( - "[transcript] skipping malformed message line {} in {}: {err}", + "[transcript] skipping malformed/unknown record line {} in {}: {err}", line_no + 1, path.display() ); @@ -570,14 +1064,119 @@ fn read_transcript_jsonl(path: &Path) -> Result { })?; log::debug!( - "[transcript] loaded {} messages (jsonl) from {}", + "[transcript] loaded {} messages (jsonl, {} compaction(s) replayed, {} interrupted skipped) from {}", messages.len(), + compactions_replayed, + interrupted_skipped, path.display() ); Ok(SessionTranscript { meta, messages }) } +// ── Display read ────────────────────────────────────────────────────── + +/// Reconstruct a [`DisplayMessage`] from a message line, preserving the +/// turn-boundary + partial flags the model-context path discards. +fn display_message_from_line(ml: MessageLine) -> DisplayMessage { + let turn_usage = turn_usage_from_line(&ml); + let reasoning_content = ml.reasoning_content.clone().or_else(|| { + turn_usage + .as_ref() + .and_then(|tu| tu.reasoning_content.clone()) + }); + DisplayMessage { + interrupted: ml.interrupted, + request_id: ml.request_id.clone(), + iteration: ml.iteration, + ts: ml.ts.clone(), + turn_usage, + reasoning_content, + failure: ml.failure, + failure_detail: ml.failure_detail.clone(), + message: ChatMessage { + id: ml.id, + role: ml.role, + content: ml.content, + extra_metadata: ml.extra_metadata, + }, + } +} + +/// Read a transcript for **display**: returns *every* record in file order, +/// including pre-compaction history, compaction markers, and interrupted +/// partials — the counterpart to the model-context [`read_transcript`], which +/// collapses the log into the reduced context. +/// +/// `meta` reflects the newest `_meta` line (cumulative totals stay current). +pub fn read_transcript_display(path: &Path) -> Result { + let raw = fs::read_to_string(path) + .with_context(|| format!("read transcript jsonl (display) {}", path.display()))?; + + let mut meta: Option = None; + let mut records: Vec = Vec::new(); + let mut seen_first = false; + + for (line_no, line) in raw.lines().enumerate() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if !seen_first { + seen_first = true; + let ml: MetaLine = serde_json::from_str(line).map_err(|err| { + anyhow::anyhow!( + "first non-empty line of {} (line {}) is not a valid _meta object: {err}", + path.display(), + line_no + 1, + ) + })?; + meta = Some(meta_from_payload(ml.meta)); + continue; + } + match classify_line(line) { + Ok(LineKind::Meta(ml)) => meta = Some(meta_from_payload(ml.meta)), + Ok(LineKind::Compaction(cl)) => { + let replacement = cl + .replacement + .into_iter() + .map(display_message_from_line) + .collect(); + records.push(DisplayRecord::Compaction(CompactionMarker { + replacement, + ts: cl.ts, + request_id: cl.request_id, + })); + } + Ok(LineKind::Message(ml)) => { + records.push(DisplayRecord::Message(display_message_from_line(ml))); + } + Err(err) => { + log::warn!( + "[transcript] display: skipping malformed/unknown record line {} in {}: {err}", + line_no + 1, + path.display() + ); + } + } + } + + let meta = meta.with_context(|| { + format!( + "missing _meta header line in jsonl transcript {}", + path.display() + ) + })?; + + log::debug!( + "[transcript] display-loaded {} record(s) from {}", + records.len(), + path.display() + ); + + Ok(DisplaySessionTranscript { meta, records }) +} + /// Find the newest root `session_raw/*.jsonl` transcript whose metadata /// declares `thread_id`. /// @@ -661,60 +1260,65 @@ pub struct SubagentArchetypeUsage { pub model: Option, } -/// Parse just the `_meta` header of a root transcript JSONL (cheap — stops at -/// the first non-empty line). +/// Parse the authoritative `_meta` of a root transcript JSONL. +/// +/// Append-only files carry the immutable header on line 1 plus a refreshed +/// `_meta` line per turn (cumulative totals). The **last** `_meta` line wins, +/// so a multi-turn session reports its running totals — not just the first +/// turn's. Falls back to line 1 for legacy single-header files. fn read_transcript_meta_only(path: &Path) -> Option { let raw = fs::read_to_string(path).ok()?; + let mut latest: Option = None; for line in raw.lines() { let line = line.trim(); if line.is_empty() { continue; } - let ml: MetaLine = serde_json::from_str(line).ok()?; - let mp = ml.meta; - return Some(TranscriptMeta { - agent_name: mp.agent, - agent_id: mp.agent_id, - agent_type: mp.agent_type, - dispatcher: mp.dispatcher, - provider: mp.provider, - model: mp.model, - created: mp.created, - updated: mp.updated, - turn_count: mp.turn_count, - input_tokens: mp.input_tokens, - output_tokens: mp.output_tokens, - cached_input_tokens: mp.cached_input_tokens, - charged_amount_usd: mp.charged_amount_usd, - thread_id: mp.thread_id, - task_id: mp.task_id, - }); + if let Ok(ml) = serde_json::from_str::(line) { + latest = Some(meta_from_payload(ml.meta)); + } else if latest.is_none() { + // The first non-empty line must be a valid meta header. + return None; + } } - None + latest } /// Extract the last assistant message's usage + model from a transcript JSONL. /// Only the final assistant message of a turn carries these (see the JSONL -/// format docs at the top of this module). +/// format docs at the top of this module). Compaction records and refreshed +/// `_meta` lines are skipped; a `compaction` record's `replacement` assistant +/// rows are considered so a compacted transcript still surfaces its latest +/// usage. fn read_last_assistant_usage(path: &Path) -> Option<(MessageUsage, Option)> { let raw = fs::read_to_string(path).ok()?; let mut result = None; - let mut seen_meta = false; + let mut seen_first = false; for line in raw.lines() { let line = line.trim(); if line.is_empty() { continue; } - if !seen_meta { - seen_meta = true; // first non-empty line is the `_meta` header + if !seen_first { + seen_first = true; // first non-empty line is the `_meta` header continue; } - if let Ok(ml) = serde_json::from_str::(line) { - if ml.role == "assistant" { + match classify_line(line) { + Ok(LineKind::Message(ml)) if ml.role == "assistant" && !ml.interrupted => { if let Some(usage) = ml.usage { result = Some((usage, ml.model)); } } + Ok(LineKind::Compaction(cl)) => { + for ml in &cl.replacement { + if ml.role == "assistant" { + if let Some(usage) = ml.usage.clone() { + result = Some((usage, ml.model.clone())); + } + } + } + } + _ => {} } } result diff --git a/src/openhuman/agent/harness/session/transcript_tests.rs b/src/openhuman/agent/harness/session/transcript_tests.rs index 333d1b9777..ed649b63c4 100644 --- a/src/openhuman/agent/harness/session/transcript_tests.rs +++ b/src/openhuman/agent/harness/session/transcript_tests.rs @@ -976,3 +976,367 @@ fn read_thread_usage_summary_groups_subagents_by_archetype() { assert_eq!(researcher.input_tokens, 500); assert_eq!(researcher.runs, 1); } + +// ── Phase A: append-only + compaction + display + interrupted ───────── + +/// A helper mirroring the in-process persist loop: track the previously +/// persisted logical set and feed each turn through `append_transcript_turn`. +struct AppendHarness { + path: std::path::PathBuf, + prev: Vec, +} + +impl AppendHarness { + fn new(path: std::path::PathBuf) -> Self { + Self { + path, + prev: Vec::new(), + } + } + + fn turn( + &mut self, + messages: &[ChatMessage], + meta: &TranscriptMeta, + usage: Option<&TurnUsage>, + request_id: Option<&str>, + ) { + append_transcript_turn(&self.path, &self.prev, messages, meta, usage, request_id) + .expect("append turn"); + self.prev = messages.to_vec(); + } +} + +fn roles(messages: &[ChatMessage]) -> Vec<&str> { + messages.iter().map(|m| m.role.as_str()).collect() +} + +/// Pure extension across turns: the model-context read reflects the final +/// (growing) message set and never rewrites earlier lines. +#[test] +fn append_pure_extension_grows_context() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("append.jsonl"); + let meta = sample_meta(); + let mut h = AppendHarness::new(path.clone()); + + let turn1 = vec![ + ChatMessage::system("sys"), + ChatMessage::user("hi"), + ChatMessage::assistant("hello"), + ]; + h.turn(&turn1, &meta, None, None); + + let mut turn2 = turn1.clone(); + turn2.push(ChatMessage::user("again")); + turn2.push(ChatMessage::assistant("hello again")); + h.turn(&turn2, &meta, None, None); + + let loaded = read_transcript(&path).unwrap(); + assert_eq!( + roles(&loaded.messages), + vec!["system", "user", "assistant", "user", "assistant"] + ); + assert_eq!(loaded.messages[4].content, "hello again"); +} + +/// Compaction round-trip: after a reduction, the model-context read returns the +/// REDUCED context, while the display read returns the FULL pre-compaction +/// history plus the compaction marker. +#[test] +fn compaction_round_trip_model_vs_display() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("compact.jsonl"); + let meta = sample_meta(); + let mut h = AppendHarness::new(path.clone()); + + // Three growing turns. + let base = vec![ + ChatMessage::system("sys"), + ChatMessage::user("q1"), + ChatMessage::assistant("a1"), + ChatMessage::user("q2"), + ChatMessage::assistant("a2"), + ]; + h.turn(&base, &meta, None, None); + + // A reduction: the harness drops the earliest exchange and keeps a summary + // + the recent tail. This is NOT a prefix of `base`, so it must land as a + // compaction record. + let reduced = vec![ + ChatMessage::system("sys"), + ChatMessage::assistant("[summary] earlier discussion about q1/q2"), + ChatMessage::user("q3"), + ChatMessage::assistant("a3"), + ]; + h.turn(&reduced, &meta, None, None); + + // Model-context read == the reduced set only. + let model = read_transcript(&path).unwrap(); + assert_eq!( + model + .messages + .iter() + .map(|m| m.content.as_str()) + .collect::>(), + vec![ + "sys", + "[summary] earlier discussion about q1/q2", + "q3", + "a3" + ], + "model context must reflect the reduced set after compaction" + ); + + // Display read == full history: the 5 pre-compaction messages, then a + // compaction marker carrying the 4-message replacement. + let display = read_transcript_display(&path).unwrap(); + let pre: Vec<&str> = display + .records + .iter() + .take_while(|r| matches!(r, DisplayRecord::Message(_))) + .filter_map(|r| match r { + DisplayRecord::Message(m) => Some(m.message.content.as_str()), + _ => None, + }) + .collect(); + assert_eq!(pre, vec!["sys", "q1", "a1", "q2", "a2"]); + let marker = display + .records + .iter() + .find_map(|r| match r { + DisplayRecord::Compaction(c) => Some(c), + _ => None, + }) + .expect("display must retain the compaction marker"); + assert_eq!(marker.replacement.len(), 4); + assert_eq!( + marker.replacement[1].message.content, + "[summary] earlier discussion about q1/q2" + ); +} + +/// After a compaction, a subsequent pure extension appends normally and the +/// model-context read replays reset-then-extend to the correct final set. +#[test] +fn append_after_compaction_extends_reduced_set() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("compact_then_extend.jsonl"); + let meta = sample_meta(); + let mut h = AppendHarness::new(path.clone()); + + h.turn( + &[ + ChatMessage::system("sys"), + ChatMessage::user("q1"), + ChatMessage::assistant("a1"), + ], + &meta, + None, + None, + ); + let reduced = vec![ + ChatMessage::system("sys"), + ChatMessage::assistant("[summary]"), + ]; + h.turn(&reduced, &meta, None, None); + let mut extended = reduced.clone(); + extended.push(ChatMessage::user("q2")); + extended.push(ChatMessage::assistant("a2")); + h.turn(&extended, &meta, None, None); + + let model = read_transcript(&path).unwrap(); + assert_eq!( + model + .messages + .iter() + .map(|m| m.content.as_str()) + .collect::>(), + vec!["sys", "[summary]", "q2", "a2"] + ); +} + +/// request_id turn-boundary stamping round-trips into the display projection on +/// every appended line of a turn. +#[test] +fn request_id_stamped_on_every_line() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("reqid.jsonl"); + let meta = sample_meta(); + let mut h = AppendHarness::new(path.clone()); + + let turn1 = vec![ChatMessage::system("sys"), ChatMessage::user("q1")]; + h.turn(&turn1, &meta, None, Some("req-1")); + + let mut turn2 = turn1.clone(); + turn2.push(ChatMessage::assistant("a2")); + h.turn(&turn2, &meta, None, Some("req-2")); + + let display = read_transcript_display(&path).unwrap(); + let msgs: Vec<&DisplayMessage> = display + .records + .iter() + .filter_map(|r| match r { + DisplayRecord::Message(m) => Some(m), + _ => None, + }) + .collect(); + // turn1 wrote sys + user with req-1; turn2 appended only the assistant tail + // with req-2. + assert_eq!(msgs[0].request_id.as_deref(), Some("req-1")); + assert_eq!(msgs[1].request_id.as_deref(), Some("req-1")); + assert_eq!(msgs[2].request_id.as_deref(), Some("req-2")); + assert_eq!(msgs[2].message.content, "a2"); +} + +/// An interrupted partial is appended to the file, is visible in the display +/// read flagged `interrupted`, and is SKIPPED by the model-context read (a +/// resumed context never carries a truncated answer). +#[test] +fn interrupted_partial_display_only() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("interrupted.jsonl"); + let meta = sample_meta(); + let mut h = AppendHarness::new(path.clone()); + h.turn( + &[ChatMessage::system("sys"), ChatMessage::user("q1")], + &meta, + None, + Some("req-1"), + ); + + append_interrupted_partial( + &path, + "partial answer that was cut off", + Some("req-1"), + Some(3), + Some("thinking that was cut off"), + ) + .expect("append interrupted"); + + // Model context: the partial is skipped. + let model = read_transcript(&path).unwrap(); + assert_eq!(roles(&model.messages), vec!["system", "user"]); + assert!( + !model.messages.iter().any(|m| m.content.contains("cut off")), + "interrupted partial must NOT enter the model context" + ); + + // Display: the partial is present and flagged. + let display = read_transcript_display(&path).unwrap(); + let partial = display + .records + .iter() + .find_map(|r| match r { + DisplayRecord::Message(m) if m.interrupted => Some(m), + _ => None, + }) + .expect("display must include the interrupted partial"); + assert_eq!(partial.message.content, "partial answer that was cut off"); + assert_eq!(partial.request_id.as_deref(), Some("req-1")); + assert_eq!(partial.iteration, Some(3)); + assert_eq!( + partial.reasoning_content.as_deref(), + Some("thinking that was cut off"), + "interrupted partial must carry its reasoning_content" + ); +} + +/// Empty partial content is a no-op — no line is written. +#[test] +fn interrupted_partial_empty_is_noop() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("empty_interrupt.jsonl"); + let meta = sample_meta(); + let mut h = AppendHarness::new(path.clone()); + h.turn(&[ChatMessage::user("q")], &meta, None, None); + append_interrupted_partial(&path, "", None, None, None).expect("noop"); + let display = read_transcript_display(&path).unwrap(); + assert!(display + .records + .iter() + .all(|r| matches!(r, DisplayRecord::Message(m) if !m.interrupted))); +} + +/// A legacy file — one produced by the full-rewrite `write_transcript` with no +/// compaction records and no `version` — reads identically under both the +/// model-context and display readers. +#[test] +fn legacy_file_reads_identically() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("legacy.jsonl"); + let messages = sample_messages(); + let meta = sample_meta(); + // Full-rewrite writer == the legacy shape (append-only readers must tolerate + // it: zero compaction records, last `_meta` == the only `_meta`). + write_transcript(&path, &messages, &meta, None).unwrap(); + + let model = read_transcript(&path).unwrap(); + assert_eq!(model.messages.len(), messages.len()); + assert_eq!(roles(&model.messages), roles(&messages)); + + let display = read_transcript_display(&path).unwrap(); + let display_roles: Vec<&str> = display + .records + .iter() + .filter_map(|r| match r { + DisplayRecord::Message(m) => Some(m.message.role.as_str()), + _ => None, + }) + .collect(); + assert_eq!(display_roles, roles(&messages)); +} + +/// A file carrying an unknown record kind (as a future core might write) is +/// skipped by the reader rather than crashing it. +#[test] +fn unknown_record_kind_is_skipped_not_fatal() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("unknown_kind.jsonl"); + let meta = sample_meta(); + let mut h = AppendHarness::new(path.clone()); + h.turn( + &[ChatMessage::system("sys"), ChatMessage::user("q1")], + &meta, + None, + None, + ); + // Simulate a future kind by appending a foreign record line. + { + use std::io::Write; + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap(); + writeln!(f, "{{\"kind\":\"future_thing\",\"payload\":42}}").unwrap(); + } + // Append a normal turn after the unknown line to prove reading continues. + let mut msgs = vec![ChatMessage::system("sys"), ChatMessage::user("q1")]; + msgs.push(ChatMessage::assistant("a1")); + h.prev = vec![ChatMessage::system("sys"), ChatMessage::user("q1")]; + h.turn(&msgs, &meta, None, None); + + let model = read_transcript(&path).unwrap(); + // The unknown record is skipped; the real messages survive. + assert!(model.messages.iter().any(|m| m.content == "a1")); + assert!(!model + .messages + .iter() + .any(|m| m.content.contains("future_thing"))); +} + +/// The `_meta` version field is stamped by the append writer and absent (0) on +/// legacy files — but both remain readable. +#[test] +fn meta_version_stamped_and_optional() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("version.jsonl"); + let meta = sample_meta(); + let mut h = AppendHarness::new(path.clone()); + h.turn(&[ChatMessage::user("q")], &meta, None, None); + let raw = std::fs::read_to_string(&path).unwrap(); + assert!( + raw.lines().next().unwrap().contains("\"version\":1"), + "append writer must stamp the schema version on the meta header" + ); +} diff --git a/src/openhuman/agent/harness/session/turn/core.rs b/src/openhuman/agent/harness/session/turn/core.rs index e7ef2fdfc1..5582c1e178 100644 --- a/src/openhuman/agent/harness/session/turn/core.rs +++ b/src/openhuman/agent/harness/session/turn/core.rs @@ -332,6 +332,67 @@ fn tool_records_from_conversation( records } +/// Stamp each **failed** tool-result [`ChatMessage`] with its failure outcome +/// before persistence, so the derived transcript view can render an error tool +/// row instead of a false success. +/// +/// The harness folds a tool result into a `role:"tool"` message whose native +/// content envelope (`{"tool_call_id":…,"content":…}`) has already dropped +/// `ToolResult::is_error`. The only structured per-call success signal is the +/// captured [`ToolCallOutcome`] side-channel; correlate by provider call id and +/// re-attach an additive failure marker (see +/// `transcript::attach_tool_failure_metadata`). Non-tool messages, tool messages +/// with no matching outcome, and successful calls are left untouched. +fn stamp_tool_failures( + messages: &mut [ChatMessage], + tool_outcomes: &[crate::openhuman::tinyagents::ToolCallOutcome], +) { + use crate::openhuman::agent::harness::session::transcript; + if tool_outcomes.is_empty() { + return; + } + for msg in messages.iter_mut() { + if msg.role != "tool" { + continue; + } + let Some(call_id) = parse_tool_call_id(&msg.content) else { + continue; + }; + let Some(outcome) = tool_outcomes.iter().find(|o| o.call_id == call_id) else { + continue; + }; + if outcome.success { + continue; + } + let detail = short_failure_detail(&outcome.content); + log::debug!( + "[transcript] stamping tool failure call_id={call_id} name={}", + outcome.name + ); + transcript::attach_tool_failure_metadata(msg, detail.as_deref()); + } +} + +/// Extract the `tool_call_id` from a native tool-result content envelope +/// (`{"tool_call_id":…,"content":…}`). `None` for non-envelope content (XML / +/// P-Format dispatchers, which don't emit `role:"tool"` messages anyway). +fn parse_tool_call_id(content: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(content).ok()?; + value.get("tool_call_id")?.as_str().map(str::to_string) +} + +/// Reduce a tool's error output to a short, single-line reason for display. +fn short_failure_detail(content: &str) -> Option { + const MAX: usize = 160; + let line = content.lines().map(str::trim).find(|l| !l.is_empty())?; + let short: String = line.chars().take(MAX).collect(); + if short.is_empty() { + None + } else { + Some(short) + } +} + /// Rewrite the **trailing** assistant `Chat` message in `history` to `text`, /// keeping the persisted transcript and the next turn's KV-cache prefix /// consistent with a repaired required-output reply (issue #4117). Only the last @@ -1465,7 +1526,11 @@ impl Agent { }, ); - let persisted = self.tool_dispatcher.to_provider_messages(&self.history); + let mut persisted = self.tool_dispatcher.to_provider_messages(&self.history); + // Re-attach per-call failure outcomes (dropped when the engine folded + // each tool result into a `role:"tool"` message) so the derived + // transcript view renders failed tools as errors, not successes. + stamp_tool_failures(&mut persisted, &outcome.tool_outcomes); // Carry the turn's provider (event channel) + effective model and usage // into the persisted transcript meta. Passing `None` here dropped // `provider`/`model` from every transcript (they are `TranscriptMeta` diff --git a/src/openhuman/agent/harness/session/turn/session_io.rs b/src/openhuman/agent/harness/session/turn/session_io.rs index 9549be90f5..b058469c29 100644 --- a/src/openhuman/agent/harness/session/turn/session_io.rs +++ b/src/openhuman/agent/harness/session/turn/session_io.rs @@ -546,8 +546,25 @@ impl Agent { task_id: None, }; - match transcript::write_transcript(path, messages, &meta, turn_usage) { + // Append-only write (Phase A, transcript-derived view): diff this turn's + // logical messages against the previously-persisted set tracked in + // memory. A pure extension appends only the new tail; a context + // reduction appends a `compaction` record. The file is never rewritten, + // so pre-compaction history survives on disk for the display projection. + // `request_id` (web-chat only) stamps a turn boundary on each line. + let prev = std::mem::take(&mut self.persisted_transcript_messages); + let request_id = crate::openhuman::agent::turn_origin::current_request_id(); + match transcript::append_transcript_turn( + path, + &prev, + messages, + &meta, + turn_usage, + request_id.as_deref(), + ) { Ok(()) => { + // Track the new persisted logical set for the next turn's diff. + self.persisted_transcript_messages = messages.to_vec(); // Best-effort, non-fatal dual-write into the TinyAgents store. // Gated by the default-ON session dual-write flag // (`OPENHUMAN_SESSION_DUAL_WRITE` is a kill switch). Only runs @@ -556,8 +573,11 @@ impl Agent { self.maybe_dual_write_session_store(path, messages, &meta, turn_usage); } Err(err) => { + // Restore the tracked state so a transient failure doesn't make + // the next turn mis-diff (and spuriously emit a compaction). + self.persisted_transcript_messages = prev; log::warn!( - "[transcript] failed to write transcript {}: {err}", + "[transcript] failed to append transcript {}: {err}", path.display() ); } diff --git a/src/openhuman/agent/harness/session/types.rs b/src/openhuman/agent/harness/session/types.rs index 10bac4625a..c6a6bd71f6 100644 --- a/src/openhuman/agent/harness/session/types.rs +++ b/src/openhuman/agent/harness/session/types.rs @@ -122,9 +122,16 @@ pub struct Agent { /// [`AgentDefinitionRegistry`]: crate::openhuman::agent::harness::definition::AgentDefinitionRegistry pub(super) agent_definition_id: String, /// Resolved filesystem path for this session's transcript file. - /// Set on first write, reused for subsequent overwrites within the + /// Set on first write, reused for subsequent **appends** within the /// same session. pub(super) session_transcript_path: Option, + /// The logical message set most recently persisted to + /// `session_transcript_path`, tracked in memory so the append-only writer + /// can diff each turn's messages against it (pure extension → append tail; + /// reduction → compaction record) without re-reading the growing file. + /// Empty until the first persist. Each process writes its own transcript + /// file, so this in-memory state is always aligned with the file it owns. + pub(super) persisted_transcript_messages: Vec, /// Unique transcript key for this session, formatted as /// `"{unix_ts}_{agent_id}"`. Generated once at agent-build time so /// every transcript write in this session uses the same filename diff --git a/src/openhuman/agent/turn_origin.rs b/src/openhuman/agent/turn_origin.rs index ef712b5837..29e5e55489 100644 --- a/src/openhuman/agent/turn_origin.rs +++ b/src/openhuman/agent/turn_origin.rs @@ -134,6 +134,18 @@ pub fn current() -> Option { AGENT_TURN_ORIGIN.try_with(|o| o.clone()).ok() } +/// Read the ambient web-chat `request_id` for the current turn, when one was +/// scoped by an [`AgentTurnOrigin::WebChat`] entry point. `None` for every +/// other origin (channel / cron / CLI / sub-agent) and outside any scope — +/// those turns are not request-scoped, so their transcript lines carry no +/// turn-boundary marker. +pub fn current_request_id() -> Option { + match current() { + Some(AgentTurnOrigin::WebChat { request_id, .. }) => request_id, + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/openhuman/threads/mod.rs b/src/openhuman/threads/mod.rs index d4ac7d9f6e..5f4ed92bf6 100644 --- a/src/openhuman/threads/mod.rs +++ b/src/openhuman/threads/mod.rs @@ -9,6 +9,7 @@ pub mod ops; pub mod schemas; pub mod title; pub mod tools; +pub mod transcript_view; pub mod turn_state; pub mod welcome_migration; diff --git a/src/openhuman/threads/ops.rs b/src/openhuman/threads/ops.rs index 4ed94914ae..6ea2052e11 100644 --- a/src/openhuman/threads/ops.rs +++ b/src/openhuman/threads/ops.rs @@ -693,6 +693,57 @@ pub struct ThreadTokenUsageRequest { pub thread_id: String, } +/// Request for [`transcript_get`]: the thread to project, plus newest-first +/// pagination controls. `cursor` is the opaque token from a prior page's +/// `nextCursor`; `limit` defaults to one screen. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct TranscriptGetRequest { + pub thread_id: String, + #[serde(default)] + pub cursor: Option, + #[serde(default)] + pub limit: Option, +} + +/// Project a thread's settled transcript (derived from `session_raw/*.jsonl`) +/// into typed display items, newest-first paginated. Returns an empty page with +/// `hasTranscript: false` when the thread has no persisted transcript yet. +pub async fn transcript_get( + request: TranscriptGetRequest, +) -> Result< + RpcOutcome>, + String, +> { + let dir = workspace_dir().await?; + let thread_id = request.thread_id.trim(); + if thread_id.is_empty() { + return Err("thread_id is required".to_string()); + } + let page = crate::openhuman::threads::transcript_view::get_page( + &dir, + thread_id, + request.cursor.as_deref(), + request.limit, + ); + let counts = counts([ + ("items", page.items.len()), + ("total", page.total), + ("has_transcript", usize::from(page.has_transcript)), + ]); + let pagination = Some(PaginationMeta { + limit: request + .limit + .unwrap_or(crate::openhuman::threads::transcript_view::DEFAULT_LIMIT), + offset: request + .cursor + .as_deref() + .and_then(|c| c.trim().parse::().ok()) + .unwrap_or(0), + count: page.total, + }); + Ok(envelope(page, Some(counts), pagination)) +} + /// Aggregated token/cost usage for one thread, read back from its persisted /// session transcripts. Seeds the UI footer when the user selects a thread so /// the totals reflect prior turns instead of starting at zero. diff --git a/src/openhuman/threads/schemas.rs b/src/openhuman/threads/schemas.rs index 0a01f927ec..f205806f63 100644 --- a/src/openhuman/threads/schemas.rs +++ b/src/openhuman/threads/schemas.rs @@ -39,6 +39,7 @@ pub fn all_controller_schemas() -> Vec { schemas("task_board_get"), schemas("task_board_put"), schemas("token_usage"), + schemas("transcript_get"), ] } @@ -120,6 +121,10 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("token_usage"), handler: handle_token_usage, }, + RegisteredController { + schema: schemas("transcript_get"), + handler: handle_transcript_get, + }, ] } @@ -527,6 +532,38 @@ pub fn schemas(function: &str) -> ControllerSchema { required: true, }], }, + "transcript_get" => ControllerSchema { + namespace: "threads", + function: "transcript_get", + description: + "Project a thread's settled transcript (derived from session_raw/*.jsonl) into typed display items, newest-first paginated.", + inputs: vec![ + FieldSchema { + name: "thread_id", + ty: TypeSchema::String, + comment: "Thread identifier.", + required: true, + }, + FieldSchema { + name: "cursor", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Opaque pagination cursor from a prior page's nextCursor; absent starts at the newest item.", + required: false, + }, + FieldSchema { + name: "limit", + ty: TypeSchema::Option(Box::new(TypeSchema::U64)), + comment: "Max items to return (default 50, capped at 500).", + required: false, + }, + ], + outputs: vec![FieldSchema { + name: "result", + ty: TypeSchema::Json, + comment: "Envelope with the newest-first page of display items, total, and nextCursor.", + required: true, + }], + }, _other => ControllerSchema { namespace: "threads", function: "unknown", @@ -745,6 +782,13 @@ fn handle_token_usage(params: Map) -> ControllerFuture { }) } +fn handle_transcript_get(params: Map) -> ControllerFuture { + Box::pin(async move { + let p = parse::(params)?; + to_json(ops::transcript_get(p).await?) + }) +} + // ── Helpers ────────────────────────────────────────────────────────── fn parse(params: Map) -> Result { diff --git a/src/openhuman/threads/schemas_tests.rs b/src/openhuman/threads/schemas_tests.rs index ec9ed44a15..44225137c6 100644 --- a/src/openhuman/threads/schemas_tests.rs +++ b/src/openhuman/threads/schemas_tests.rs @@ -22,6 +22,7 @@ const ALL_FUNCTIONS: &[&str] = &[ "task_board_get", "task_board_put", "token_usage", + "transcript_get", ]; #[test] diff --git a/src/openhuman/threads/transcript_view/cache.rs b/src/openhuman/threads/transcript_view/cache.rs new file mode 100644 index 0000000000..f8b9016837 --- /dev/null +++ b/src/openhuman/threads/transcript_view/cache.rs @@ -0,0 +1,152 @@ +//! In-memory, mtime-keyed projection cache for the transcript view. +//! +//! The settled transcript is derived from `session_raw/*.jsonl` on every +//! request. Re-projecting a long thread on each page fetch is wasteful, so we +//! memoize per-thread projections keyed on the backing files' `(path, mtime, +//! len)` signature. An append (new turn, interrupted partial, sub-agent file) +//! changes the signature and transparently invalidates the entry — there are +//! **no disk writes** and no explicit invalidation call. The cache is bounded +//! (LRU over a few dozen threads) so a long-lived core can't grow it without +//! limit. + +use std::collections::HashMap; +use std::collections::VecDeque; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::SystemTime; + +use super::project; +use super::types::ProjectedTranscript; + +const LOG_PREFIX: &str = "[threads][transcript][cache]"; + +/// Number of distinct threads whose projections are retained. Beyond this the +/// least-recently-used entry is evicted. +const CACHE_CAPACITY: usize = 32; + +/// Signature of one backing file — changes on any append/rewrite. +#[derive(Debug, Clone, PartialEq, Eq)] +struct FileSig { + path: PathBuf, + mtime: Option, + len: u64, +} + +fn file_sig(path: &Path) -> FileSig { + let (mtime, len) = match std::fs::metadata(path) { + Ok(meta) => (meta.modified().ok(), meta.len()), + Err(_) => (None, 0), + }; + FileSig { + path: path.to_path_buf(), + mtime, + len, + } +} + +struct CacheEntry { + signature: Vec, + projected: Arc, +} + +#[derive(Default)] +struct CacheInner { + entries: HashMap, + /// LRU order — front is least-recently-used. + order: VecDeque, +} + +/// Bounded per-thread projection cache. +#[derive(Default)] +pub struct TranscriptViewCache { + inner: Mutex, +} + +impl TranscriptViewCache { + /// Project `thread_id`'s transcript, serving a cached result when the + /// backing files are unchanged. `None` when the thread has no transcript. + pub fn get_or_project( + &self, + workspace_dir: &Path, + thread_id: &str, + ) -> Option> { + let (root_path, sub_paths) = project::resolve_files(workspace_dir, thread_id)?; + let signature: Vec = std::iter::once(file_sig(&root_path)) + .chain(sub_paths.iter().map(|p| file_sig(p))) + .collect(); + + { + let mut inner = self.inner.lock().ok()?; + if let Some(entry) = inner.entries.get(thread_id) { + if entry.signature == signature { + let projected = entry.projected.clone(); + touch(&mut inner, thread_id); + log::debug!( + "{LOG_PREFIX} hit thread={thread_id} items={}", + projected.items.len() + ); + return Some(projected); + } + log::debug!("{LOG_PREFIX} miss (signature changed) thread={thread_id}"); + } else { + log::debug!("{LOG_PREFIX} miss (cold) thread={thread_id}"); + } + } + + let projected = Arc::new(project::project_from_files( + thread_id, &root_path, &sub_paths, + )); + + let mut inner = self.inner.lock().ok()?; + inner.entries.insert( + thread_id.to_string(), + CacheEntry { + signature, + projected: projected.clone(), + }, + ); + touch(&mut inner, thread_id); + evict_if_needed(&mut inner); + log::debug!( + "{LOG_PREFIX} stored thread={thread_id} items={} cache_size={}", + projected.items.len(), + inner.entries.len() + ); + Some(projected) + } + + #[cfg(test)] + fn len(&self) -> usize { + self.inner.lock().unwrap().entries.len() + } +} + +/// Move `thread_id` to the most-recently-used end of the LRU order. +fn touch(inner: &mut CacheInner, thread_id: &str) { + if let Some(pos) = inner.order.iter().position(|t| t == thread_id) { + inner.order.remove(pos); + } + inner.order.push_back(thread_id.to_string()); +} + +/// Evict least-recently-used entries until within [`CACHE_CAPACITY`]. +fn evict_if_needed(inner: &mut CacheInner) { + while inner.entries.len() > CACHE_CAPACITY { + let Some(victim) = inner.order.pop_front() else { + break; + }; + inner.entries.remove(&victim); + log::debug!("{LOG_PREFIX} evicted thread={victim}"); + } +} + +/// Process-wide cache singleton. The transcript view is read-only and derived, +/// so one shared cache across RPC calls is correct. +pub fn global() -> &'static TranscriptViewCache { + static CACHE: OnceLock = OnceLock::new(); + CACHE.get_or_init(TranscriptViewCache::default) +} + +#[cfg(test)] +#[path = "cache_tests.rs"] +mod tests; diff --git a/src/openhuman/threads/transcript_view/cache_tests.rs b/src/openhuman/threads/transcript_view/cache_tests.rs new file mode 100644 index 0000000000..6422c6f0a9 --- /dev/null +++ b/src/openhuman/threads/transcript_view/cache_tests.rs @@ -0,0 +1,74 @@ +//! Cache hit/miss + invalidation tests for the transcript view cache. + +use super::TranscriptViewCache; +use crate::openhuman::agent::harness::session::transcript; +use std::path::Path; +use tempfile::TempDir; + +fn meta_line(thread_id: &str) -> String { + format!( + r#"{{"_meta":{{"version":1,"agent":"orchestrator","dispatcher":"native","created":"2026-07-21T00:00:00Z","updated":"2026-07-21T00:00:00Z","turn_count":1,"input_tokens":0,"output_tokens":0,"cached_input_tokens":0,"charged_amount_usd":0.0,"thread_id":"{thread_id}"}}}}"# + ) +} + +fn write_raw(workspace: &Path, stem: &str, thread_id: &str, body: &[&str]) -> std::path::PathBuf { + let path = transcript::resolve_keyed_transcript_path(workspace, stem).unwrap(); + let mut buf = meta_line(thread_id); + buf.push('\n'); + for line in body { + buf.push_str(line); + buf.push('\n'); + } + std::fs::write(&path, buf).unwrap(); + path +} + +#[test] +fn recomputes_when_file_grows() { + let dir = TempDir::new().unwrap(); + let path = write_raw( + dir.path(), + "100_orchestrator", + "thr_cache", + &[r#"{"role":"user","content":"first"}"#], + ); + let cache = TranscriptViewCache::default(); + + let a = cache + .get_or_project(dir.path(), "thr_cache") + .expect("first"); + assert_eq!(a.items.len(), 1); + + // Second call, unchanged file → same cached Arc (hit). + let b = cache + .get_or_project(dir.path(), "thr_cache") + .expect("second"); + assert!( + std::sync::Arc::ptr_eq(&a, &b), + "unchanged file must serve cached Arc" + ); + + // Append a line → file length changes → signature invalidates → recompute. + let mut appended = std::fs::read_to_string(&path).unwrap(); + appended.push_str(r#"{"role":"assistant","content":"second"}"#); + appended.push('\n'); + std::fs::write(&path, appended).unwrap(); + + let c = cache + .get_or_project(dir.path(), "thr_cache") + .expect("third"); + assert!(!std::sync::Arc::ptr_eq(&a, &c), "grown file must recompute"); + assert_eq!( + c.items.len(), + 2, + "recomputed projection reflects the append" + ); +} + +#[test] +fn missing_thread_returns_none() { + let dir = TempDir::new().unwrap(); + let cache = TranscriptViewCache::default(); + assert!(cache.get_or_project(dir.path(), "ghost").is_none()); + assert_eq!(cache.len(), 0); +} diff --git a/src/openhuman/threads/transcript_view/mod.rs b/src/openhuman/threads/transcript_view/mod.rs new file mode 100644 index 0000000000..d17607644e --- /dev/null +++ b/src/openhuman/threads/transcript_view/mod.rs @@ -0,0 +1,105 @@ +//! Transcript-derived view: project the append-only `session_raw/*.jsonl` +//! source of truth into typed display items for the chat renderer, with +//! newest-first pagination over a bounded in-memory cache. +//! +//! Entry point: [`get_page`] (used by the `threads.transcript_get` RPC). + +mod cache; +mod project; +pub mod types; + +use std::path::Path; + +use serde::Serialize; + +pub use types::{DisplayItem, ProjectedTranscript, ToolCallStatus}; + +const LOG_PREFIX: &str = "[threads][transcript]"; + +/// Default page size — roughly one screen of chat items. +pub const DEFAULT_LIMIT: usize = 50; +/// Hard upper bound on a single page so a client can't request an unbounded +/// projection slice. +pub const MAX_LIMIT: usize = 500; + +/// One newest-first page of a thread's projected transcript. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TranscriptPage { + pub thread_id: String, + /// Display items for this page, **newest-first**. + pub items: Vec, + /// Total top-level items available for the thread. + pub total: usize, + /// Opaque cursor to pass back for the next (older) page; `null` at the end. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + /// `true` when more (older) items remain beyond this page. + pub has_more: bool, + /// `false` when the thread has no persisted transcript yet (empty page). + pub has_transcript: bool, +} + +/// Project `thread_id`'s transcript and return one newest-first page. +/// +/// `cursor` is the opaque token returned as `next_cursor` by a previous call +/// (an offset from the newest item); `None`/empty starts at the newest item. +/// `limit` defaults to [`DEFAULT_LIMIT`] and is clamped to [`MAX_LIMIT`]. +pub fn get_page( + workspace_dir: &Path, + thread_id: &str, + cursor: Option<&str>, + limit: Option, +) -> TranscriptPage { + let limit = limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT); + let offset = parse_cursor(cursor); + + let Some(projected) = cache::global().get_or_project(workspace_dir, thread_id) else { + log::debug!("{LOG_PREFIX} get_page thread={thread_id}: no transcript"); + return TranscriptPage { + thread_id: thread_id.to_string(), + items: Vec::new(), + total: 0, + next_cursor: None, + has_more: false, + has_transcript: false, + }; + }; + + let total = projected.items.len(); + let start = offset.min(total); + let end = (offset + limit).min(total); + // Newest-first: item `offset` is the newest, walking backwards from the end. + let items: Vec = (start..end) + .map(|i| projected.items[total - 1 - i].clone()) + .collect(); + let has_more = end < total; + let next_cursor = has_more.then(|| end.to_string()); + + log::debug!( + "{LOG_PREFIX} get_page thread={thread_id} total={total} offset={offset} returned={} has_more={has_more}", + items.len() + ); + + TranscriptPage { + thread_id: thread_id.to_string(), + items, + total, + next_cursor, + has_more, + has_transcript: true, + } +} + +/// Parse the opaque cursor into a numeric offset (0 on absent/invalid). +fn parse_cursor(cursor: Option<&str>) -> usize { + cursor + .map(str::trim) + .filter(|c| !c.is_empty()) + .and_then(|c| c.parse::().ok()) + .unwrap_or(0) +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/src/openhuman/threads/transcript_view/project.rs b/src/openhuman/threads/transcript_view/project.rs new file mode 100644 index 0000000000..328e56102c --- /dev/null +++ b/src/openhuman/threads/transcript_view/project.rs @@ -0,0 +1,499 @@ +//! Project raw session-transcript records into typed display items. +//! +//! Turns the append-only log's [`DisplayRecord`]s (message lines, compaction +//! markers, interrupted partials) into the frontend's chat vocabulary +//! ([`DisplayItem`]), sanitizing injected scaffolding as it goes. Sub-agent +//! sibling files are discovered and nested one level deep. + +use std::collections::VecDeque; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::openhuman::agent::harness::session::transcript::{ + self, CompactionMarker, DisplayMessage, DisplayRecord, +}; + +use super::types::{DisplayItem, ProjectedTranscript, ToolCallFailure, ToolCallStatus}; + +const LOG_PREFIX: &str = "[threads][transcript]"; + +/// Max sub-agent nesting depth the projection descends. The plan calls for +/// one level of recursion; we allow a small bound so a delegated worker that +/// itself delegates still surfaces, without unbounded fan-out. +const MAX_SUBAGENT_DEPTH: usize = 3; + +/// The scaffolding line injected onto every user message (see +/// `agent::prompts::current_datetime_line`). Stripped at projection so the UI +/// shows the user's actual words, not the per-turn time stamp. +const DATETIME_PREFIX: &str = "Current Date & Time:"; + +/// A legacy/alternate channel-context prefix. Kept for defensiveness; the +/// live injector currently only prepends [`DATETIME_PREFIX`]. +const CHANNEL_CONTEXT_PREFIX: &str = "[Channel context]"; + +/// Resolve a thread's root transcript, discover its sub-agent siblings, and +/// project everything into display items. Returns `None` when the thread has +/// no root transcript yet (brand-new thread / first turn not persisted). +pub fn project_thread(workspace_dir: &Path, thread_id: &str) -> Option { + let (root_path, sub_paths) = resolve_files(workspace_dir, thread_id)?; + Some(project_from_files(thread_id, &root_path, &sub_paths)) +} + +/// Resolve the on-disk file set backing a thread's transcript view: the root +/// transcript path plus every sub-agent sibling file. `None` when the thread +/// has no root transcript yet. Exposed so the cache can key on these paths +/// (and their mtimes/lengths) without re-projecting. +pub fn resolve_files(workspace_dir: &Path, thread_id: &str) -> Option<(PathBuf, Vec)> { + let root_path = transcript::find_root_transcript_for_thread(workspace_dir, thread_id)?; + let root_stem = root_path.file_stem()?.to_str()?.to_string(); + let sub_paths = discover_subagent_files(workspace_dir, &root_stem); + Some((root_path, sub_paths)) +} + +/// Project a thread from an already-resolved file set (root + sub-agent +/// siblings). Missing/unreadable files degrade to empty rather than failing. +pub fn project_from_files( + thread_id: &str, + root_path: &Path, + sub_paths: &[PathBuf], +) -> ProjectedTranscript { + let root_stem = root_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_default() + .to_string(); + + log::debug!( + "{LOG_PREFIX} projecting thread={thread_id} root={} subagent_files={}", + root_path.display(), + sub_paths.len() + ); + + // Read the root display records once: they feed both the top-level items + // and the per-turn timestamp ranges used to anchor sub-agent trails. + let (mut items, segments) = match transcript::read_transcript_display(root_path) { + Ok(d) => (project_records(&d.records), turn_segments(&d.records)), + Err(err) => { + log::warn!( + "{LOG_PREFIX} failed to read root transcript {}: {err}", + root_path.display() + ); + (Vec::new(), Vec::new()) + } + }; + + let subagents = build_subagent_items(sub_paths, &root_stem, 0, &segments); + log::debug!( + "{LOG_PREFIX} projected thread={thread_id} top_level_items={} subagents={}", + items.len(), + subagents.len() + ); + items.extend(subagents); + + ProjectedTranscript { + thread_id: thread_id.to_string(), + items, + } +} + +/// Discover every sub-agent transcript file for `root_stem` under +/// `session_raw/`. Sub-agent stems are `{root_stem}__…`; results are sorted so +/// the timestamp-prefixed suffixes order by creation time. +fn discover_subagent_files(workspace_dir: &Path, root_stem: &str) -> Vec { + let raw_dir = workspace_dir.join("session_raw"); + let prefix = format!("{root_stem}__"); + let Ok(entries) = fs::read_dir(&raw_dir) else { + return Vec::new(); + }; + let mut paths: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("jsonl")) + .filter(|p| { + p.file_stem() + .and_then(|s| s.to_str()) + .is_some_and(|stem| stem.starts_with(&prefix)) + }) + .collect(); + paths.sort(); + paths +} + +/// Build nested [`DisplayItem::Subagent`] items for the direct children of +/// `parent_stem` among `all_sub_paths`, recursing one level per depth up to +/// [`MAX_SUBAGENT_DEPTH`]. Attachment is flat (ordered by file timestamp): the +/// transcript doesn't record a robust delegation-call → file link, so we nest +/// by stem lineage rather than guessing the parent tool call. +/// +/// Each item is anchored to a parent turn via [`anchor_request_id`] so the +/// frontend can render the trail under the turn that spawned it rather than the +/// most recent turn. `segments` are the root turns' start timestamps. +fn build_subagent_items( + all_sub_paths: &[PathBuf], + parent_stem: &str, + depth: usize, + segments: &[(String, i64)], +) -> Vec { + if depth >= MAX_SUBAGENT_DEPTH { + return Vec::new(); + } + let child_prefix = format!("{parent_stem}__"); + let mut out = Vec::new(); + for path in all_sub_paths { + let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + let Some(rest) = stem.strip_prefix(&child_prefix) else { + continue; + }; + // Direct child only — no further `__` in the remainder. + if rest.contains("__") { + continue; + } + let display = match transcript::read_transcript_display(path) { + Ok(d) => d, + Err(err) => { + log::warn!( + "{LOG_PREFIX} failed to read sub-agent transcript {}: {err}", + path.display() + ); + continue; + } + }; + let mut items = project_records(&display.records); + items.extend(build_subagent_items( + all_sub_paths, + stem, + depth + 1, + segments, + )); + // Prefer the archetype id from meta; fall back to the stem suffix. + let id = if display.meta.agent_name.is_empty() { + rest.to_string() + } else { + display.meta.agent_name.clone() + }; + // Anchor to the parent turn active at the sub-agent's spawn time. + let request_id = anchor_request_id(child_spawn_unix(rest), segments); + log::debug!("{LOG_PREFIX} subagent id={id} stem={rest} anchored request_id={request_id:?}"); + out.push(DisplayItem::Subagent { + id, + request_id, + items, + }); + } + out +} + +/// The root turns' start timestamps as `(request_id, unix_seconds)` in file +/// order — one entry per turn boundary. Built from the first timestamped line +/// of each `request_id` run. Turns whose lines carry no `request_id` or no +/// parseable timestamp contribute nothing (legacy/CLI transcripts yield an +/// empty list, so sub-agents there stay unanchored). +fn turn_segments(records: &[DisplayRecord]) -> Vec<(String, i64)> { + let mut segments: Vec<(String, i64)> = Vec::new(); + let mut last_request_id: Option = None; + for record in records { + let DisplayRecord::Message(msg) = record else { + continue; + }; + let (Some(rid), Some(ts)) = (msg.request_id.as_deref(), msg.ts.as_deref()) else { + continue; + }; + if last_request_id.as_deref() == Some(rid) { + continue; + } + let Some(unix) = parse_rfc3339_unix(ts) else { + continue; + }; + segments.push((rid.to_string(), unix)); + last_request_id = Some(rid.to_string()); + } + segments +} + +/// Extract a sub-agent's spawn unix timestamp (seconds) from its file-stem +/// suffix. Stems are `{unix_ts}_{agent_id}`; the leading integer is the agent +/// build/spawn time (see the transcript module's stem docs). `None` for +/// non-numeric legacy stems. +fn child_spawn_unix(stem_suffix: &str) -> Option { + stem_suffix + .split('_') + .next() + .and_then(|s| s.parse::().ok()) +} + +/// Parse an RFC-3339 timestamp into unix seconds. +fn parse_rfc3339_unix(ts: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(ts) + .ok() + .map(|dt| dt.timestamp()) +} + +/// Anchor a sub-agent to the parent turn that was active at its `child_unix` +/// spawn time: the last turn segment whose start is `<= child_unix`. +/// +/// Fallbacks (documented heuristic, since sub-agent files carry no explicit +/// delegation-call back-link): +/// - No segments (legacy/CLI root): `None` — the item stays unanchored and the +/// frontend leaves it under the current turn cursor, as before. +/// - Unknown spawn time (non-numeric stem): the newest turn (best effort). +/// - Spawn time precedes every turn start: the first turn. +fn anchor_request_id(child_unix: Option, segments: &[(String, i64)]) -> Option { + if segments.is_empty() { + return None; + } + let Some(child_unix) = child_unix else { + return segments.last().map(|(rid, _)| rid.clone()); + }; + let mut chosen = &segments[0]; + for seg in segments { + if seg.1 <= child_unix { + chosen = seg; + } + } + Some(chosen.0.clone()) +} + +/// Project one file's display records into display items, in file order. +/// +/// - System lines are dropped (they carry the tool-policy preamble and other +/// scaffolding that must never render as a chat item). +/// - `reasoning_content` on an assistant line becomes a [`DisplayItem::Reasoning`] +/// preceding its message. +/// - Assistant `tool_calls` register pending [`DisplayItem::ToolCall`]s; a later +/// `role:"tool"` line pairs to one by id, falling back to FIFO order. +/// - Interrupted partials and compaction markers pass through as their items. +/// - A [`DisplayItem::TurnBoundary`] is emitted whenever `request_id` changes. +pub fn project_records(records: &[DisplayRecord]) -> Vec { + let mut items: Vec = Vec::new(); + // Pending tool calls awaiting a result line: (call_id, index into `items`). + let mut pending: VecDeque<(String, usize)> = VecDeque::new(); + let mut last_request_id: Option = None; + + for record in records { + match record { + DisplayRecord::Message(msg) => { + maybe_emit_turn_boundary(msg, &mut last_request_id, &mut items); + project_message(msg, &mut items, &mut pending); + } + DisplayRecord::Compaction(marker) => { + project_compaction(marker, &mut items); + // A compaction supersedes prior context; drop stale pending + // pairings so a post-compaction result never binds to them. + pending.clear(); + } + } + } + items +} + +fn maybe_emit_turn_boundary( + msg: &DisplayMessage, + last_request_id: &mut Option, + items: &mut Vec, +) { + let Some(rid) = msg.request_id.as_deref() else { + return; + }; + if last_request_id.as_deref() != Some(rid) { + items.push(DisplayItem::TurnBoundary { + request_id: rid.to_string(), + }); + *last_request_id = Some(rid.to_string()); + } +} + +fn project_message( + msg: &DisplayMessage, + items: &mut Vec, + pending: &mut VecDeque<(String, usize)>, +) { + // Interrupted partial: display-only, carries its own thinking. + if msg.interrupted { + items.push(DisplayItem::InterruptedPartial { + text: msg.message.content.clone(), + thinking: msg.reasoning_content.clone(), + }); + return; + } + + match msg.message.role.as_str() { + "system" => { + // Scaffolding (tool-policy preamble, etc.) — never a display item. + log::debug!("{LOG_PREFIX} sanitize: dropped system line from projection"); + } + "user" => { + let raw = msg.message.content.clone(); + let sanitized = sanitize_user_content(&raw); + if sanitized.is_some() { + log::debug!("{LOG_PREFIX} sanitize: stripped injected prefix from user message"); + } + items.push(DisplayItem::UserMessage { + content: raw, + display_content: sanitized, + request_id: msg.request_id.clone(), + }); + } + "assistant" => project_assistant(msg, items, pending), + "tool" => project_tool_result(msg, items, pending), + other => { + log::debug!("{LOG_PREFIX} projecting unknown role {other:?} as assistant message"); + items.push(DisplayItem::AssistantMessage { + content: msg.message.content.clone(), + interim: false, + request_id: msg.request_id.clone(), + model: msg.turn_usage.as_ref().map(|tu| tu.model.clone()), + iteration: msg.iteration, + }); + } + } +} + +fn project_assistant( + msg: &DisplayMessage, + items: &mut Vec, + pending: &mut VecDeque<(String, usize)>, +) { + // Reasoning precedes the message it belongs to. + if let Some(reasoning) = msg.reasoning_content.as_deref() { + if !reasoning.trim().is_empty() { + items.push(DisplayItem::Reasoning { + text: reasoning.to_string(), + }); + } + } + + let tool_calls = msg + .turn_usage + .as_ref() + .map(|tu| tu.tool_calls.as_slice()) + .unwrap_or_default(); + let interim = !tool_calls.is_empty(); + + // The assistant's prose (if any) shows before its tool calls. + if !msg.message.content.trim().is_empty() { + items.push(DisplayItem::AssistantMessage { + content: msg.message.content.clone(), + interim, + request_id: msg.request_id.clone(), + model: msg.turn_usage.as_ref().map(|tu| tu.model.clone()), + iteration: msg.iteration, + }); + } + + for call in tool_calls { + let args = parse_tool_args(&call.arguments); + items.push(DisplayItem::ToolCall { + call_id: call.id.clone(), + name: call.name.clone(), + args, + result: None, + status: ToolCallStatus::Running, + failure: None, + }); + pending.push_back((call.id.clone(), items.len() - 1)); + } +} + +fn project_tool_result( + msg: &DisplayMessage, + items: &mut Vec, + pending: &mut VecDeque<(String, usize)>, +) { + let result = msg.message.content.clone(); + // A failed tool line (`ToolResult::is_error`, stamped at persistence) pairs + // to an error row with a failure payload instead of a false success. + let (status, failure) = if msg.failure { + ( + ToolCallStatus::Error, + Some(ToolCallFailure { + detail: msg.failure_detail.clone(), + }), + ) + } else { + (ToolCallStatus::Success, None) + }; + // Pair by explicit call id first, else FIFO. + let idx = msg + .message + .id + .as_deref() + .and_then(|id| take_pending_by_id(pending, id)) + .or_else(|| pending.pop_front().map(|(_, idx)| idx)); + + if let Some(idx) = idx { + if let Some(DisplayItem::ToolCall { + result: slot, + status: status_slot, + failure: failure_slot, + .. + }) = items.get_mut(idx) + { + *slot = Some(result); + *status_slot = status; + *failure_slot = failure; + return; + } + } + + // Orphan result (no matching assistant tool_call recorded) — surface it as + // a best-effort completed tool row so the output is not lost. + log::debug!("{LOG_PREFIX} tool result with no pending call — emitting orphan tool row"); + items.push(DisplayItem::ToolCall { + call_id: msg.message.id.clone().unwrap_or_default(), + name: "tool".to_string(), + args: None, + result: Some(result), + status, + failure, + }); +} + +/// Remove and return the pending entry whose call id matches `id`, if any. +fn take_pending_by_id(pending: &mut VecDeque<(String, usize)>, id: &str) -> Option { + let pos = pending.iter().position(|(cid, _)| cid == id)?; + pending.remove(pos).map(|(_, idx)| idx) +} + +fn project_compaction(marker: &CompactionMarker, items: &mut Vec) { + items.push(DisplayItem::Compaction { + replaced_count: 0, + kept_count: marker.replacement.len(), + ts: marker.ts.clone(), + request_id: marker.request_id.clone(), + }); +} + +/// Parse a tool call's raw argument string into JSON when possible; a +/// non-JSON string is wrapped so the frontend still receives structured args. +fn parse_tool_args(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + match serde_json::from_str::(trimmed) { + Ok(value) => Some(value), + Err(_) => Some(serde_json::Value::String(trimmed.to_string())), + } +} + +/// Strip the injected scaffolding prefix from a user message, returning the +/// sanitized body only when a prefix was actually present (so the caller can +/// tag rather than mutate — the raw `content` is preserved alongside). +fn sanitize_user_content(content: &str) -> Option { + let trimmed_start = content.trim_start(); + if trimmed_start.starts_with(DATETIME_PREFIX) + || trimmed_start.starts_with(CHANNEL_CONTEXT_PREFIX) + { + // The injector prepends the scaffolding line followed by a blank line, + // then the user's actual text. Strip the first paragraph. + if let Some(idx) = content.find("\n\n") { + let body = content[idx + 2..].to_string(); + return Some(body); + } + // No body after the prefix — the whole message was scaffolding. + return Some(String::new()); + } + None +} diff --git a/src/openhuman/threads/transcript_view/tests.rs b/src/openhuman/threads/transcript_view/tests.rs new file mode 100644 index 0000000000..4eed3a126d --- /dev/null +++ b/src/openhuman/threads/transcript_view/tests.rs @@ -0,0 +1,435 @@ +//! Projection + pagination + sanitization tests for the transcript view. + +use super::project::{project_records, project_thread}; +use super::types::{DisplayItem, ToolCallStatus}; +use super::{get_page, DEFAULT_LIMIT}; +use crate::openhuman::agent::harness::session::transcript::{self, read_transcript_display}; +use crate::openhuman::inference::provider::ChatMessage; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +fn meta_line(thread_id: &str) -> String { + format!( + r#"{{"_meta":{{"version":1,"agent":"orchestrator","dispatcher":"native","created":"2026-07-21T00:00:00Z","updated":"2026-07-21T00:00:10Z","turn_count":1,"input_tokens":30,"output_tokens":13,"cached_input_tokens":0,"charged_amount_usd":0.003,"thread_id":"{thread_id}"}}}}"# + ) +} + +/// Write a raw JSONL transcript (meta header + given body lines) into +/// `session_raw/{stem}.jsonl` and return the path. +fn write_raw(workspace: &Path, stem: &str, thread_id: &str, body: &[&str]) -> PathBuf { + let path = transcript::resolve_keyed_transcript_path(workspace, stem).expect("resolve"); + let mut buf = meta_line(thread_id); + buf.push('\n'); + for line in body { + buf.push_str(line); + buf.push('\n'); + } + std::fs::write(&path, buf).expect("write raw transcript"); + path +} + +/// A full turn: system scaffolding, a user prompt with the injected datetime +/// prefix, an assistant tool-calling step (reasoning + tool_calls), a tool +/// result, then the final assistant answer. +fn full_turn_body() -> Vec<&'static str> { + vec![ + r#"{"role":"system","content":"[tool-policy preamble] you may use tools ..."}"#, + r#"{"role":"user","content":"Current Date & Time: 2026-07-21 09:00:00 UTC\n\nWhat's the weather in NYC?","request_id":"req-1"}"#, + r#"{"role":"assistant","content":"Let me check.","provider":"anthropic","model":"claude-x","usage":{"input":10,"output":5,"cached_input":0,"cost_usd":0.001},"ts":"2026-07-21T09:00:01Z","reasoning_content":"I should call the weather tool.","tool_calls":[{"id":"call-1","name":"get_weather","arguments":"{\"city\":\"NYC\"}"}],"iteration":1,"request_id":"req-1"}"#, + r#"{"role":"tool","content":"72F and sunny","id":"call-1","request_id":"req-1"}"#, + r#"{"role":"assistant","content":"It's 72F and sunny in NYC.","provider":"anthropic","model":"claude-x","usage":{"input":20,"output":8,"cached_input":0,"cost_usd":0.002},"ts":"2026-07-21T09:00:02Z","iteration":2,"request_id":"req-1"}"#, + ] +} + +#[test] +fn projects_turn_with_tools_reasoning_and_sanitization() { + let dir = TempDir::new().unwrap(); + let path = write_raw(dir.path(), "100_orchestrator", "thr_w", &full_turn_body()); + let display = read_transcript_display(&path).unwrap(); + let items = project_records(&display.records); + + // Expected order: turnBoundary, userMessage, reasoning, assistant(interim), + // toolCall(paired), assistant(final). System line dropped. + assert_eq!(items.len(), 6, "unexpected items: {items:#?}"); + + match &items[0] { + DisplayItem::TurnBoundary { request_id } => assert_eq!(request_id, "req-1"), + other => panic!("expected turnBoundary, got {other:?}"), + } + match &items[1] { + DisplayItem::UserMessage { + content, + display_content, + request_id, + } => { + assert!(content.starts_with("Current Date & Time:"), "raw kept"); + assert_eq!( + display_content.as_deref(), + Some("What's the weather in NYC?"), + "datetime prefix stripped into displayContent" + ); + assert_eq!(request_id.as_deref(), Some("req-1")); + } + other => panic!("expected userMessage, got {other:?}"), + } + match &items[2] { + DisplayItem::Reasoning { text } => assert_eq!(text, "I should call the weather tool."), + other => panic!("expected reasoning, got {other:?}"), + } + match &items[3] { + DisplayItem::AssistantMessage { + content, interim, .. + } => { + assert_eq!(content, "Let me check."); + assert!(*interim, "tool-calling assistant step is interim"); + } + other => panic!("expected interim assistantMessage, got {other:?}"), + } + match &items[4] { + DisplayItem::ToolCall { + call_id, + name, + args, + result, + status, + failure, + } => { + assert_eq!(call_id, "call-1"); + assert_eq!(name, "get_weather"); + assert_eq!( + args.as_ref() + .and_then(|v| v.get("city")) + .and_then(|v| v.as_str()), + Some("NYC") + ); + assert_eq!(result.as_deref(), Some("72F and sunny"), "paired by id"); + assert_eq!(*status, ToolCallStatus::Success); + assert!(failure.is_none(), "successful tool carries no failure"); + } + other => panic!("expected toolCall, got {other:?}"), + } + match &items[5] { + DisplayItem::AssistantMessage { + content, interim, .. + } => { + assert_eq!(content, "It's 72F and sunny in NYC."); + assert!(!*interim, "final answer is not interim"); + } + other => panic!("expected final assistantMessage, got {other:?}"), + } +} + +#[test] +fn projects_compaction_and_interrupted_partial() { + let dir = TempDir::new().unwrap(); + let body = vec![ + r#"{"role":"user","content":"hi","request_id":"req-1"}"#, + r#"{"kind":"compaction","replacement":[{"role":"user","content":"summary so far"}],"ts":"2026-07-21T09:05:00Z","request_id":"req-2"}"#, + r#"{"role":"assistant","content":"partial ans","interrupted":true,"reasoning_content":"mid-thought","iteration":3,"request_id":"req-2"}"#, + ]; + let path = write_raw(dir.path(), "200_orchestrator", "thr_c", &body); + let display = read_transcript_display(&path).unwrap(); + let items = project_records(&display.records); + + let has_compaction = items.iter().any(|i| { + matches!( + i, + DisplayItem::Compaction { kept_count, .. } if *kept_count == 1 + ) + }); + assert!(has_compaction, "compaction projected: {items:#?}"); + + let partial = items + .iter() + .find_map(|i| match i { + DisplayItem::InterruptedPartial { text, thinking } => Some((text, thinking)), + _ => None, + }) + .expect("interrupted partial projected"); + assert_eq!(partial.0, "partial ans"); + assert_eq!(partial.1.as_deref(), Some("mid-thought")); +} + +#[test] +fn legacy_file_without_version_or_request_id_projects() { + let dir = TempDir::new().unwrap(); + // Legacy meta: no `version`, messages carry no `request_id`. + let path = transcript::resolve_keyed_transcript_path(dir.path(), "300_orchestrator").unwrap(); + let raw = concat!( + r#"{"_meta":{"agent":"orchestrator","dispatcher":"native","created":"2026-01-01T00:00:00Z","updated":"2026-01-01T00:00:00Z","turn_count":1,"input_tokens":0,"output_tokens":0,"cached_input_tokens":0,"charged_amount_usd":0.0,"thread_id":"thr_legacy"}}"#, + "\n", + r#"{"role":"user","content":"plain question"}"#, + "\n", + r#"{"role":"assistant","content":"plain answer"}"#, + "\n", + ); + std::fs::write(&path, raw).unwrap(); + let display = read_transcript_display(&path).unwrap(); + let items = project_records(&display.records); + + // No request_id → no turn boundary; user + assistant still project, and an + // un-prefixed user message keeps no displayContent (nothing to strip). + assert!( + !items + .iter() + .any(|i| matches!(i, DisplayItem::TurnBoundary { .. })), + "legacy lines have no request_id, so no boundary" + ); + match items.first() { + Some(DisplayItem::UserMessage { + content, + display_content, + .. + }) => { + assert_eq!(content, "plain question"); + assert_eq!( + display_content.as_deref(), + None, + "no prefix, no displayContent" + ); + } + other => panic!("expected userMessage first, got {other:?}"), + } + assert!(items.iter().any( + |i| matches!(i, DisplayItem::AssistantMessage { content, .. } if content == "plain answer") + )); +} + +#[test] +fn subagent_file_projects_as_nested_item() { + let dir = TempDir::new().unwrap(); + let root_stem = "400_orchestrator"; + write_raw( + dir.path(), + root_stem, + "thr_s", + &[r#"{"role":"user","content":"delegate please","request_id":"req-1"}"#], + ); + // Sub-agent sibling shares the root stem with a `__` suffix. + write_raw( + dir.path(), + &format!("{root_stem}__100_coder"), + "thr_s", + &[ + r#"{"role":"assistant","content":"sub work done","provider":"anthropic","model":"claude-x","usage":{"input":5,"output":3,"cached_input":0,"cost_usd":0.0},"ts":"2026-07-21T09:10:00Z","iteration":1}"#, + ], + ); + + let projected = project_thread(dir.path(), "thr_s").expect("project thread"); + let subagent = projected + .items + .iter() + .find_map(|i| match i { + DisplayItem::Subagent { id, items, .. } => Some((id, items)), + _ => None, + }) + .expect("subagent item present"); + assert_eq!(subagent.0, "orchestrator"); + assert!(subagent.1.iter().any( + |i| matches!(i, DisplayItem::AssistantMessage { content, .. } if content == "sub work done") + )); +} + +#[test] +fn get_page_paginates_newest_first_with_cursor() { + let dir = TempDir::new().unwrap(); + // Five plain user messages → five top-level items. + let body: Vec = (0..5) + .map(|i| format!(r#"{{"role":"user","content":"msg-{i}"}}"#)) + .collect(); + let body_refs: Vec<&str> = body.iter().map(String::as_str).collect(); + write_raw(dir.path(), "500_orchestrator", "thr_p", &body_refs); + + // First page: newest first, limit 2 → msg-4, msg-3. + let page1 = get_page(dir.path(), "thr_p", None, Some(2)); + assert_eq!(page1.total, 5); + assert!(page1.has_more); + assert_eq!(page1.items.len(), 2); + assert!( + matches!(&page1.items[0], DisplayItem::UserMessage { content, .. } if content == "msg-4") + ); + assert!( + matches!(&page1.items[1], DisplayItem::UserMessage { content, .. } if content == "msg-3") + ); + + let cursor = page1.next_cursor.clone().expect("next cursor"); + let page2 = get_page(dir.path(), "thr_p", Some(&cursor), Some(2)); + assert!( + matches!(&page2.items[0], DisplayItem::UserMessage { content, .. } if content == "msg-2") + ); + + // Walk to the end. + let last = get_page(dir.path(), "thr_p", page2.next_cursor.as_deref(), Some(2)); + assert!(!last.has_more, "final page exhausts the thread"); + assert!(last.next_cursor.is_none()); +} + +#[test] +fn failed_tool_line_projects_error_status_with_failure_payload() { + let dir = TempDir::new().unwrap(); + // Assistant issues a tool call; the paired tool result line carries the + // additive `failure` flag (stamped at persistence from `is_error`). + let body = vec![ + r#"{"role":"assistant","content":"trying","provider":"anthropic","model":"m","usage":{"input":1,"output":1,"cached_input":0,"cost_usd":0.0},"ts":"2026-07-21T09:00:01Z","tool_calls":[{"id":"call-9","name":"shell","arguments":"{\"cmd\":\"boom\"}"}],"iteration":1,"request_id":"req-1"}"#, + r#"{"role":"tool","content":"error: command not found","id":"call-9","request_id":"req-1","failure":true,"failure_detail":"error: command not found"}"#, + ]; + let path = write_raw(dir.path(), "600_orchestrator", "thr_f", &body); + let display = read_transcript_display(&path).unwrap(); + let items = project_records(&display.records); + + let tool = items + .iter() + .find_map(|i| match i { + DisplayItem::ToolCall { + status, failure, .. + } => Some((status, failure)), + _ => None, + }) + .expect("toolCall projected"); + assert_eq!(*tool.0, ToolCallStatus::Error, "failed tool → error status"); + let failure = tool.1.as_ref().expect("failure payload present"); + assert_eq!(failure.detail.as_deref(), Some("error: command not found")); +} + +#[test] +fn tool_failure_metadata_round_trips_write_to_display_line() { + // Full write path: a failed tool ChatMessage stamped with failure metadata + // must serialise the additive `failure` line field and read back as a failed + // display message — proving the harness → transcript → projection seam. + let dir = TempDir::new().unwrap(); + let now = "2026-07-21T09:00:00Z".to_string(); + let meta = transcript::TranscriptMeta { + agent_name: "orchestrator".into(), + agent_id: Some("orchestrator".into()), + agent_type: Some("root".into()), + dispatcher: "native".into(), + provider: Some("anthropic".into()), + model: Some("m".into()), + created: now.clone(), + updated: now, + turn_count: 1, + input_tokens: 0, + output_tokens: 0, + cached_input_tokens: 0, + charged_amount_usd: 0.0, + thread_id: Some("thr_rt".into()), + task_id: None, + }; + + let mut tool_msg = ChatMessage { + id: Some("call-1".into()), + role: "tool".into(), + content: r#"{"tool_call_id":"call-1","content":"boom"}"#.into(), + extra_metadata: None, + }; + transcript::attach_tool_failure_metadata(&mut tool_msg, Some("boom: exit 1")); + + let messages = vec![ + ChatMessage { + id: None, + role: "user".into(), + content: "do it".into(), + extra_metadata: None, + }, + tool_msg, + ]; + let path = transcript::resolve_keyed_transcript_path(dir.path(), "700_orchestrator").unwrap(); + transcript::write_transcript(&path, &messages, &meta, None).unwrap(); + + let display = read_transcript_display(&path).unwrap(); + let failed = display + .records + .iter() + .find_map(|r| match r { + transcript::DisplayRecord::Message(m) if m.message.role == "tool" => Some(m), + _ => None, + }) + .expect("tool display message present"); + assert!( + failed.failure, + "failure flag survived the write/read round trip" + ); + assert_eq!(failed.failure_detail.as_deref(), Some("boom: exit 1")); +} + +#[test] +fn subagent_anchors_to_parent_turn_by_spawn_timestamp() { + let dir = TempDir::new().unwrap(); + let root_stem = "800_orchestrator"; + let thread_id = "thr_anchor"; + + let t1 = chrono::DateTime::from_timestamp(1_000_000, 0) + .unwrap() + .to_rfc3339(); + let t2 = chrono::DateTime::from_timestamp(2_000_000, 0) + .unwrap() + .to_rfc3339(); + + // Two turns: req-1 (assistant ts t1), req-2 (assistant ts t2). + let root_body = vec![ + r#"{"role":"user","content":"one","request_id":"req-1"}"#.to_string(), + format!( + r#"{{"role":"assistant","content":"a1","provider":"anthropic","model":"m","usage":{{"input":1,"output":1,"cached_input":0,"cost_usd":0.0}},"ts":"{t1}","iteration":1,"request_id":"req-1"}}"# + ), + r#"{"role":"user","content":"two","request_id":"req-2"}"#.to_string(), + format!( + r#"{{"role":"assistant","content":"a2","provider":"anthropic","model":"m","usage":{{"input":1,"output":1,"cached_input":0,"cost_usd":0.0}},"ts":"{t2}","iteration":1,"request_id":"req-2"}}"# + ), + ]; + let root_refs: Vec<&str> = root_body.iter().map(String::as_str).collect(); + write_raw(dir.path(), root_stem, thread_id, &root_refs); + + // Sub-agent stems encode the spawn unix timestamp: coder spawned during + // turn 1 (1_000_050), planner during turn 2 (2_000_050). + write_raw( + dir.path(), + &format!("{root_stem}__1000050_coder"), + thread_id, + &[r#"{"role":"assistant","content":"coder work"}"#], + ); + write_raw( + dir.path(), + &format!("{root_stem}__2000050_planner"), + thread_id, + &[r#"{"role":"assistant","content":"planner work"}"#], + ); + + let projected = project_thread(dir.path(), thread_id).expect("project thread"); + // The seeded sub-agent files share the `orchestrator` meta agent name, so + // key the anchoring by each sub-agent's inner work content instead of `id`. + let mut anchors: Vec<(String, Option)> = projected + .items + .iter() + .filter_map(|i| match i { + DisplayItem::Subagent { + request_id, items, .. + } => { + let marker = items.iter().find_map(|inner| match inner { + DisplayItem::AssistantMessage { content, .. } => Some(content.clone()), + _ => None, + })?; + Some((marker, request_id.clone())) + } + _ => None, + }) + .collect(); + anchors.sort(); + + assert_eq!( + anchors, + vec![ + ("coder work".to_string(), Some("req-1".to_string())), + ("planner work".to_string(), Some("req-2".to_string())), + ], + "each sub-agent anchors to the turn active at its spawn time" + ); +} + +#[test] +fn get_page_missing_thread_is_empty_not_error() { + let dir = TempDir::new().unwrap(); + let page = get_page(dir.path(), "no_such_thread", None, Some(DEFAULT_LIMIT)); + assert!(!page.has_transcript); + assert_eq!(page.total, 0); + assert!(page.items.is_empty()); +} diff --git a/src/openhuman/threads/transcript_view/types.rs b/src/openhuman/threads/transcript_view/types.rs new file mode 100644 index 0000000000..6c36a09b45 --- /dev/null +++ b/src/openhuman/threads/transcript_view/types.rs @@ -0,0 +1,139 @@ +//! Typed display items for the transcript projection RPC +//! (`threads.transcript_get`). +//! +//! These mirror the frontend's existing chat vocabulary (user/assistant +//! bubbles, reasoning drawer, tool timeline rows, sub-agent activity) so the +//! Phase C renderer can map them onto the same components. Serde is camelCase +//! on the wire — the frontend reads `displayContent`, `callId`, `requestId`, +//! etc. + +use serde::Serialize; + +/// Terminal state of a projected tool call. Mirrors the live timeline's +/// `ToolTimelineStatus` vocabulary (`running` / `success` / `error`) so the +/// settled projection and the live stream render identically. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolCallStatus { + /// Call issued but no result line has been paired yet. + Running, + /// A result line was paired to the call. + Success, + /// A result line the projection identified as a **failure**: the persisted + /// tool line carried the additive `failure` flag (stamped at turn-loop + /// persistence from the tool's `ToolResult::is_error` outcome). Paired with + /// a [`ToolCallFailure`] payload on the item. + Error, +} + +/// Failure payload attached to an errored [`DisplayItem::ToolCall`]. Minimal by +/// design: the persisted transcript only records that the call failed plus an +/// optional short reason. The frontend mapper expands this into its richer +/// `ToolFailureExplanation` shape (`class` / `category` / `causePlain` / +/// `nextAction`) for the `ToolFailureLines` renderer. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolCallFailure { + /// Short, single-line reason for the failure, when the writer captured one. + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +/// One item in a projected transcript, in the frontend's display vocabulary. +/// +/// `#[serde(tag = "kind")]` gives each variant a camelCase discriminator +/// (`userMessage`, `assistantMessage`, …) and every field is camelCase. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum DisplayItem { + /// A user prompt. `content` is the raw persisted content (may carry the + /// injected `Current Date & Time:` scaffolding line); `displayContent` is + /// the sanitized version to show, present only when it differs from raw. + UserMessage { + content: String, + #[serde(skip_serializing_if = "Option::is_none")] + display_content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + request_id: Option, + }, + /// An assistant answer. `interim: true` marks a non-terminal tool-calling + /// step within a multi-iteration turn (not the final answer bubble). + AssistantMessage { + content: String, + #[serde(default, skip_serializing_if = "is_false")] + interim: bool, + #[serde(skip_serializing_if = "Option::is_none")] + request_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + iteration: Option, + }, + /// The model's reasoning/thinking that preceded an assistant message. + Reasoning { text: String }, + /// A tool invocation with its paired result, when available. + ToolCall { + call_id: String, + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + args: Option, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + status: ToolCallStatus, + /// Present only when `status` is `Error` — the failure payload the + /// frontend expands for the `ToolFailureLines` renderer. + #[serde(skip_serializing_if = "Option::is_none")] + failure: Option, + }, + /// A delegated sub-agent run, with its own nested projected items. + /// + /// `request_id` anchors the whole sub-agent trail to the parent turn that + /// spawned it. Sub-agent transcripts are sibling files with no explicit + /// back-link to the delegating tool call, so the projection derives this by + /// matching the sub-agent's spawn timestamp (encoded in its file stem) + /// against the parent turns' timestamp ranges (see + /// `project::anchor_request_id`). Absent for legacy/CLI transcripts whose + /// lines carry no `request_id`. + Subagent { + id: String, + #[serde(skip_serializing_if = "Option::is_none")] + request_id: Option, + items: Vec, + }, + /// A turn boundary — emitted when the `request_id` changes between lines. + TurnBoundary { request_id: String }, + /// A partial assistant answer captured when a turn was interrupted. + InterruptedPartial { + text: String, + #[serde(skip_serializing_if = "Option::is_none")] + thinking: Option, + }, + /// A context-compaction marker: the reduced set replaced everything before + /// it. Counts describe what the record superseded/installed. + Compaction { + replaced_count: usize, + kept_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + ts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + request_id: Option, + }, +} + +#[allow(clippy::trivially_copy_pass_by_ref)] +fn is_false(b: &bool) -> bool { + !*b +} + +/// A projected transcript for one thread, before pagination. Chronological +/// (file) order; the RPC layer paginates newest-first. +#[derive(Debug, Clone)] +pub struct ProjectedTranscript { + pub thread_id: String, + /// All top-level display items in chronological order. + pub items: Vec, +} diff --git a/src/openhuman/threads/turn_state/mirror.rs b/src/openhuman/threads/turn_state/mirror.rs index cb0bc0c3b2..aa694adb9c 100644 --- a/src/openhuman/threads/turn_state/mirror.rs +++ b/src/openhuman/threads/turn_state/mirror.rs @@ -558,6 +558,69 @@ impl TurnStateMirror { self.state.active_subagent = None; self.state.updated_at = chrono::Utc::now().to_rfc3339(); self.flush(); + self.persist_interrupted_partial(); + } + + /// Append the partial streamed answer of an interrupted turn to the session + /// transcript so the derived display view (Phase B) can surface it even + /// after the live turn_state snapshot is gone. Display-only: the + /// model-context reader skips `interrupted:true` lines. + /// + /// Guard: the root transcript file must already exist. An interrupted + /// **first** turn has no session file yet (the harness has not persisted a + /// turn), so there is nothing to append to — that case stays recoverable + /// from the turn_state snapshot alone, as today. We log and skip it. + fn persist_interrupted_partial(&self) { + let partial = self.state.streaming_text.trim(); + if partial.is_empty() { + return; + } + let thread_id = self.state.thread_id.trim(); + if thread_id.is_empty() { + return; + } + let workspace_dir = self.store.workspace_dir(); + let Some(path) = + crate::openhuman::agent::harness::session::transcript::find_root_transcript_for_thread( + workspace_dir, + thread_id, + ) + else { + log::debug!( + "{MIRROR_LOG_PREFIX} no root transcript for thread={thread_id} yet — leaving interrupted partial ({} chars) in turn_state snapshot only", + partial.len() + ); + return; + }; + let request_id = if self.state.request_id.is_empty() { + None + } else { + Some(self.state.request_id.as_str()) + }; + let thinking = self.state.thinking.trim(); + let reasoning = if thinking.is_empty() { + None + } else { + Some(thinking) + }; + match crate::openhuman::agent::harness::session::transcript::append_interrupted_partial( + &path, + partial, + request_id, + Some(self.state.iteration), + reasoning, + ) { + Ok(()) => log::debug!( + "{MIRROR_LOG_PREFIX} appended interrupted partial ({} chars, thinking={} chars) for thread={thread_id} request_id={} to {}", + partial.len(), + thinking.len(), + self.state.request_id, + path.display() + ), + Err(err) => log::warn!( + "{MIRROR_LOG_PREFIX} failed to append interrupted partial for thread={thread_id}: {err}" + ), + } } fn flush(&mut self) { diff --git a/src/openhuman/threads/turn_state/mirror_tests.rs b/src/openhuman/threads/turn_state/mirror_tests.rs index 2e46bd6691..c116e99d47 100644 --- a/src/openhuman/threads/turn_state/mirror_tests.rs +++ b/src/openhuman/threads/turn_state/mirror_tests.rs @@ -674,3 +674,138 @@ fn subagent_transcript_persists_interleaved_prose_and_tools() { "no snake_case fields on the wire" ); } + +// ── Interrupted-partial → session transcript wiring (Task 1) ────────── + +use crate::openhuman::agent::harness::session::transcript::{ + self, read_transcript, read_transcript_display, DisplayRecord, TranscriptMeta, +}; +use crate::openhuman::inference::provider::ChatMessage; + +fn seed_root_transcript(workspace: &std::path::Path, thread_id: &str) -> std::path::PathBuf { + let stem = "100_orchestrator".to_string(); + let path = transcript::resolve_keyed_transcript_path(workspace, &stem).expect("resolve path"); + let meta = TranscriptMeta { + agent_name: "orchestrator".into(), + agent_id: None, + agent_type: Some("root".into()), + dispatcher: "native".into(), + provider: None, + model: None, + created: "2026-07-21T00:00:00Z".into(), + updated: "2026-07-21T00:00:00Z".into(), + turn_count: 1, + input_tokens: 0, + output_tokens: 0, + cached_input_tokens: 0, + charged_amount_usd: 0.0, + thread_id: Some(thread_id.to_string()), + task_id: None, + }; + transcript::write_transcript(&path, &[ChatMessage::user("hello there")], &meta, None) + .expect("seed transcript"); + path +} + +/// When a streaming turn is interrupted and a root transcript already exists, +/// `finish()` appends the partial streamed answer (display-only) to the file. +#[test] +fn finish_appends_interrupted_partial_to_existing_transcript() { + let dir = tempdir().expect("tempdir"); + let thread_id = "thr_abc"; + let path = seed_root_transcript(dir.path(), thread_id); + + let store = TurnStateStore::new(dir.path().to_path_buf()); + let mut m = TurnStateMirror::new(store, thread_id, "req-9"); + m.observe(&AgentProgress::IterationStarted { + iteration: 2, + max_iterations: 25, + }); + m.observe(&AgentProgress::ThinkingDelta { + delta: "hmm".into(), + iteration: 2, + }); + m.observe(&AgentProgress::TextDelta { + delta: "half an ".into(), + iteration: 2, + }); + m.observe(&AgentProgress::TextDelta { + delta: "answer".into(), + iteration: 2, + }); + // No TurnCompleted — the bridge exits, marking the turn interrupted. + m.finish(); + + // Model context must NOT carry the partial. + let model = read_transcript(&path).expect("read model context"); + assert!( + !model + .messages + .iter() + .any(|msg| msg.content.contains("half an answer")), + "interrupted partial must be excluded from the model context" + ); + + // Display projection carries the flagged partial with request_id + thinking. + let display = read_transcript_display(&path).expect("read display"); + let partial = display + .records + .iter() + .find_map(|r| match r { + DisplayRecord::Message(msg) if msg.interrupted => Some(msg), + _ => None, + }) + .expect("display must include the interrupted partial"); + assert_eq!(partial.message.content, "half an answer"); + assert_eq!(partial.request_id.as_deref(), Some("req-9")); + assert_eq!(partial.iteration, Some(2)); + assert_eq!(partial.reasoning_content.as_deref(), Some("hmm")); +} + +/// A completed turn never writes an interrupted partial. +#[test] +fn finish_after_completion_writes_no_partial() { + let dir = tempdir().expect("tempdir"); + let thread_id = "thr_done"; + let path = seed_root_transcript(dir.path(), thread_id); + + let store = TurnStateStore::new(dir.path().to_path_buf()); + let mut m = TurnStateMirror::new(store, thread_id, "req-done"); + m.observe(&AgentProgress::TextDelta { + delta: "final answer".into(), + iteration: 1, + }); + m.observe(&AgentProgress::TurnCompleted { iterations: 1 }); + m.finish(); + + let display = read_transcript_display(&path).expect("read display"); + assert!( + !display + .records + .iter() + .any(|r| matches!(r, DisplayRecord::Message(msg) if msg.interrupted)), + "a completed turn must not append an interrupted partial" + ); +} + +/// An interrupted FIRST turn (no root transcript file yet) is a no-op — the +/// partial stays in the turn_state snapshot only, and finish() does not panic. +#[test] +fn finish_first_turn_without_transcript_is_noop() { + let dir = tempdir().expect("tempdir"); + let store = TurnStateStore::new(dir.path().to_path_buf()); + let mut m = TurnStateMirror::new(store, "thr_new", "req-first"); + m.observe(&AgentProgress::TextDelta { + delta: "orphan partial".into(), + iteration: 1, + }); + // Must not panic even though no session_raw transcript exists. + m.finish(); + // The snapshot itself still records the interrupted turn. + let listed = TurnStateStore::new(dir.path().to_path_buf()) + .get("thr_new") + .expect("get") + .expect("snapshot present"); + assert_eq!(listed.lifecycle, TurnLifecycle::Interrupted); + assert_eq!(listed.streaming_text, "orphan partial"); +} diff --git a/src/openhuman/threads/turn_state/store.rs b/src/openhuman/threads/turn_state/store.rs index c88a89006d..af8c4afb05 100644 --- a/src/openhuman/threads/turn_state/store.rs +++ b/src/openhuman/threads/turn_state/store.rs @@ -54,6 +54,13 @@ impl TurnStateStore { Self { workspace_dir } } + /// Workspace root this store persists under. Exposed so the mirror can + /// resolve sibling session transcripts (append the interrupted partial to + /// `session_raw/{root}.jsonl`) without re-plumbing the path. + pub fn workspace_dir(&self) -> &std::path::Path { + &self.workspace_dir + } + /// Atomically write the snapshot for `state.request_id` under /// `state.thread_id`. On a `Completed` write, prune the thread's completed /// turns to the newest [`COMPLETED_RETENTION`]. diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 28f82bf034..a315f54da2 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -15398,3 +15398,221 @@ async fn json_rpc_agent_meetings_generate_summary_rejects_empty_meeting_id() { rpc_join.abort(); } + +/// Seed a raw append-only session transcript at +/// `{workspace}/session_raw/{stem}.jsonl` (meta header + body lines). +fn seed_raw_transcript(workspace: &Path, stem: &str, thread_id: &str, body: &[&str]) { + let raw_dir = workspace.join("session_raw"); + std::fs::create_dir_all(&raw_dir).expect("create session_raw"); + let meta = format!( + r#"{{"_meta":{{"version":1,"agent":"orchestrator","dispatcher":"native","created":"2026-07-21T00:00:00Z","updated":"2026-07-21T00:00:10Z","turn_count":1,"input_tokens":30,"output_tokens":13,"cached_input_tokens":0,"charged_amount_usd":0.003,"thread_id":"{thread_id}"}}}}"# + ); + let mut buf = meta; + buf.push('\n'); + for line in body { + buf.push_str(line); + buf.push('\n'); + } + std::fs::write(raw_dir.join(format!("{stem}.jsonl")), buf).expect("write transcript"); +} + +#[tokio::test] +async fn json_rpc_threads_transcript_get_projects_and_paginates() { + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + // Config resolves the runtime workspace to `OPENHUMAN_WORKSPACE/workspace` + // (see resolve_config_dir_for_workspace), so seed transcripts there. + let workspace = tmp.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + let workspace = workspace.as_path(); + + let _workspace_guard = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", tmp.path()); + let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL"); + let _vite_backend_url_guard = EnvVarGuard::unset("VITE_BACKEND_URL"); + let _api_url_guard = EnvVarGuard::unset("OPENHUMAN_API_URL"); + + let thread_id = "thr_transcript_e2e"; + let root_stem = "1000_orchestrator"; + // A full turn: system scaffolding (dropped), user with injected datetime + // prefix, assistant tool-calling step (reasoning + tool_calls), tool result, + // final assistant answer. + seed_raw_transcript( + workspace, + root_stem, + thread_id, + &[ + r#"{"role":"system","content":"[tool-policy preamble] ..."}"#, + r#"{"role":"user","content":"Current Date & Time: 2026-07-21 09:00:00 UTC\n\nWeather in NYC?","request_id":"req-1"}"#, + r#"{"role":"assistant","content":"Checking.","provider":"anthropic","model":"claude-x","usage":{"input":10,"output":5,"cached_input":0,"cost_usd":0.001},"ts":"2026-07-21T09:00:01Z","reasoning_content":"call the tool","tool_calls":[{"id":"call-1","name":"get_weather","arguments":"{\"city\":\"NYC\"}"},{"id":"call-2","name":"get_traffic","arguments":"{\"city\":\"NYC\"}"}],"iteration":1,"request_id":"req-1"}"#, + r#"{"role":"tool","content":"72F sunny","id":"call-1","request_id":"req-1"}"#, + r#"{"role":"tool","content":"error: traffic service unavailable","id":"call-2","request_id":"req-1","failure":true,"failure_detail":"traffic service unavailable"}"#, + r#"{"role":"assistant","content":"72F and sunny.","provider":"anthropic","model":"claude-x","usage":{"input":20,"output":8,"cached_input":0,"cost_usd":0.002},"ts":"2026-07-21T09:00:02Z","iteration":2,"request_id":"req-1"}"#, + ], + ); + // A sub-agent sibling sharing the root stem. + seed_raw_transcript( + workspace, + &format!("{root_stem}__50_coder"), + thread_id, + &[ + r#"{"role":"assistant","content":"sub work done","provider":"anthropic","model":"claude-x","usage":{"input":5,"output":3,"cached_input":0,"cost_usd":0.0},"ts":"2026-07-21T09:10:00Z","iteration":1}"#, + ], + ); + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let rpc_base = format!("http://{rpc_addr}"); + + // Full projection (default limit). + let full = post_json_rpc( + &rpc_base, + 41_001, + "openhuman.threads_transcript_get", + json!({ "thread_id": thread_id }), + ) + .await; + let full_result = assert_no_jsonrpc_error(&full, "threads_transcript_get"); + let data = full_result.get("data").expect("data envelope"); + assert_eq!( + data.get("threadId").and_then(Value::as_str), + Some(thread_id) + ); + assert_eq!( + data.get("hasTranscript").and_then(Value::as_bool), + Some(true) + ); + + let items = data + .get("items") + .and_then(Value::as_array) + .expect("items array"); + // 7 top-level from the root turn (turnBoundary, userMessage, reasoning, + // interim assistant, 2 toolCalls, final assistant) + 1 subagent = 8. + assert_eq!( + data.get("total").and_then(Value::as_u64), + Some(8), + "items: {items:#?}" + ); + + let kinds: Vec<&str> = items + .iter() + .filter_map(|i| i.get("kind").and_then(Value::as_str)) + .collect(); + assert!(kinds.contains(&"turnBoundary")); + assert!(kinds.contains(&"userMessage")); + assert!(kinds.contains(&"reasoning")); + assert!(kinds.contains(&"toolCall")); + assert!(kinds.contains(&"subagent")); + + // Sanitization: the user message keeps raw content but exposes a stripped + // displayContent. + let user = items + .iter() + .find(|i| i.get("kind").and_then(Value::as_str) == Some("userMessage")) + .expect("userMessage present"); + assert_eq!( + user.get("displayContent").and_then(Value::as_str), + Some("Weather in NYC?") + ); + + // Tool call paired with its result. + let tool = items + .iter() + .find(|i| i.get("callId").and_then(Value::as_str) == Some("call-1")) + .expect("toolCall call-1 present"); + assert_eq!( + tool.get("result").and_then(Value::as_str), + Some("72F sunny") + ); + assert_eq!(tool.get("status").and_then(Value::as_str), Some("success")); + assert!( + tool.get("failure").is_none(), + "successful tool has no failure" + ); + + // A failed tool result projects an error status + failure payload rather + // than a false success (Gap 1). + let failed_tool = items + .iter() + .find(|i| i.get("callId").and_then(Value::as_str) == Some("call-2")) + .expect("toolCall call-2 present"); + assert_eq!( + failed_tool.get("status").and_then(Value::as_str), + Some("error") + ); + assert_eq!( + failed_tool + .get("failure") + .and_then(|f| f.get("detail")) + .and_then(Value::as_str), + Some("traffic service unavailable") + ); + + // Pagination: newest-first, limit 2 → 2 items + a cursor. + let page1 = post_json_rpc( + &rpc_base, + 41_002, + "openhuman.threads_transcript_get", + json!({ "thread_id": thread_id, "limit": 2 }), + ) + .await; + let page1_data = assert_no_jsonrpc_error(&page1, "transcript_get page1") + .get("data") + .cloned() + .expect("data"); + assert_eq!( + page1_data + .get("items") + .and_then(Value::as_array) + .map(Vec::len), + Some(2) + ); + assert_eq!( + page1_data.get("hasMore").and_then(Value::as_bool), + Some(true) + ); + let cursor = page1_data + .get("nextCursor") + .and_then(Value::as_str) + .expect("nextCursor present") + .to_string(); + + let page2 = post_json_rpc( + &rpc_base, + 41_003, + "openhuman.threads_transcript_get", + json!({ "thread_id": thread_id, "limit": 2, "cursor": cursor }), + ) + .await; + let page2_data = assert_no_jsonrpc_error(&page2, "transcript_get page2") + .get("data") + .cloned() + .expect("data"); + assert_eq!( + page2_data + .get("items") + .and_then(Value::as_array) + .map(Vec::len), + Some(2), + "second page returns the next two items" + ); + + // Missing thread → empty page, not an error. + let missing = post_json_rpc( + &rpc_base, + 41_004, + "openhuman.threads_transcript_get", + json!({ "thread_id": "no_such_thread" }), + ) + .await; + let missing_data = assert_no_jsonrpc_error(&missing, "transcript_get missing") + .get("data") + .cloned() + .expect("data"); + assert_eq!( + missing_data.get("hasTranscript").and_then(Value::as_bool), + Some(false) + ); + assert_eq!(missing_data.get("total").and_then(Value::as_u64), Some(0)); + + rpc_join.abort(); +} From c7f67b7910e514dfa6aa05e3bcfc38462ad6c819 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:18:14 +0300 Subject: [PATCH 10/56] feat(prompts): load AGENTS.md project instructions into the system prompt (#5087) --- .../developing/architecture/agent-harness.md | 11 + src/openhuman/about_app/catalog_data.rs | 31 ++ src/openhuman/agent/debug/mod.rs | 2 + .../agent/harness/session/turn/context.rs | 18 ++ .../harness/subagent_runner/ops/runner.rs | 39 ++- src/openhuman/agent/prompts/agents_md.rs | 250 +++++++++++++++++ src/openhuman/agent/prompts/builder.rs | 29 +- src/openhuman/agent/prompts/mod.rs | 13 +- src/openhuman/agent/prompts/mod_tests.rs | 265 ++++++++++++++++++ src/openhuman/agent/prompts/render_helpers.rs | 70 +++++ src/openhuman/agent/prompts/sections.rs | 29 ++ src/openhuman/agent/prompts/types.rs | 11 + src/openhuman/agent/triage/evaluator.rs | 2 + .../agent_registry/agents/archivist/prompt.rs | 2 + .../agents/code_executor/prompt.rs | 2 + .../agents/context_scout/prompt.rs | 2 + .../agent_registry/agents/critic/prompt.rs | 2 + .../agents/crypto_agent/prompt.rs | 2 + .../agents/desktop_control_agent/prompt.rs | 2 + .../agents/goals_agent/prompt.rs | 2 + .../agent_registry/agents/help/prompt.rs | 2 + .../agents/image_agent/prompt.rs | 2 + .../agents/integrations_agent/prompt.rs | 2 + src/openhuman/agent_registry/agents/loader.rs | 2 + .../agents/markets_agent/prompt.rs | 2 + .../agent_registry/agents/mcp_agent/prompt.rs | 2 + .../agent_registry/agents/mcp_setup/prompt.rs | 2 + .../agents/morning_briefing/prompt.rs | 2 + .../agents/orchestrator/prompt.rs | 2 + .../agent_registry/agents/planner/prompt.rs | 2 + .../agents/presentation_agent/prompt.rs | 2 + .../agents/researcher/prompt.rs | 2 + .../agents/scheduler_agent/prompt.rs | 2 + .../agents/skill_creator/prompt.rs | 2 + .../agents/summarizer/prompt.rs | 2 + .../agents/task_manager_agent/prompt.rs | 2 + .../agents/tool_maker/prompt.rs | 2 + .../agents/tools_agent/prompt.rs | 2 + .../agents/trigger_reactor/prompt.rs | 2 + .../agents/trigger_triage/prompt.rs | 2 + .../agents/video_agent/prompt.rs | 2 + .../agents/vision_agent/prompt.rs | 2 + src/openhuman/config/schema/agent.rs | 39 +++ .../flows/agents/flow_discovery/prompt.rs | 2 + .../flows/agents/workflow_builder/prompt.rs | 2 + src/openhuman/learning/prompt_sections.rs | 2 + src/openhuman/memory_tools/prompt.rs | 2 + src/openhuman/profiles/prompt_section.rs | 2 + .../tinyagents/payload_summarizer.rs | 12 + src/openhuman/tinyplace/agent/prompt.rs | 2 + 50 files changed, 883 insertions(+), 8 deletions(-) create mode 100644 src/openhuman/agent/prompts/agents_md.rs diff --git a/gitbooks/developing/architecture/agent-harness.md b/gitbooks/developing/architecture/agent-harness.md index fa1eb37289..ce3058e9ec 100644 --- a/gitbooks/developing/architecture/agent-harness.md +++ b/gitbooks/developing/architecture/agent-harness.md @@ -123,6 +123,17 @@ A **session** is the live conversation an `Agent` instance is running. The `Agen The system prompt is **not** rebuilt on subsequent turns. Even cosmetic byte changes invalidate the KV-cache prefix and force a full re-prefill, so dynamic per-turn context (memory recall, freshly-learned snippets) is appended as user-visible message content rather than spliced into the system prompt. +### AGENTS.md project instructions + +Alongside identity/soul/profile/memory, the system prompt pulls in **AGENTS.md** instruction files — OpenHuman's analog of Claude Code's `CLAUDE.md` / Codex's `AGENTS.md`. Two layers are loaded **once**, at system-prompt build time (never re-read per turn, so the frozen-prefix / KV-cache contract holds): + +* **Global** — `/AGENTS.md`, the user's OpenHuman workspace (where `SOUL.md` / `USER.md` live). Applies to every run. +* **Project** — `/AGENTS.md`, the folder the agent is operating in. For sub-agent runs with a git-worktree override (`SubagentRunOptions.worktree_action_dir`), that override dir is the project layer instead. + +The global layer renders first, the project layer second (project instructions layered after, taking precedence on conflict), under a `## Project instructions (AGENTS.md)` heading. When the two dirs resolve to the same path the file is loaded **once** (deduped). Missing / unreadable / empty files are silently skipped, and each layer is capped at `BOOTSTRAP_MAX_CHARS` (~20 000 chars) with a `[... truncated]` marker so a large file can't crowd the prompt. + +The loader is `agent::prompts::agents_md` (pure functions returning pre-loaded strings, bounded at read time so a pathological multi-MB file can't exhaust memory before the render-time cap); the strings are threaded onto `PromptContext` (`agents_md_global` / `agents_md_local`) and rendered by `AgentsInstructionsSection`, which sits after the user-files section and before the tool catalogue in the default and sub-agent builders. The **primary / orchestrator** agent and the other built-in dynamic agents (`PromptSource::Dynamic`) assemble their own body via `render_*` helpers, so the same `AgentsInstructionsSection` is injected centrally by `SystemPromptBuilder::from_dynamic` — appended after the agent's own body, before the central grounding contract — rather than by each `agents//prompt.rs` builder. The feature is gated by `agent.agents_md_enabled` (default **on**); when off, no AGENTS.md content is loaded or injected. + ## The tool-call loop Inside `Agent::turn`, the tool-call loop is the inner engine. Since issue #4249 it is the published **tinyagents** crate's `AgentHarness` loop, assembled per turn by `run_turn_via_tinyagents_shared` ([`src/openhuman/tinyagents/mod.rs`](../../../src/openhuman/tinyagents/mod.rs)). It runs up to `max_tool_iterations` rounds (default 10): diff --git a/src/openhuman/about_app/catalog_data.rs b/src/openhuman/about_app/catalog_data.rs index 8e48a33347..cc08c6003a 100644 --- a/src/openhuman/about_app/catalog_data.rs +++ b/src/openhuman/about_app/catalog_data.rs @@ -20,6 +20,18 @@ const CODING_SESSION_TO_BACKEND: Option = Some(CapabilityPriv destinations: &["Configured OpenHuman inference provider"], }); +// AGENTS.md instruction layers are injected verbatim into the agent's system +// prompt, which is sent to whichever inference provider is configured (the +// managed cloud default or a user-selected remote model). The raw file content +// therefore leaves the device whenever a remote provider is active — +// `LOCAL_RAW` (leaves_device: false) under-reported this. Same shape as +// `CODING_SESSION_TO_BACKEND`: raw payload to the configured provider. +const AGENTS_MD_TO_INFERENCE_PROVIDER: Option = Some(CapabilityPrivacy { + leaves_device: true, + data_kind: PrivacyDataKind::Raw, + destinations: &["Configured OpenHuman inference provider"], +}); + // Vision sub-agent ships the attached image (raw pixels) to the managed // multimodal model for analysis. const IMAGE_TO_BACKEND: Option = Some(CapabilityPrivacy { @@ -419,6 +431,25 @@ pub(super) const CAPABILITIES: &[Capability] = &[ status: CapabilityStatus::Beta, privacy: None, }, + Capability { + id: "intelligence.agents_md_instructions", + name: "AGENTS.md Project Instructions", + domain: "intelligence", + category: CapabilityCategory::Intelligence, + description: "Load configurable standing instructions from AGENTS.md files into the agent's \ + system prompt — OpenHuman's analog of Claude Code's CLAUDE.md / Codex's AGENTS.md. Two \ + layers are read once at session start: a global layer from the OpenHuman workspace \ + (/AGENTS.md) and a project layer from the folder the agent is operating \ + in (/AGENTS.md, or a sub-agent's isolated worktree). The global layer is \ + injected first, the project layer second (project instructions take precedence). \ + Missing or empty files are silently skipped, and each layer is capped so a large file \ + can't crowd out the rest of the prompt. On by default; disable via \ + `agent.agents_md_enabled = false`.", + how_to: "Create an AGENTS.md file in your OpenHuman workspace and/or your project's action \ + directory. Toggle off with `agent.agents_md_enabled = false` in config.toml.", + status: CapabilityStatus::Stable, + privacy: AGENTS_MD_TO_INFERENCE_PROVIDER, + }, Capability { id: "intelligence.tool_scoped_memory", name: "Tool-Scoped Memory Rules", diff --git a/src/openhuman/agent/debug/mod.rs b/src/openhuman/agent/debug/mod.rs index 36b3357527..5e354dd90a 100644 --- a/src/openhuman/agent/debug/mod.rs +++ b/src/openhuman/agent/debug/mod.rs @@ -432,6 +432,8 @@ async fn render_integrations_agent(config: &Config, toolkit: &str) -> Result XmlToolDispatcher.prompt_instructions(&empty_tools), } }; + // Load AGENTS.md instruction layers once, at prompt-build time, when the + // config gate is on. The global layer comes from the workspace dir; the + // project layer comes from the sub-agent's `worktree_action_dir` override + // when present (git-worktree isolation), otherwise the global config + // `action_dir`. Loading here (not per turn) keeps the sub-agent system + // prompt byte-stable for prefix caching. + let agents_md = if parent.agent_config.agents_md_enabled { + let local_dir = options + .worktree_action_dir + .clone() + .or_else(|| config_loaded.as_ref().ok().map(|c| c.action_dir.clone())); + match local_dir { + Some(dir) => { + crate::openhuman::agent::prompts::load_agents_md_layers(&parent.workspace_dir, &dir) + } + None => { + // No resolvable project dir — still surface the workspace layer. + crate::openhuman::agent::prompts::AgentsMdContent { + global: crate::openhuman::agent::prompts::load_agents_md(&parent.workspace_dir), + local: None, + } + } + } + } else { + tracing::debug!( + agent_id = %definition.id, + "[agents_md] disabled by config; skipping AGENTS.md injection for subagent" + ); + crate::openhuman::agent::prompts::AgentsMdContent::default() + }; + let prompt_ctx = PromptContext { workspace_dir: &parent.workspace_dir, model_name: &model, @@ -989,6 +1020,8 @@ async fn run_typed_mode( personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: agents_md.global.clone(), + agents_md_local: agents_md.local.clone(), }; let system_prompt = match &definition.system_prompt { @@ -1000,7 +1033,7 @@ async fn run_typed_mode( } PromptSource::Inline(_) | PromptSource::File { .. } => { let archetype_prompt_body = load_prompt_source(&definition.system_prompt, &prompt_ctx)?; - render_subagent_system_prompt( + render_subagent_system_prompt_with_format( &parent.workspace_dir, &model, &allowed_indices, @@ -1010,6 +1043,8 @@ async fn run_typed_mode( render_options, parent.tool_call_format, &narrowed_integrations, + agents_md.global.as_deref(), + agents_md.local.as_deref(), ) } }; diff --git a/src/openhuman/agent/prompts/agents_md.rs b/src/openhuman/agent/prompts/agents_md.rs new file mode 100644 index 0000000000..53d7368376 --- /dev/null +++ b/src/openhuman/agent/prompts/agents_md.rs @@ -0,0 +1,250 @@ +//! AGENTS.md instruction loading — OpenHuman's analog of Claude Code's +//! `CLAUDE.md` / Codex's `AGENTS.md`. +//! +//! Loads configurable instruction text from `AGENTS.md` files at two layers: +//! +//! 1. **Global** — `/AGENTS.md`: the user's OpenHuman workspace +//! (where `SOUL.md` / `USER.md` already live). Applies to every run. +//! 2. **Local / project** — `/AGENTS.md`: the folder the +//! agent is actually operating in. For sub-agent runs with a +//! `worktree_action_dir` override, that override dir is the local layer. +//! +//! The loaded strings are threaded into +//! [`crate::openhuman::context::prompt::PromptContext`] and rendered by +//! `AgentsInstructionsSection` **once at system-prompt build time** — never +//! re-read per turn — so the frozen system-prompt prefix / KV-cache contract is +//! preserved. Missing, unreadable, or empty files are silently skipped. The +//! renderer caps each layer at +//! [`crate::openhuman::context::prompt::BOOTSTRAP_MAX_CHARS`] with a +//! `[... truncated]` marker. + +use super::types::BOOTSTRAP_MAX_CHARS; +use std::io::Read; +use std::path::Path; + +/// The instruction file name loaded at each layer. +pub const AGENTS_MD_FILENAME: &str = "AGENTS.md"; + +/// Hard cap on the number of bytes read from an `AGENTS.md` before the +/// renderer's per-layer character cap ([`BOOTSTRAP_MAX_CHARS`]) applies. +/// +/// `AGENTS.md` is an untrusted, user/project-controlled file. Reading it whole +/// via `read_to_string` would allocate an arbitrarily large buffer — a +/// multi-megabyte file could stall prompt construction or exhaust memory +/// **before** the renderer ever gets a chance to truncate. Bounding the read +/// here fixes that at the root. At UTF-8's worst case of 4 bytes per character +/// this budget still yields at least `BOOTSTRAP_MAX_CHARS` characters (plus +/// slack), so the renderer's cap + `[... truncated]` marker still fire exactly +/// as before for any file large enough to matter — the read bound is invisible +/// to well-formed files and only clamps pathological ones. +const MAX_AGENTS_MD_READ_BYTES: u64 = (BOOTSTRAP_MAX_CHARS as u64) * 4 + 1024; + +/// Pre-loaded `AGENTS.md` contents for the global + local layers. +/// +/// Both fields are `None` when the corresponding file is absent / empty / +/// unreadable, or (for `local`) when it deduplicates against the global layer. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AgentsMdContent { + /// `/AGENTS.md` — the workspace-global layer. + pub global: Option, + /// `/AGENTS.md` — the project layer. `None` when the + /// local dir resolves to the same path as the workspace dir (the file is + /// then loaded once, into [`Self::global`]). + pub local: Option, +} + +impl AgentsMdContent { + /// Whether either layer carries content. + pub fn is_empty(&self) -> bool { + self.global.is_none() && self.local.is_none() + } +} + +/// Load a single `AGENTS.md` from `dir`. +/// +/// Returns `Some(trimmed_content)` when the file exists, is readable, and is +/// non-empty after trimming. Missing / unreadable / empty files yield `None` +/// (silently skipped) — callers inject the result unconditionally without a +/// noisy placeholder. Never logs the file contents, only paths and sizes. +pub fn load_agents_md(dir: &Path) -> Option { + let path = dir.join(AGENTS_MD_FILENAME); + // Bounded read: never slurp an arbitrarily large untrusted file whole into + // memory. We open + `take(MAX_AGENTS_MD_READ_BYTES)` rather than + // `read_to_string`, so a pathological multi-MB AGENTS.md can't stall prompt + // construction or exhaust memory before the renderer's char cap applies. + let file = match std::fs::File::open(&path) { + Ok(f) => f, + Err(e) => { + match e.kind() { + std::io::ErrorKind::NotFound => { + log::debug!("[agents_md] no AGENTS.md at {}", path.display()); + } + _ => { + log::debug!("[agents_md] failed to open {}: {e}", path.display()); + } + } + return None; + } + }; + let mut buf = Vec::new(); + if let Err(e) = file.take(MAX_AGENTS_MD_READ_BYTES).read_to_end(&mut buf) { + log::debug!("[agents_md] failed to read {}: {e}", path.display()); + return None; + } + let hit_read_bound = buf.len() as u64 >= MAX_AGENTS_MD_READ_BYTES; + // `from_utf8_lossy` tolerates a multi-byte char clipped at the read bound by + // substituting the replacement character for the trailing partial bytes. + let content = String::from_utf8_lossy(&buf); + let trimmed = content.trim(); + if trimmed.is_empty() { + log::debug!("[agents_md] skipped empty {}", path.display()); + return None; + } + if hit_read_bound { + log::debug!( + "[agents_md] {} exceeds {} bytes; read bounded before render-time cap", + path.display(), + MAX_AGENTS_MD_READ_BYTES + ); + } + log::debug!( + "[agents_md] loaded {} ({} chars)", + path.display(), + trimmed.chars().count() + ); + Some(trimmed.to_string()) +} + +/// Load the global + local `AGENTS.md` layers given the workspace dir and the +/// effective local (action) dir. +/// +/// Global renders first, local second (project instructions layered after). +/// **Dedupe:** when the two dirs resolve to the same path the file is loaded +/// once, into [`AgentsMdContent::global`], and `local` stays `None` so the same +/// instructions are never injected twice. +pub fn load_agents_md_layers(workspace_dir: &Path, local_dir: &Path) -> AgentsMdContent { + let global = load_agents_md(workspace_dir); + let same_dir = paths_equal(workspace_dir, local_dir); + let local = if same_dir { + log::debug!( + "[agents_md] local dir {} == workspace dir; loaded once (deduped)", + local_dir.display() + ); + None + } else { + load_agents_md(local_dir) + }; + AgentsMdContent { global, local } +} + +/// Compare two directory paths for identity, canonicalizing when possible so +/// `./foo` and `foo` (or symlinked equivalents) dedupe. Falls back to literal +/// equality when canonicalization fails (e.g. a dir that does not exist yet). +fn paths_equal(a: &Path, b: &Path) -> bool { + match (std::fs::canonicalize(a), std::fs::canonicalize(b)) { + (Ok(ca), Ok(cb)) => ca == cb, + _ => a == b, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn tmp() -> std::path::PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let base = std::env::temp_dir().join(format!( + "openhuman-agents-md-{}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + COUNTER.fetch_add(1, Ordering::Relaxed), + )); + fs::create_dir_all(&base).unwrap(); + base + } + + #[test] + fn missing_file_returns_none() { + let dir = tmp(); + assert_eq!(load_agents_md(&dir), None); + } + + #[test] + fn present_file_returns_trimmed_content() { + let dir = tmp(); + fs::write(dir.join(AGENTS_MD_FILENAME), "\n\n hello world \n\n").unwrap(); + assert_eq!(load_agents_md(&dir), Some("hello world".to_string())); + } + + #[test] + fn empty_file_is_skipped() { + let dir = tmp(); + fs::write(dir.join(AGENTS_MD_FILENAME), " \n\t\n ").unwrap(); + assert_eq!(load_agents_md(&dir), None); + } + + #[test] + fn layers_load_both_when_dirs_differ() { + let ws = tmp(); + let local = tmp(); + fs::write(ws.join(AGENTS_MD_FILENAME), "global rules").unwrap(); + fs::write(local.join(AGENTS_MD_FILENAME), "project rules").unwrap(); + let content = load_agents_md_layers(&ws, &local); + assert_eq!(content.global.as_deref(), Some("global rules")); + assert_eq!(content.local.as_deref(), Some("project rules")); + assert!(!content.is_empty()); + } + + #[test] + fn same_dir_dedupes_to_global_only() { + let ws = tmp(); + fs::write(ws.join(AGENTS_MD_FILENAME), "shared rules").unwrap(); + // Pass the same dir as both workspace and local. + let content = load_agents_md_layers(&ws, &ws); + assert_eq!(content.global.as_deref(), Some("shared rules")); + assert_eq!(content.local, None, "same-dir local must dedupe to None"); + } + + #[test] + fn same_dir_dedupes_even_with_non_canonical_paths() { + let ws = tmp(); + fs::write(ws.join(AGENTS_MD_FILENAME), "shared rules").unwrap(); + // A `./` prefixed variant must canonicalize to the same path. + let dotted = ws.join(".").join(""); + let content = load_agents_md_layers(&ws, &dotted); + assert_eq!(content.local, None, "canonicalized same-dir must dedupe"); + } + + #[test] + fn oversized_file_is_bounded_at_read_time() { + let dir = tmp(); + // A file far larger than the read bound must not be slurped whole: + // the loader returns bounded content (never the full body) so a + // pathological AGENTS.md can't exhaust memory before rendering. + let oversized = "a".repeat((MAX_AGENTS_MD_READ_BYTES as usize) * 3); + fs::write(dir.join(AGENTS_MD_FILENAME), &oversized).unwrap(); + let loaded = load_agents_md(&dir).expect("non-empty file loads"); + assert!( + (loaded.len() as u64) <= MAX_AGENTS_MD_READ_BYTES, + "loader must bound the read to MAX_AGENTS_MD_READ_BYTES, got {} bytes", + loaded.len() + ); + assert!( + loaded.len() < oversized.len(), + "bounded content must be shorter than the on-disk file" + ); + } + + #[test] + fn both_missing_is_empty() { + let ws = tmp(); + let local = tmp(); + let content = load_agents_md_layers(&ws, &local); + assert!(content.is_empty()); + } +} diff --git a/src/openhuman/agent/prompts/builder.rs b/src/openhuman/agent/prompts/builder.rs index 3e0bcbfeef..dbaae3d869 100644 --- a/src/openhuman/agent/prompts/builder.rs +++ b/src/openhuman/agent/prompts/builder.rs @@ -39,6 +39,12 @@ impl SystemPromptBuilder { // get their user files (welcome / orchestrator / the // trigger pair). Box::new(UserFilesSection), + // Project instructions (AGENTS.md) sit right after the user + // context and before the tool catalogue — standing, per-project + // guidance the model should read alongside identity/memory. Both + // layers are pre-loaded into `PromptContext` and this section is + // empty (skipped) when neither exists or the gate is off. + Box::new(AgentsInstructionsSection), // User memory sits right after the identity bootstrap so the // model has rich, persistent context about the user before it // sees the tool catalogue. Section is empty (and skipped) when @@ -100,6 +106,10 @@ impl SystemPromptBuilder { // onboarding + archivist context when `omit_profile` / // `omit_memory_md` are opted in. sections.push(Box::new(UserFilesSection)); + // Project instructions (AGENTS.md) — same placement as the default + // chain (after user files, before tools). Empty (skipped) unless the + // caller pre-loaded content onto `PromptContext`. + sections.push(Box::new(AgentsInstructionsSection)); // Tools section is always included — the sub-agent needs to see // its own (filtered) tool catalogue. sections.push(Box::new(ToolsSection)); @@ -146,7 +156,24 @@ impl SystemPromptBuilder { builder: crate::openhuman::agent::harness::definition::PromptBuilder, ) -> Self { Self { - sections: vec![Box::new(DynamicPromptSection::new(builder))], + sections: vec![ + Box::new(DynamicPromptSection::new(builder)), + // Project instructions (AGENTS.md). The ~26 dynamic + // `agents//prompt.rs` builders (orchestrator / main chat, + // welcome, integrations_agent, …) hand-assemble their own body + // via the `render_*` helpers and none of them individually call + // `render_agents_md`, so the pre-loaded AGENTS.md layers on + // `PromptContext` would otherwise be silently dropped for the + // primary agent. Inject the shared section centrally here — + // mirroring how `build()` appends the grounding contract for all + // dynamic builders — so every dynamic agent inherits the same + // AGENTS.md injection as the `with_defaults` / `for_subagent` + // chains. Rendered after the agent's own body (as trailing + // standing guidance) and before the central grounding suffix. + // Empty (skipped) when neither layer carries content or the + // `agents_md_enabled` gate is off. + Box::new(AgentsInstructionsSection), + ], } } diff --git a/src/openhuman/agent/prompts/mod.rs b/src/openhuman/agent/prompts/mod.rs index e8cb626a5f..0b98549e06 100644 --- a/src/openhuman/agent/prompts/mod.rs +++ b/src/openhuman/agent/prompts/mod.rs @@ -3,6 +3,9 @@ pub use types::*; mod connected_identities; pub use connected_identities::render_connected_identities; +pub mod agents_md; +pub use agents_md::{load_agents_md, load_agents_md_layers, AgentsMdContent, AGENTS_MD_FILENAME}; + pub mod builder; pub use builder::{SystemPromptBuilder, GLOBAL_STYLE_SUFFIX}; @@ -13,11 +16,11 @@ pub mod render_helpers; pub use render_helpers::{ current_datetime_line, default_workspace_file_content, inject_inline_content, inject_snapshot_content, inject_workspace_file, inject_workspace_file_capped, - memory_date_label, render_ambient_environment, render_datetime, render_grounding, - render_identity, render_runtime, render_safety, render_subagent_system_prompt, - render_subagent_system_prompt_with_format, render_tools, render_user_files, - render_user_identity, render_user_memory, render_user_reflections, render_workspace, - sync_workspace_file, + memory_date_label, render_agents_md, render_ambient_environment, render_datetime, + render_grounding, render_identity, render_runtime, render_safety, + render_subagent_system_prompt, render_subagent_system_prompt_with_format, render_tools, + render_user_files, render_user_identity, render_user_memory, render_user_reflections, + render_workspace, sync_workspace_file, }; #[cfg(test)] diff --git a/src/openhuman/agent/prompts/mod_tests.rs b/src/openhuman/agent/prompts/mod_tests.rs index f16483c43e..ffeb89bb0c 100644 --- a/src/openhuman/agent/prompts/mod_tests.rs +++ b/src/openhuman/agent/prompts/mod_tests.rs @@ -72,6 +72,8 @@ fn prompt_builder_assembles_sections() { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let rendered = SystemPromptBuilder::with_defaults().build(&ctx).unwrap(); assert!(rendered.contains("## Tools")); @@ -102,6 +104,8 @@ fn grounding_contract_appended_to_every_build_path() { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; // A distinctive clause from GROUNDING_BODY — present regardless of which @@ -194,6 +198,8 @@ fn identity_section_creates_missing_workspace_files() { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let section = IdentitySection; @@ -265,6 +271,8 @@ fn datetime_section_is_static_grounding_rule_without_volatile_timestamp() { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let rendered = DateTimeSection.build(&ctx).unwrap(); @@ -337,6 +345,8 @@ fn datetime_section_appends_resolve_time_rule_only_when_tool_present() { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let rendered_with = DateTimeSection.build(&ctx_with).unwrap(); assert!( @@ -383,6 +393,8 @@ fn ctx_with_identity(identity: Option) -> PromptContext<'static> { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, } } @@ -525,6 +537,8 @@ fn tools_section_pformat_renders_signature_not_schema() { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let rendered = ToolsSection.build(&ctx).unwrap(); @@ -568,6 +582,8 @@ fn tools_section_uses_pformat_signature_for_text_dispatchers() { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let rendered = ToolsSection.build(&ctx).unwrap(); assert!( @@ -618,6 +634,8 @@ fn user_memory_section_renders_namespaces_with_headings() { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let rendered = UserMemorySection.build(&ctx).unwrap(); assert!(rendered.starts_with("## User Memory\n\n")); @@ -697,6 +715,8 @@ fn user_memory_section_returns_empty_when_no_summaries() { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let rendered = UserMemorySection.build(&ctx).unwrap(); assert!(rendered.is_empty()); @@ -788,6 +808,8 @@ fn render_subagent_system_prompt_honors_identity_safety_and_skills_flags() { }, ToolCallFormat::Json, &[], + None, + None, ); assert!(rendered.contains("## Project Context")); @@ -815,6 +837,8 @@ fn render_subagent_system_prompt_honors_identity_safety_and_skills_flags() { SubagentRenderOptions::narrow(), ToolCallFormat::Native, &[], + None, + None, ); assert!(native.contains("native tool-calling output")); assert!(!native.contains("## Safety")); @@ -1445,6 +1469,8 @@ fn for_subagent_builder_injects_user_files_even_when_identity_omitted() { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; // Test a narrow-agent runtime path: @@ -1491,6 +1517,8 @@ fn for_subagent_builder_injects_user_files_even_when_identity_omitted() { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let narrow = builder.build(&ctx_narrow).unwrap(); assert!( @@ -1530,6 +1558,8 @@ fn memory_framing_ctx<'a>( personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, } } @@ -1666,6 +1696,8 @@ fn prompt_tool_constructors_and_user_memory_skip_empty_bodies() { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let rendered = UserMemorySection.build(&ctx).unwrap(); assert!(rendered.contains("### user")); @@ -1694,6 +1726,8 @@ fn ctx_with_learned(learned: LearnedContextData) -> PromptContext<'static> { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, } } @@ -1833,6 +1867,8 @@ fn tools_section_empty_for_native() { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let out = ToolsSection.build(&ctx).unwrap(); assert!( @@ -1866,6 +1902,8 @@ fn tools_section_nonempty_for_pformat() { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let out = ToolsSection.build(&ctx).unwrap(); assert!( @@ -1901,6 +1939,8 @@ fn tools_section_native_with_dispatcher_instructions_returns_instructions() { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let out = ToolsSection.build(&ctx).unwrap(); assert!( @@ -1912,3 +1952,228 @@ fn tools_section_native_with_dispatcher_instructions_returns_instructions() { "Native mode must not include the tool catalogue header, got: {out:?}" ); } + +// ───────────────────────────────────────────────────────────────────────────── +// AGENTS.md project-instructions section +// ───────────────────────────────────────────────────────────────────────────── + +/// Build a minimal `PromptContext` carrying the given AGENTS.md layers. +/// Everything else is inert so tests isolate the AGENTS.md behaviour. +fn agents_md_ctx(global: Option, local: Option) -> PromptContext<'static> { + PromptContext { + workspace_dir: Path::new("/tmp"), + model_name: "test-model", + agent_id: "", + tools: &[], + workflows: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: &NO_FILTER, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + connected_identities_md: String::new(), + include_profile: false, + include_memory_md: false, + curated_snapshot: None, + user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], + agents_md_global: global, + agents_md_local: local, + } +} + +#[test] +fn agents_md_section_empty_when_both_layers_absent() { + let ctx = agents_md_ctx(None, None); + let out = AgentsInstructionsSection.build(&ctx).unwrap(); + assert!( + out.trim().is_empty(), + "section must be empty when no AGENTS.md content is present, got: {out:?}" + ); +} + +#[test] +fn agents_md_section_renders_global_only() { + let ctx = agents_md_ctx(Some("workspace rule one".into()), None); + let out = AgentsInstructionsSection.build(&ctx).unwrap(); + assert!(out.contains("## Project instructions (AGENTS.md)")); + assert!(out.contains("AGENTS.md (workspace)")); + assert!(out.contains("workspace rule one")); + assert!( + !out.contains("AGENTS.md (project)"), + "no project layer should be rendered, got: {out}" + ); +} + +#[test] +fn agents_md_section_renders_local_only() { + let ctx = agents_md_ctx(None, Some("project rule two".into())); + let out = AgentsInstructionsSection.build(&ctx).unwrap(); + assert!(out.contains("## Project instructions (AGENTS.md)")); + assert!(out.contains("AGENTS.md (project)")); + assert!(out.contains("project rule two")); +} + +#[test] +fn agents_md_section_layers_global_before_local() { + let ctx = agents_md_ctx(Some("GLOBAL_MARKER".into()), Some("LOCAL_MARKER".into())); + let out = AgentsInstructionsSection.build(&ctx).unwrap(); + let g = out.find("GLOBAL_MARKER").expect("global present"); + let l = out.find("LOCAL_MARKER").expect("local present"); + assert!( + g < l, + "global layer must render before local layer, got: {out}" + ); + // Both sub-headings present. + assert!(out.contains("AGENTS.md (workspace)")); + assert!(out.contains("AGENTS.md (project)")); +} + +#[test] +fn agents_md_section_truncates_oversized_layer_at_cap() { + // One char over the cap forces truncation with a marker. + let huge = "x".repeat(BOOTSTRAP_MAX_CHARS + 500); + let ctx = agents_md_ctx(Some(huge), None); + let out = AgentsInstructionsSection.build(&ctx).unwrap(); + assert!( + out.contains("truncated"), + "expected a truncation marker, got tail: {}", + &out[out.len().saturating_sub(120)..] + ); + // The rendered block must not carry the full oversized body. + assert!( + out.matches('x').count() <= BOOTSTRAP_MAX_CHARS, + "content must be capped at BOOTSTRAP_MAX_CHARS" + ); +} + +#[test] +fn agents_md_section_registered_in_default_builder() { + let ctx = agents_md_ctx(Some("DEFAULT_BUILDER_MARKER".into()), None); + let rendered = SystemPromptBuilder::with_defaults().build(&ctx).unwrap(); + assert!( + rendered.contains("## Project instructions (AGENTS.md)"), + "with_defaults() must include the AGENTS.md section" + ); + assert!(rendered.contains("DEFAULT_BUILDER_MARKER")); + // Ordering contract: AGENTS.md after user-context, before the tool catalogue. + let agents_pos = rendered + .find("## Project instructions (AGENTS.md)") + .unwrap(); + let tools_pos = rendered.find("## Tools").unwrap(); + assert!( + agents_pos < tools_pos, + "AGENTS.md must render before the ## Tools catalogue" + ); +} + +#[test] +fn agents_md_section_registered_in_dynamic_builder() { + // The primary/orchestrator + welcome + integrations_agent path: + // `PromptSource::Dynamic` agents assemble their own body via `render_*` + // helpers and never call `render_agents_md` individually, so the shared + // AGENTS.md section is injected centrally in `from_dynamic`. Without this + // the main chat agent would load AGENTS.md but silently drop it from the + // system prompt. + fn dynamic_body(_ctx: &PromptContext<'_>) -> anyhow::Result { + Ok("DYNAMIC_AGENT_BODY".to_string()) + } + let ctx = agents_md_ctx(Some("DYNAMIC_GLOBAL_MARKER".into()), None); + let rendered = SystemPromptBuilder::from_dynamic(dynamic_body) + .build(&ctx) + .unwrap(); + assert!( + rendered.contains("DYNAMIC_AGENT_BODY"), + "the dynamic agent body must render" + ); + assert!( + rendered.contains("## Project instructions (AGENTS.md)"), + "from_dynamic() must include the AGENTS.md section for the main/orchestrator agent" + ); + assert!(rendered.contains("DYNAMIC_GLOBAL_MARKER")); + // Ordering contract: the agent's own body renders first, AGENTS.md follows + // as trailing standing guidance (before the central grounding suffix). + let body_pos = rendered.find("DYNAMIC_AGENT_BODY").unwrap(); + let agents_pos = rendered + .find("## Project instructions (AGENTS.md)") + .unwrap(); + assert!( + body_pos < agents_pos, + "AGENTS.md must render after the dynamic agent body" + ); +} + +#[test] +fn agents_md_section_registered_in_subagent_builder() { + let ctx = agents_md_ctx(None, Some("SUBAGENT_BUILDER_MARKER".into())); + let builder = SystemPromptBuilder::for_subagent("role body".into(), true, true, true); + let rendered = builder.build(&ctx).unwrap(); + assert!( + rendered.contains("## Project instructions (AGENTS.md)"), + "for_subagent() must include the AGENTS.md section" + ); + assert!(rendered.contains("SUBAGENT_BUILDER_MARKER")); +} + +#[test] +fn agents_md_section_absent_from_prompt_when_gate_off_yields_none() { + // The config gate produces `None`/`None` (loader not called); the section + // must then contribute nothing to either builder — no heading leak. + let ctx = agents_md_ctx(None, None); + let rendered = SystemPromptBuilder::with_defaults().build(&ctx).unwrap(); + assert!( + !rendered.contains("## Project instructions (AGENTS.md)"), + "gated-off (None/None) must not emit the AGENTS.md heading" + ); +} + +#[test] +fn subagent_renderer_injects_agents_md_before_tools() { + let tools: Vec> = vec![Box::new(TestTool)]; + let rendered = render_subagent_system_prompt_with_format( + Path::new("/tmp"), + "reasoning-v1", + &[0], + &tools, + &[], + "You are a specialist.", + SubagentRenderOptions::narrow(), + ToolCallFormat::PFormat, + &[], + Some("WS_AGENTS_MARKER"), + Some("PROJ_AGENTS_MARKER"), + ); + assert!(rendered.contains("## Project instructions (AGENTS.md)")); + assert!(rendered.contains("WS_AGENTS_MARKER")); + assert!(rendered.contains("PROJ_AGENTS_MARKER")); + let agents_pos = rendered + .find("## Project instructions (AGENTS.md)") + .expect("agents heading present"); + let tools_pos = rendered.find("## Tools").expect("tools heading present"); + assert!( + agents_pos < tools_pos, + "AGENTS.md must render before the tool catalogue in the subagent renderer" + ); +} + +#[test] +fn subagent_renderer_omits_agents_md_when_none() { + let tools: Vec> = vec![Box::new(TestTool)]; + let rendered = render_subagent_system_prompt( + Path::new("/tmp"), + "reasoning-v1", + &[0], + &tools, + &[], + "You are a specialist.", + SubagentRenderOptions::narrow(), + ToolCallFormat::PFormat, + &[], + ); + assert!( + !rendered.contains("## Project instructions (AGENTS.md)"), + "public wrapper passes None/None and must emit no AGENTS.md block" + ); +} diff --git a/src/openhuman/agent/prompts/render_helpers.rs b/src/openhuman/agent/prompts/render_helpers.rs index ce9a18f809..c1a9f98b4a 100644 --- a/src/openhuman/agent/prompts/render_helpers.rs +++ b/src/openhuman/agent/prompts/render_helpers.rs @@ -38,6 +38,14 @@ pub fn render_user_memory(ctx: &PromptContext<'_>) -> Result { UserMemorySection.build(ctx) } +/// Render the `## Project instructions (AGENTS.md)` block from the pre-loaded +/// global + local content on [`PromptContext`]. Empty when neither layer +/// carries content. Dynamic `agents//prompt.rs` builders call this so they +/// inherit the same AGENTS.md injection as the default section chain. +pub fn render_agents_md(ctx: &PromptContext<'_>) -> Result { + AgentsInstructionsSection.build(ctx) +} + /// Render the privileged `## User Reflections` block. Empty when the /// learning subsystem has not captured any reflections yet. pub fn render_user_reflections(ctx: &PromptContext<'_>) -> Result { @@ -242,6 +250,8 @@ pub fn render_subagent_system_prompt( options, tool_call_format, connected_integrations, + None, + None, ) } @@ -249,6 +259,14 @@ pub fn render_subagent_system_prompt( /// that know the active dispatcher format can thread it through. The /// public [`render_subagent_system_prompt`] defaults to PFormat for /// backwards compatibility. +/// +/// `agents_md_global` / `agents_md_local` are the pre-loaded AGENTS.md layers +/// (see [`super::agents_md::load_agents_md_layers`]); `None`/`None` (the value +/// the public wrapper passes) renders no AGENTS.md block. When present they are +/// injected as `## Project instructions (AGENTS.md)` right after the user files +/// and before the tool catalogue — matching the section order of the default / +/// sub-agent builders. +#[allow(clippy::too_many_arguments)] pub fn render_subagent_system_prompt_with_format( workspace_dir: &Path, model_name: &str, @@ -259,6 +277,8 @@ pub fn render_subagent_system_prompt_with_format( options: SubagentRenderOptions, tool_call_format: ToolCallFormat, _connected_integrations: &[ConnectedIntegration], + agents_md_global: Option<&str>, + agents_md_local: Option<&str>, ) -> String { let mut out = String::new(); @@ -316,6 +336,13 @@ pub fn render_subagent_system_prompt_with_format( } } + // 1d. Project instructions (AGENTS.md), pre-loaded by the caller and shared + // with the section-based builders through `write_agents_md_blocks` so + // the byte layout can never drift between the two paths. Placed after + // the user files and before the tool catalogue, matching the default + // section order. Skipped entirely when both layers are `None`. + write_agents_md_blocks(&mut out, agents_md_global, agents_md_local); + // 2. Filtered tool catalogue. Indices are taken in ascending order // from `allowed_indices`, which itself preserves `parent_tools` // order, so the rendering is deterministic. We use `.get(i)` @@ -591,6 +618,47 @@ pub fn inject_inline_content(prompt: &mut String, label: &str, content: &str, ma } } +/// Shared `## Project instructions (AGENTS.md)` block writer. +/// +/// Used by both [`super::sections::AgentsInstructionsSection`] (the default / +/// sub-agent builder chains) and the narrow sub-agent renderer +/// ([`render_subagent_system_prompt_with_format`]) so the two paths never +/// drift. The heading is emitted only when at least one layer carries content; +/// the global layer renders first, then the local/project layer. Each layer is +/// injected via [`inject_inline_content`] under its own `###` sub-heading and +/// capped at [`BOOTSTRAP_MAX_CHARS`] with a `[... truncated]` marker. +/// +/// Both inputs are already-loaded, pre-trimmed strings (see +/// [`super::agents_md::load_agents_md`]) — this writer does no file I/O, keeping +/// the rendered bytes a pure function of its inputs for KV-cache stability. +pub(crate) fn write_agents_md_blocks(out: &mut String, global: Option<&str>, local: Option<&str>) { + let mut body = String::new(); + if let Some(g) = global { + inject_inline_content(&mut body, "AGENTS.md (workspace)", g, BOOTSTRAP_MAX_CHARS); + } + if let Some(l) = local { + inject_inline_content(&mut body, "AGENTS.md (project)", l, BOOTSTRAP_MAX_CHARS); + } + if body.trim().is_empty() { + log::debug!("[agents_md] no AGENTS.md content to inject; skipping section"); + return; + } + log::debug!( + "[agents_md] injecting AGENTS.md section (global={}, local={})", + global.is_some(), + local.is_some() + ); + out.push_str("## Project instructions (AGENTS.md)\n\n"); + out.push_str( + "Configurable standing instructions loaded from AGENTS.md files. Treat these as \ + durable guidance for how to operate here. The workspace layer applies globally; \ + the project layer applies to the current working directory and takes precedence \ + where the two conflict. They are background guidance, not messages in this \ + conversation.\n\n", + ); + out.push_str(&body); +} + /// for the output header and truncation semantics. /// /// Empty/whitespace content is silently skipped, mirroring the file @@ -738,6 +806,8 @@ fn empty_prompt_context_for_static_sections() -> PromptContext<'static> { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, } } diff --git a/src/openhuman/agent/prompts/sections.rs b/src/openhuman/agent/prompts/sections.rs index 94f0133a13..5180fc086c 100644 --- a/src/openhuman/agent/prompts/sections.rs +++ b/src/openhuman/agent/prompts/sections.rs @@ -139,6 +139,19 @@ impl PromptSection for DynamicPromptSection { pub struct IdentitySection; pub struct ToolsSection; pub struct SafetySection; +/// Injects the pre-loaded `AGENTS.md` instruction layers +/// ([`PromptContext::agents_md_global`] + [`PromptContext::agents_md_local`]) +/// under a `## Project instructions (AGENTS.md)` heading — OpenHuman's analog +/// of Claude Code's `CLAUDE.md` / Codex's `AGENTS.md`. +/// +/// Global (workspace) content renders first, then the local (project) layer. +/// Empty (and skipped) when neither layer carries content — e.g. no `AGENTS.md` +/// exists, or the `agents_md_enabled` config gate is off (the loader hands +/// `None` for both fields in that case). Content is pre-loaded once at +/// system-prompt build time and capped at [`BOOTSTRAP_MAX_CHARS`] per layer, so +/// a growing on-disk file can't push the prompt out of the cache-friendly +/// prefix range. +pub struct AgentsInstructionsSection; /// Renders the canonical grounding / anti-hallucination contract /// ([`GROUNDING_BODY`]). Always included; never gated. pub struct GroundingSection; @@ -378,6 +391,22 @@ impl PromptSection for UserFilesSection { } } +impl PromptSection for AgentsInstructionsSection { + fn name(&self) -> &str { + "agents_md" + } + + fn build(&self, ctx: &PromptContext<'_>) -> Result { + let mut out = String::new(); + super::render_helpers::write_agents_md_blocks( + &mut out, + ctx.agents_md_global.as_deref(), + ctx.agents_md_local.as_deref(), + ); + Ok(out) + } +} + impl PromptSection for ToolsSection { fn name(&self) -> &str { "tools" diff --git a/src/openhuman/agent/prompts/types.rs b/src/openhuman/agent/prompts/types.rs index 88c543cdb8..d0a4237da4 100644 --- a/src/openhuman/agent/prompts/types.rs +++ b/src/openhuman/agent/prompts/types.rs @@ -381,6 +381,17 @@ pub struct PromptContext<'a> { /// Non-self personality roster entries for the master agent's prompt. /// Empty for non-master agents. pub personality_roster: Vec, + /// Pre-loaded global `AGENTS.md` content (`/AGENTS.md`), + /// injected by [`crate::openhuman::agent::prompts::sections::AgentsInstructionsSection`]. + /// `None` when the file is absent/empty or the `agents_md_enabled` config + /// gate is off. Loaded once at system-prompt build time (never re-read per + /// turn) so the frozen system-prompt prefix / KV-cache contract holds. + pub agents_md_global: Option, + /// Pre-loaded project-layer `AGENTS.md` content — `/AGENTS.md`, + /// or a sub-agent's `worktree_action_dir` override. `None` when the file is + /// absent/empty, deduplicated against the global layer (same dir), or the + /// gate is off. Rendered after the global layer. + pub agents_md_local: Option, } // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/openhuman/agent/triage/evaluator.rs b/src/openhuman/agent/triage/evaluator.rs index e7a6fbcba2..eed870fdc3 100644 --- a/src/openhuman/agent/triage/evaluator.rs +++ b/src/openhuman/agent/triage/evaluator.rs @@ -749,6 +749,8 @@ fn extract_inline_prompt(def: &AgentDefinition) -> Option { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; match build(&ctx) { Ok(body) if !body.is_empty() => Some(body), diff --git a/src/openhuman/agent_registry/agents/archivist/prompt.rs b/src/openhuman/agent_registry/agents/archivist/prompt.rs index 47a600d0ac..f860dfdc3c 100644 --- a/src/openhuman/agent_registry/agents/archivist/prompt.rs +++ b/src/openhuman/agent_registry/agents/archivist/prompt.rs @@ -66,6 +66,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent_registry/agents/code_executor/prompt.rs b/src/openhuman/agent_registry/agents/code_executor/prompt.rs index 096dadcabd..104639b69c 100644 --- a/src/openhuman/agent_registry/agents/code_executor/prompt.rs +++ b/src/openhuman/agent_registry/agents/code_executor/prompt.rs @@ -70,6 +70,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent_registry/agents/context_scout/prompt.rs b/src/openhuman/agent_registry/agents/context_scout/prompt.rs index 1113f27365..c7d331ca74 100644 --- a/src/openhuman/agent_registry/agents/context_scout/prompt.rs +++ b/src/openhuman/agent_registry/agents/context_scout/prompt.rs @@ -136,6 +136,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, } } diff --git a/src/openhuman/agent_registry/agents/critic/prompt.rs b/src/openhuman/agent_registry/agents/critic/prompt.rs index ef2c5252ed..bd201009ce 100644 --- a/src/openhuman/agent_registry/agents/critic/prompt.rs +++ b/src/openhuman/agent_registry/agents/critic/prompt.rs @@ -65,6 +65,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent_registry/agents/crypto_agent/prompt.rs b/src/openhuman/agent_registry/agents/crypto_agent/prompt.rs index efcf6861e7..0ec295a98f 100644 --- a/src/openhuman/agent_registry/agents/crypto_agent/prompt.rs +++ b/src/openhuman/agent_registry/agents/crypto_agent/prompt.rs @@ -91,6 +91,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, } } diff --git a/src/openhuman/agent_registry/agents/desktop_control_agent/prompt.rs b/src/openhuman/agent_registry/agents/desktop_control_agent/prompt.rs index f5e0e81e72..f7107886aa 100644 --- a/src/openhuman/agent_registry/agents/desktop_control_agent/prompt.rs +++ b/src/openhuman/agent_registry/agents/desktop_control_agent/prompt.rs @@ -61,6 +61,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let body = build(&ctx).unwrap(); assert!(body.contains("Desktop Control Agent")); diff --git a/src/openhuman/agent_registry/agents/goals_agent/prompt.rs b/src/openhuman/agent_registry/agents/goals_agent/prompt.rs index db08fe67f1..869404b199 100644 --- a/src/openhuman/agent_registry/agents/goals_agent/prompt.rs +++ b/src/openhuman/agent_registry/agents/goals_agent/prompt.rs @@ -65,6 +65,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let body = build(&ctx).unwrap(); assert!(body.contains("Goals Curator")); diff --git a/src/openhuman/agent_registry/agents/help/prompt.rs b/src/openhuman/agent_registry/agents/help/prompt.rs index cca53738c6..f871a1af01 100644 --- a/src/openhuman/agent_registry/agents/help/prompt.rs +++ b/src/openhuman/agent_registry/agents/help/prompt.rs @@ -65,6 +65,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent_registry/agents/image_agent/prompt.rs b/src/openhuman/agent_registry/agents/image_agent/prompt.rs index 6a0ea97ad1..9ed134e4b4 100644 --- a/src/openhuman/agent_registry/agents/image_agent/prompt.rs +++ b/src/openhuman/agent_registry/agents/image_agent/prompt.rs @@ -64,6 +64,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let body = build(&ctx).unwrap(); assert!(body.contains("Image-generation specialist")); diff --git a/src/openhuman/agent_registry/agents/integrations_agent/prompt.rs b/src/openhuman/agent_registry/agents/integrations_agent/prompt.rs index 603556e888..db9eed2fda 100644 --- a/src/openhuman/agent_registry/agents/integrations_agent/prompt.rs +++ b/src/openhuman/agent_registry/agents/integrations_agent/prompt.rs @@ -199,6 +199,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, } } diff --git a/src/openhuman/agent_registry/agents/loader.rs b/src/openhuman/agent_registry/agents/loader.rs index a7bf29b682..6d32e71062 100644 --- a/src/openhuman/agent_registry/agents/loader.rs +++ b/src/openhuman/agent_registry/agents/loader.rs @@ -638,6 +638,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let body = build(&ctx) .unwrap_or_else(|e| panic!("{} prompt build failed: {e}", def.id)); diff --git a/src/openhuman/agent_registry/agents/markets_agent/prompt.rs b/src/openhuman/agent_registry/agents/markets_agent/prompt.rs index 794931bfce..dd77ede1d6 100644 --- a/src/openhuman/agent_registry/agents/markets_agent/prompt.rs +++ b/src/openhuman/agent_registry/agents/markets_agent/prompt.rs @@ -93,6 +93,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, } } diff --git a/src/openhuman/agent_registry/agents/mcp_agent/prompt.rs b/src/openhuman/agent_registry/agents/mcp_agent/prompt.rs index 96d199857a..0b950f2efd 100644 --- a/src/openhuman/agent_registry/agents/mcp_agent/prompt.rs +++ b/src/openhuman/agent_registry/agents/mcp_agent/prompt.rs @@ -67,6 +67,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, } } diff --git a/src/openhuman/agent_registry/agents/mcp_setup/prompt.rs b/src/openhuman/agent_registry/agents/mcp_setup/prompt.rs index ddc8c10d53..2b48665bfe 100644 --- a/src/openhuman/agent_registry/agents/mcp_setup/prompt.rs +++ b/src/openhuman/agent_registry/agents/mcp_setup/prompt.rs @@ -66,6 +66,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, } } diff --git a/src/openhuman/agent_registry/agents/morning_briefing/prompt.rs b/src/openhuman/agent_registry/agents/morning_briefing/prompt.rs index 0477094a4f..0795266b4d 100644 --- a/src/openhuman/agent_registry/agents/morning_briefing/prompt.rs +++ b/src/openhuman/agent_registry/agents/morning_briefing/prompt.rs @@ -81,6 +81,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, } } diff --git a/src/openhuman/agent_registry/agents/orchestrator/prompt.rs b/src/openhuman/agent_registry/agents/orchestrator/prompt.rs index 57ff5e8f87..f5591c1161 100644 --- a/src/openhuman/agent_registry/agents/orchestrator/prompt.rs +++ b/src/openhuman/agent_registry/agents/orchestrator/prompt.rs @@ -490,6 +490,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, } } diff --git a/src/openhuman/agent_registry/agents/planner/prompt.rs b/src/openhuman/agent_registry/agents/planner/prompt.rs index 466bb09c7b..e1daa2edf0 100644 --- a/src/openhuman/agent_registry/agents/planner/prompt.rs +++ b/src/openhuman/agent_registry/agents/planner/prompt.rs @@ -72,6 +72,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent_registry/agents/presentation_agent/prompt.rs b/src/openhuman/agent_registry/agents/presentation_agent/prompt.rs index c1d1490099..5a625fc9ad 100644 --- a/src/openhuman/agent_registry/agents/presentation_agent/prompt.rs +++ b/src/openhuman/agent_registry/agents/presentation_agent/prompt.rs @@ -61,6 +61,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let body = build(&ctx).unwrap(); assert!(body.contains("Presentation Agent")); diff --git a/src/openhuman/agent_registry/agents/researcher/prompt.rs b/src/openhuman/agent_registry/agents/researcher/prompt.rs index 538aefbd4a..f378a3acaf 100644 --- a/src/openhuman/agent_registry/agents/researcher/prompt.rs +++ b/src/openhuman/agent_registry/agents/researcher/prompt.rs @@ -58,6 +58,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent_registry/agents/scheduler_agent/prompt.rs b/src/openhuman/agent_registry/agents/scheduler_agent/prompt.rs index 394d6251de..61e125b66e 100644 --- a/src/openhuman/agent_registry/agents/scheduler_agent/prompt.rs +++ b/src/openhuman/agent_registry/agents/scheduler_agent/prompt.rs @@ -61,6 +61,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let body = build(&ctx).unwrap(); assert!(body.contains("Scheduler Agent")); diff --git a/src/openhuman/agent_registry/agents/skill_creator/prompt.rs b/src/openhuman/agent_registry/agents/skill_creator/prompt.rs index b9a3ee231b..7c79f7686e 100644 --- a/src/openhuman/agent_registry/agents/skill_creator/prompt.rs +++ b/src/openhuman/agent_registry/agents/skill_creator/prompt.rs @@ -115,6 +115,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent_registry/agents/summarizer/prompt.rs b/src/openhuman/agent_registry/agents/summarizer/prompt.rs index 5e306eefc3..db970a579b 100644 --- a/src/openhuman/agent_registry/agents/summarizer/prompt.rs +++ b/src/openhuman/agent_registry/agents/summarizer/prompt.rs @@ -66,6 +66,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent_registry/agents/task_manager_agent/prompt.rs b/src/openhuman/agent_registry/agents/task_manager_agent/prompt.rs index 8e2c001c18..aad5e79103 100644 --- a/src/openhuman/agent_registry/agents/task_manager_agent/prompt.rs +++ b/src/openhuman/agent_registry/agents/task_manager_agent/prompt.rs @@ -65,6 +65,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let body = build(&ctx).unwrap(); assert!(body.contains("Task Manager Agent")); diff --git a/src/openhuman/agent_registry/agents/tool_maker/prompt.rs b/src/openhuman/agent_registry/agents/tool_maker/prompt.rs index 08ca3010a6..d23ac164a0 100644 --- a/src/openhuman/agent_registry/agents/tool_maker/prompt.rs +++ b/src/openhuman/agent_registry/agents/tool_maker/prompt.rs @@ -70,6 +70,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent_registry/agents/tools_agent/prompt.rs b/src/openhuman/agent_registry/agents/tools_agent/prompt.rs index 43de1cf829..77708b2327 100644 --- a/src/openhuman/agent_registry/agents/tools_agent/prompt.rs +++ b/src/openhuman/agent_registry/agents/tools_agent/prompt.rs @@ -67,6 +67,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent_registry/agents/trigger_reactor/prompt.rs b/src/openhuman/agent_registry/agents/trigger_reactor/prompt.rs index 25180519e1..75f8774a3c 100644 --- a/src/openhuman/agent_registry/agents/trigger_reactor/prompt.rs +++ b/src/openhuman/agent_registry/agents/trigger_reactor/prompt.rs @@ -66,6 +66,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent_registry/agents/trigger_triage/prompt.rs b/src/openhuman/agent_registry/agents/trigger_triage/prompt.rs index 4437187f6c..d1f9f5c575 100644 --- a/src/openhuman/agent_registry/agents/trigger_triage/prompt.rs +++ b/src/openhuman/agent_registry/agents/trigger_triage/prompt.rs @@ -66,6 +66,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent_registry/agents/video_agent/prompt.rs b/src/openhuman/agent_registry/agents/video_agent/prompt.rs index 38708ec7bc..a36e60c597 100644 --- a/src/openhuman/agent_registry/agents/video_agent/prompt.rs +++ b/src/openhuman/agent_registry/agents/video_agent/prompt.rs @@ -64,6 +64,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let body = build(&ctx).unwrap(); assert!(body.contains("Video-generation specialist")); diff --git a/src/openhuman/agent_registry/agents/vision_agent/prompt.rs b/src/openhuman/agent_registry/agents/vision_agent/prompt.rs index 8389349bd5..f3868d5c30 100644 --- a/src/openhuman/agent_registry/agents/vision_agent/prompt.rs +++ b/src/openhuman/agent_registry/agents/vision_agent/prompt.rs @@ -65,6 +65,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/config/schema/agent.rs b/src/openhuman/config/schema/agent.rs index fc6417d988..17eb831f03 100644 --- a/src/openhuman/config/schema/agent.rs +++ b/src/openhuman/config/schema/agent.rs @@ -359,6 +359,22 @@ pub struct AgentConfig { /// existing agents are unaffected. #[serde(default)] pub required_output: Option, + + /// Whether to load `AGENTS.md` instruction files into the agent's system + /// prompt — OpenHuman's analog of Claude Code's `CLAUDE.md` / Codex's + /// `AGENTS.md`. When `true` (the default), the harness reads + /// `/AGENTS.md` (global) and `/AGENTS.md` + /// (project) once at system-prompt build time and injects them as + /// `## Project instructions (AGENTS.md)`. When `false`, no AGENTS.md content + /// is loaded or injected. Missing/empty files are always a silent no-op, so + /// leaving this on has zero effect until the user actually creates an + /// `AGENTS.md`. + #[serde(default = "default_agents_md_enabled")] + pub agents_md_enabled: bool, +} + +fn default_agents_md_enabled() -> bool { + true } fn default_session_dual_write() -> bool { @@ -501,6 +517,7 @@ impl Default for AgentConfig { session_dual_write: default_session_dual_write(), session_shadow_reads: default_session_shadow_reads(), required_output: None, + agents_md_enabled: default_agents_md_enabled(), } } } @@ -700,4 +717,26 @@ mod memory_window_tests { assert!(!migrated); assert!(cfg.channel_permissions.is_empty()); } + + #[test] + fn agents_md_enabled_defaults_to_true() { + assert!( + AgentConfig::default().agents_md_enabled, + "AGENTS.md loading must be on by default" + ); + } + + #[test] + fn agents_md_enabled_defaults_true_when_field_omitted() { + // A config that predates the field must deserialize with the feature on + // (matches the `#[serde(default = ...)]` contract). + let cfg: AgentConfig = serde_json::from_str("{}").unwrap(); + assert!(cfg.agents_md_enabled); + } + + #[test] + fn agents_md_enabled_roundtrips_when_disabled() { + let cfg: AgentConfig = serde_json::from_str(r#"{"agents_md_enabled": false}"#).unwrap(); + assert!(!cfg.agents_md_enabled); + } } diff --git a/src/openhuman/flows/agents/flow_discovery/prompt.rs b/src/openhuman/flows/agents/flow_discovery/prompt.rs index 579f3cbec5..0320d4aa8e 100644 --- a/src/openhuman/flows/agents/flow_discovery/prompt.rs +++ b/src/openhuman/flows/agents/flow_discovery/prompt.rs @@ -72,6 +72,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, } } diff --git a/src/openhuman/flows/agents/workflow_builder/prompt.rs b/src/openhuman/flows/agents/workflow_builder/prompt.rs index df95d14f54..059993cac2 100644 --- a/src/openhuman/flows/agents/workflow_builder/prompt.rs +++ b/src/openhuman/flows/agents/workflow_builder/prompt.rs @@ -68,6 +68,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, } } diff --git a/src/openhuman/learning/prompt_sections.rs b/src/openhuman/learning/prompt_sections.rs index 75d32476f6..1707a17d1f 100644 --- a/src/openhuman/learning/prompt_sections.rs +++ b/src/openhuman/learning/prompt_sections.rs @@ -309,6 +309,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, curated_snapshot: None, } } diff --git a/src/openhuman/memory_tools/prompt.rs b/src/openhuman/memory_tools/prompt.rs index 0f730b5a11..df1c22b334 100644 --- a/src/openhuman/memory_tools/prompt.rs +++ b/src/openhuman/memory_tools/prompt.rs @@ -99,6 +99,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let built = section.build(&ctx).unwrap(); assert!(built.contains("never email Sarah")); diff --git a/src/openhuman/profiles/prompt_section.rs b/src/openhuman/profiles/prompt_section.rs index 5d530cfa07..5257d4e00e 100644 --- a/src/openhuman/profiles/prompt_section.rs +++ b/src/openhuman/profiles/prompt_section.rs @@ -58,6 +58,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let rendered = section.build(&ctx).expect("render profile section"); assert!(rendered.starts_with("## Agent profile")); diff --git a/src/openhuman/tinyagents/payload_summarizer.rs b/src/openhuman/tinyagents/payload_summarizer.rs index a61f0b7b0f..825c331edb 100644 --- a/src/openhuman/tinyagents/payload_summarizer.rs +++ b/src/openhuman/tinyagents/payload_summarizer.rs @@ -331,6 +331,18 @@ impl SubagentPayloadSummarizer { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + // AGENTS.md layers are intentionally excluded from the payload + // summarizer. This is a narrow internal utility that condenses an + // oversized tool payload into a summary — it does no project work in + // the action directory, so standing project instructions are pure + // noise here and would waste the tight token budget this summary + // path is trying to reclaim. Unlike the user-facing agents it also + // builds its dynamic prompt directly (not via + // `SystemPromptBuilder::from_dynamic`), so it is deliberately outside + // the AGENTS.md injection contract that the main + sub-agent prompt + // paths honour. + agents_md_global: None, + agents_md_local: None, }; let system_prompt = match &self.definition.system_prompt { diff --git a/src/openhuman/tinyplace/agent/prompt.rs b/src/openhuman/tinyplace/agent/prompt.rs index 6ee924b63d..2d866feea8 100644 --- a/src/openhuman/tinyplace/agent/prompt.rs +++ b/src/openhuman/tinyplace/agent/prompt.rs @@ -85,6 +85,8 @@ mod tests { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, } } From 4df2e24da44b8449ff856a81c92671f48b9785fc Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:34:29 +0300 Subject: [PATCH 11/56] fix(agents-md): security hardening dropped from #5087 merge (#5096) --- .../agent/harness/session/turn/core.rs | 12 +- src/openhuman/agent/prompts/agents_md.rs | 143 ++++++++++++++++-- 2 files changed, 141 insertions(+), 14 deletions(-) diff --git a/src/openhuman/agent/harness/session/turn/core.rs b/src/openhuman/agent/harness/session/turn/core.rs index 5582c1e178..f6756db6e3 100644 --- a/src/openhuman/agent/harness/session/turn/core.rs +++ b/src/openhuman/agent/harness/session/turn/core.rs @@ -507,13 +507,21 @@ impl Agent { // instead. Narrow specialists (both flags off) keep the // full-body log so prompt-engineering iteration on // tools/safety sections stays easy. - if self.omit_profile && self.omit_memory_md { + // + // AGENTS.md instruction layers are also user/project-controlled and + // can land in the prompt even when PROFILE/MEMORY are both omitted + // (common for narrow specialists), so treat their presence as a + // redaction trigger too — otherwise the full-body path would print + // raw AGENTS.md contents verbatim. + let contains_agents_md = + rendered_prompt.contains("## Project instructions (AGENTS.md)"); + if self.omit_profile && self.omit_memory_md && !contains_agents_md { log::debug!("[agent_loop] system prompt body:\n{}", rendered_prompt); } else { let mut hasher = std::collections::hash_map::DefaultHasher::new(); rendered_prompt.hash(&mut hasher); log::debug!( - "[agent_loop] system prompt body redacted (contains PROFILE/MEMORY): chars={} hash={:016x}", + "[agent_loop] system prompt body redacted (contains PROFILE/MEMORY/AGENTS.md): chars={} hash={:016x}", rendered_prompt.chars().count(), hasher.finish() ); diff --git a/src/openhuman/agent/prompts/agents_md.rs b/src/openhuman/agent/prompts/agents_md.rs index 53d7368376..9dc8f6dedd 100644 --- a/src/openhuman/agent/prompts/agents_md.rs +++ b/src/openhuman/agent/prompts/agents_md.rs @@ -68,24 +68,62 @@ impl AgentsMdContent { /// noisy placeholder. Never logs the file contents, only paths and sizes. pub fn load_agents_md(dir: &Path) -> Option { let path = dir.join(AGENTS_MD_FILENAME); - // Bounded read: never slurp an arbitrarily large untrusted file whole into - // memory. We open + `take(MAX_AGENTS_MD_READ_BYTES)` rather than - // `read_to_string`, so a pathological multi-MB AGENTS.md can't stall prompt - // construction or exhaust memory before the renderer's char cap applies. - let file = match std::fs::File::open(&path) { + // Path hardening (race-free): the project-layer AGENTS.md lives in the + // agent's user/project-controlled action dir, so a checkout could make it a + // symlink pointing outside the root (a home-directory secret, a device node) + // and have its bytes read into the system prompt and shipped to the + // configured inference provider. We open with `O_NOFOLLOW` (Unix), so the + // kernel atomically refuses to follow a final-component symlink at open + // time — there is no stat-then-open window a racing writer could exploit by + // swapping a checked regular file for a symlink. We then fstat the *opened* + // handle and require a regular file (rejecting a directory / FIFO / socket / + // device substituted in the same race), and — defence in depth against a + // symlinked *parent* component — require the canonical path to stay under + // `dir` (mirroring `validate_path_within_root`, used for agent-definition + // TOML). All rejections skip silently, exactly like a missing file. + let file = match open_no_follow(&path) { Ok(f) => f, Err(e) => { - match e.kind() { - std::io::ErrorKind::NotFound => { - log::debug!("[agents_md] no AGENTS.md at {}", path.display()); - } - _ => { - log::debug!("[agents_md] failed to open {}: {e}", path.display()); - } + if is_symlink_refusal(&e) { + log::warn!( + "[agents_md] refusing to read symlinked {} (path hardening)", + path.display() + ); + } else if e.kind() == std::io::ErrorKind::NotFound { + log::debug!("[agents_md] no AGENTS.md at {}", path.display()); + } else { + log::debug!("[agents_md] failed to open {}: {e}", path.display()); } return None; } }; + // fstat on the opened fd (race-free): reject anything that is not a regular + // file — a directory, FIFO, socket, or device that slipped through. + match file.metadata() { + Ok(m) if m.is_file() => {} + Ok(_) => { + log::debug!( + "[agents_md] {} is not a regular file; skipping", + path.display() + ); + return None; + } + Err(e) => { + log::debug!("[agents_md] failed to stat opened {}: {e}", path.display()); + return None; + } + } + if let Err(e) = crate::openhuman::security::validate_path_within_root(&path, dir) { + log::warn!( + "[agents_md] refusing to read {} outside its root: {e}", + path.display() + ); + return None; + } + // Bounded read: never slurp an arbitrarily large untrusted file whole into + // memory. We `take(MAX_AGENTS_MD_READ_BYTES)` rather than `read_to_string`, + // so a pathological multi-MB AGENTS.md can't stall prompt construction or + // exhaust memory before the renderer's char cap applies. let mut buf = Vec::new(); if let Err(e) = file.take(MAX_AGENTS_MD_READ_BYTES).read_to_end(&mut buf) { log::debug!("[agents_md] failed to read {}: {e}", path.display()); @@ -147,6 +185,51 @@ fn paths_equal(a: &Path, b: &Path) -> bool { } } +/// Open `path` read-only while refusing to follow a final-component symlink. +/// +/// On Unix this passes `O_NOFOLLOW` (the kernel returns `ELOOP`/`EMLINK` instead +/// of opening the symlink target) plus `O_NONBLOCK` (never block on a FIFO / +/// device a racing writer may substitute — it is rejected by the `is_file` +/// fstat check afterwards). This closes the check-to-open race that a +/// stat-then-`File::open` sequence would leave open. See [`load_agents_md`]. +#[cfg(unix)] +fn open_no_follow(path: &Path) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path) +} + +/// Non-Unix fallback: best-effort pre-open symlink check. Windows symlink +/// creation requires elevation / developer mode, so the residual +/// check-to-open race is low risk on these platforms. +#[cfg(not(unix))] +fn open_no_follow(path: &Path) -> std::io::Result { + let meta = std::fs::symlink_metadata(path)?; + if meta.file_type().is_symlink() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "refusing to follow symlink", + )); + } + std::fs::File::open(path) +} + +/// Whether an [`open_no_follow`] error is the "refused a symlink" signal (as +/// opposed to a genuine I/O failure), so the caller can log it distinctly. +#[cfg(unix)] +fn is_symlink_refusal(e: &std::io::Error) -> bool { + // `O_NOFOLLOW` on a symlink yields `ELOOP` on Linux/macOS and `EMLINK` on + // some BSDs — either way the open was refused *because* it was a symlink. + matches!(e.raw_os_error(), Some(v) if v == libc::ELOOP || v == libc::EMLINK) +} + +#[cfg(not(unix))] +fn is_symlink_refusal(e: &std::io::Error) -> bool { + e.kind() == std::io::ErrorKind::InvalidInput +} + #[cfg(test)] mod tests { use super::*; @@ -240,6 +323,42 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn symlinked_agents_md_is_refused() { + // A project-controlled AGENTS.md that symlinks to a secret outside the + // action root must not be read into the prompt (path hardening). + let dir = tmp(); + let secret_dir = tmp(); + let secret = secret_dir.join("secret.txt"); + fs::write(&secret, "TOP SECRET — must not leak").unwrap(); + std::os::unix::fs::symlink(&secret, dir.join(AGENTS_MD_FILENAME)).unwrap(); + assert_eq!( + load_agents_md(&dir), + None, + "symlinked AGENTS.md must be refused" + ); + } + + #[cfg(unix)] + #[test] + fn fifo_agents_md_is_refused_without_hanging() { + use std::ffi::CString; + // A FIFO (or device) is the kind of non-regular file a racing writer + // could substitute after a naive stat check. The opened-fd `is_file` + // fstat must reject it, and `O_NONBLOCK` must keep the open from + // blocking on a FIFO that has no writer. + let dir = tmp(); + let cpath = CString::new(dir.join(AGENTS_MD_FILENAME).to_str().unwrap()).unwrap(); + let rc = unsafe { libc::mkfifo(cpath.as_ptr(), 0o600) }; + assert_eq!(rc, 0, "mkfifo failed: {}", std::io::Error::last_os_error()); + assert_eq!( + load_agents_md(&dir), + None, + "FIFO AGENTS.md must be refused, not read" + ); + } + #[test] fn both_missing_is_empty() { let ws = tmp(); From c09bef22dbaf8d5d5b046c10899c120fdff01de9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 21 Jul 2026 14:52:27 +0000 Subject: [PATCH 12/56] chore(release): v0.62.0 [skip ci] --- Cargo.lock | 2 +- Cargo.toml | 2 +- app/package.json | 2 +- app/src-tauri/Cargo.lock | 4 ++-- app/src-tauri/Cargo.toml | 2 +- app/src-tauri/tauri.conf.json | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 704cdfef68..989f24c3cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4701,7 +4701,7 @@ dependencies = [ [[package]] name = "openhuman" -version = "0.61.7" +version = "0.62.0" dependencies = [ "aes-gcm", "aho-corasick", diff --git a/Cargo.toml b/Cargo.toml index aacb8d574b..c8fbbac0d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openhuman" -version = "0.61.7" +version = "0.62.0" edition = "2021" description = "OpenHuman core business logic and RPC server" autobins = false diff --git a/app/package.json b/app/package.json index 83386f13a7..14ad445157 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "openhuman-app", - "version": "0.61.7", + "version": "0.62.0", "type": "module", "engines": { "node": ">=24.0.0" diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 60b18144fd..f4eefc9f3c 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "OpenHuman" -version = "0.61.7" +version = "0.62.0" dependencies = [ "anyhow", "async-trait", @@ -5603,7 +5603,7 @@ dependencies = [ [[package]] name = "openhuman" -version = "0.61.7" +version = "0.62.0" dependencies = [ "aes-gcm", "aho-corasick", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 2e69451e6d..16fe16764f 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "OpenHuman" -version = "0.61.7" +version = "0.62.0" description = "OpenHuman - AI-powered Super Assistant" authors = ["OpenHuman"] edition = "2021" diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 2660735901..5ef257fa58 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenHuman", - "version": "0.61.7", + "version": "0.62.0", "identifier": "com.openhuman.app", "build": { "beforeDevCommand": "pnpm run dev", From 4efd80db341b9bcf2ef42587f66620205d9e566a Mon Sep 17 00:00:00 2001 From: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:31:31 +0530 Subject: [PATCH 13/56] chore(core): remove zero-reference deps and redirect_links domain (#5052) --- Cargo.lock | 321 +--------------- Cargo.toml | 33 +- app/package.json | 3 - app/src-tauri/Cargo.lock | 324 +---------------- package.json | 1 - pnpm-lock.yaml | 63 ---- scripts/generate-test-inventory.mjs | 1 - src/core/all.rs | 9 - src/core/all_tests.rs | 1 - src/openhuman/mod.rs | 1 - src/openhuman/redirect_links/README.md | 82 ----- src/openhuman/redirect_links/mod.rs | 24 -- src/openhuman/redirect_links/ops.rs | 464 ------------------------ src/openhuman/redirect_links/schemas.rs | 315 ---------------- src/openhuman/redirect_links/store.rs | 337 ----------------- src/openhuman/redirect_links/types.rs | 25 -- src/openhuman/security/policy/types.rs | 4 + 17 files changed, 40 insertions(+), 1968 deletions(-) delete mode 100644 src/openhuman/redirect_links/README.md delete mode 100644 src/openhuman/redirect_links/mod.rs delete mode 100644 src/openhuman/redirect_links/ops.rs delete mode 100644 src/openhuman/redirect_links/schemas.rs delete mode 100644 src/openhuman/redirect_links/store.rs delete mode 100644 src/openhuman/redirect_links/types.rs diff --git a/Cargo.lock b/Cargo.lock index 989f24c3cf..dff2cdac7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -958,15 +958,6 @@ dependencies = [ "strsim", ] -[[package]] -name = "clap_complete" -version = "4.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" -dependencies = [ - "clap", -] - [[package]] name = "clap_derive" version = "4.6.1" @@ -1033,7 +1024,7 @@ dependencies = [ "bs58", "coins-core", "digest 0.10.7", - "hmac 0.12.1", + "hmac", "k256", "serde", "sha2 0.10.9", @@ -1048,7 +1039,7 @@ checksum = "3db8fba409ce3dc04f7d804074039eb68b960b0829161f8e06c95fea3f122528" dependencies = [ "bitvec", "coins-bip32", - "hmac 0.12.1", + "hmac", "once_cell", "pbkdf2 0.12.2", "rand 0.8.6", @@ -1747,19 +1738,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "dialoguer" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" -dependencies = [ - "console", - "fuzzy-matcher", - "shell-words", - "tempfile", - "zeroize", -] - [[package]] name = "digest" version = "0.10.7" @@ -1781,7 +1759,6 @@ dependencies = [ "block-buffer 0.12.0", "const-oid 0.10.2", "crypto-common 0.2.1", - "ctutils", ] [[package]] @@ -2123,7 +2100,7 @@ dependencies = [ "ctr", "digest 0.10.7", "hex", - "hmac 0.12.1", + "hmac", "pbkdf2 0.11.0", "rand 0.8.6", "scrypt", @@ -2274,12 +2251,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fallible-iterator" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" - [[package]] name = "fallible-iterator" version = "0.3.0" @@ -2643,15 +2614,6 @@ dependencies = [ "slab", ] -[[package]] -name = "fuzzy-matcher" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" -dependencies = [ - "thread_local", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -2691,7 +2653,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] @@ -2914,7 +2876,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac 0.12.1", + "hmac", ] [[package]] @@ -2926,15 +2888,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest 0.11.3", -] - [[package]] name = "hostname" version = "0.4.2" @@ -3873,7 +3826,7 @@ dependencies = [ "indexmap", "itoa", "log", - "md-5 0.10.6", + "md-5", "nom 8.0.0", "nom_locate", "rand 0.9.4", @@ -3982,16 +3935,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "md-5" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" -dependencies = [ - "cfg-if", - "digest 0.11.3", -] - [[package]] name = "md5" version = "0.8.0" @@ -4068,7 +4011,7 @@ checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] @@ -4581,15 +4524,6 @@ dependencies = [ "objc2-foundation 0.3.2", ] -[[package]] -name = "objc2-system-configuration" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" -dependencies = [ - "objc2-core-foundation", -] - [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -4708,7 +4642,6 @@ dependencies = [ "anyhow", "arboard", "argon2", - "async-imap", "async-trait", "axum", "base64 0.22.1", @@ -4720,14 +4653,12 @@ dependencies = [ "chrono", "chrono-tz", "clap", - "clap_complete", "coins-bip39", "console", "cpal", "cron", "crossterm", "curve25519-dalek", - "dialoguer", "directories", "dirs 5.0.1", "docx-rs", @@ -4747,7 +4678,7 @@ dependencies = [ "glob", "hex", "hkdf", - "hmac 0.12.1", + "hmac", "hostname", "hound", "iana-time-zone", @@ -4757,30 +4688,22 @@ dependencies = [ "lettre", "libc", "log", - "mail-parser", "motosan-ai-oauth", "nu-ansi-term 0.46.0", "objc2 0.6.4", "objc2-contacts", "objc2-foundation 0.3.2", "once_cell", - "opentelemetry", - "opentelemetry-otlp", - "opentelemetry_sdk", "parking_lot", "pdf-extract", - "postgres", "ppt-rs", - "prometheus", "proptest", - "prost", "rand 0.10.1", "ratatui", "rdev", "regex", "reqwest 0.12.28", "ring", - "ripemd", "rppal", "rusqlite", "rustls", @@ -4793,7 +4716,6 @@ dependencies = [ "serde_yaml", "sha1", "sha2 0.10.9", - "shellexpand", "similar", "socketioxide", "starship-battery", @@ -4818,13 +4740,7 @@ dependencies = [ "tracing-appender", "tracing-log", "tracing-subscriber", - "tree-sitter", - "tree-sitter-python", - "tree-sitter-rust", - "tree-sitter-typescript", "uiautomation", - "unicode-normalization", - "unicode-segmentation", "unicode-width", "url", "urlencoding", @@ -4884,75 +4800,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "opentelemetry" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682" -dependencies = [ - "futures-core", - "futures-sink", - "js-sys", - "pin-project-lite", - "thiserror 2.0.18", -] - -[[package]] -name = "opentelemetry-http" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331" -dependencies = [ - "async-trait", - "bytes", - "http 1.4.0", - "opentelemetry", - "reqwest 0.13.1", -] - -[[package]] -name = "opentelemetry-otlp" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" -dependencies = [ - "http 1.4.0", - "opentelemetry", - "opentelemetry-http", - "opentelemetry-proto", - "opentelemetry_sdk", - "prost", - "reqwest 0.13.1", - "thiserror 2.0.18", -] - -[[package]] -name = "opentelemetry-proto" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" -dependencies = [ - "opentelemetry", - "opentelemetry_sdk", - "prost", -] - -[[package]] -name = "opentelemetry_sdk" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368afaed344110f40b179bb8fbe54bc52d98f9bd2b281799ef32487c2650c956" -dependencies = [ - "futures-channel", - "futures-executor", - "futures-util", - "opentelemetry", - "percent-encoding", - "portable-atomic", - "rand 0.9.4", - "thiserror 2.0.18", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -5100,7 +4947,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ "digest 0.10.7", - "hmac 0.12.1", + "hmac", "password-hash 0.4.2", "sha2 0.10.9", ] @@ -5112,7 +4959,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", - "hmac 0.12.1", + "hmac", ] [[package]] @@ -5217,7 +5064,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_shared 0.13.1", - "serde", ] [[package]] @@ -5429,50 +5275,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "postgres" -version = "0.19.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aacf632d0554ff75f58183694f41dc8999c8a3a43a386994d0ec2d034f1dfbe1" -dependencies = [ - "bytes", - "fallible-iterator 0.2.0", - "futures-util", - "log", - "tokio", - "tokio-postgres", -] - -[[package]] -name = "postgres-protocol" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" -dependencies = [ - "base64 0.22.1", - "byteorder", - "bytes", - "fallible-iterator 0.2.0", - "hmac 0.13.0", - "md-5 0.11.0", - "memchr", - "rand 0.10.1", - "sha2 0.11.0", - "stringprep", -] - -[[package]] -name = "postgres-types" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" -dependencies = [ - "bytes", - "chrono", - "fallible-iterator 0.2.0", - "postgres-protocol", -] - [[package]] name = "postscript" version = "0.14.1" @@ -5562,20 +5364,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "prometheus" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a" -dependencies = [ - "cfg-if", - "fnv", - "lazy_static", - "memchr", - "parking_lot", - "thiserror 2.0.18", -] - [[package]] name = "proptest" version = "1.11.0" @@ -6170,7 +5958,6 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.7", ] [[package]] @@ -6179,7 +5966,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac 0.12.1", + "hmac", "subtle", ] @@ -6283,7 +6070,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b3492ea85308705c3a5cc24fb9b9cf77273d30590349070db42991202b214c4" dependencies = [ "bitflags 2.13.1", - "fallible-iterator 0.3.0", + "fallible-iterator", "fallible-streaming-iterator", "hashlink", "libsqlite3-sys", @@ -6520,7 +6307,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f9e24d2b632954ded8ab2ef9fea0a0c769ea56ea98bddbafbad22caeeadf45d" dependencies = [ - "hmac 0.12.1", + "hmac", "pbkdf2 0.11.0", "salsa20", "sha2 0.10.9", @@ -6897,21 +6684,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - -[[package]] -name = "shellexpand" -version = "3.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" -dependencies = [ - "dirs 6.0.0", -] - [[package]] name = "shlex" version = "1.3.0" @@ -7575,7 +7347,7 @@ dependencies = [ "futures", "futures-util", "hex", - "hmac 0.12.1", + "hmac", "lettre", "mail-parser", "parking_lot", @@ -7687,7 +7459,7 @@ dependencies = [ "ed25519-dalek", "futures-util", "hkdf", - "hmac 0.12.1", + "hmac", "rand 0.8.6", "reqwest 0.12.28", "serde", @@ -7772,32 +7544,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-postgres" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" -dependencies = [ - "async-trait", - "byteorder", - "bytes", - "fallible-iterator 0.2.0", - "futures-channel", - "futures-util", - "log", - "parking_lot", - "percent-encoding", - "phf 0.13.1", - "pin-project-lite", - "postgres-protocol", - "postgres-types", - "rand 0.10.1", - "socket2", - "tokio", - "tokio-util", - "whoami", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -8545,7 +8291,7 @@ dependencies = [ "futures", "hex", "hkdf", - "hmac 0.12.1", + "hmac", "log", "md5", "once_cell", @@ -8632,7 +8378,7 @@ dependencies = [ "ghash 0.6.0", "hex", "hkdf", - "hmac 0.12.1", + "hmac", "log", "prost", "rand 0.10.1", @@ -8711,15 +8457,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" @@ -8738,15 +8475,6 @@ dependencies = [ "wit-bindgen 0.51.0", ] -[[package]] -name = "wasite" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" -dependencies = [ - "wasi 0.14.7+wasi-0.2.4", -] - [[package]] name = "wasm-bindgen" version = "0.2.121" @@ -9079,19 +8807,6 @@ dependencies = [ "semver", ] -[[package]] -name = "whoami" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" -dependencies = [ - "libc", - "libredox", - "objc2-system-configuration", - "wasite", - "web-sys", -] - [[package]] name = "winapi" version = "0.3.9" @@ -10082,7 +9797,7 @@ dependencies = [ "crc32fast", "crossbeam-utils", "flate2", - "hmac 0.12.1", + "hmac", "pbkdf2 0.11.0", "sha1", "time", diff --git a/Cargo.toml b/Cargo.toml index c8fbbac0d3..51d395f834 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,13 +96,6 @@ tinyagents = { version = "1.7", features = ["sqlite"] } # link. Keep the version pin in lockstep with the submodule tag. tinycortex = { version = "0.1", features = ["git-diff", "persona", "sync"] } tinychannels = { version = "0.1", features = ["relay-websocket"] } -# TokenJuice code compressor — AST-aware signature extraction. Optional (C build) -# behind the default `tokenjuice-treesitter` feature; disabling it falls back to -# the language-agnostic brace-depth heuristic. See src/openhuman/tokenjuice/compressors/code.rs. -tree-sitter = { version = "0.26", optional = true } -tree-sitter-rust = { version = "0.24", optional = true } -tree-sitter-typescript = { version = "0.23", optional = true } -tree-sitter-python = { version = "0.25", optional = true } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_repr = "0.1" @@ -180,21 +173,16 @@ cron = "0.12" futures-util = "0.3" directories = "6" toml = "1.0" -shellexpand = "3.1" schemars = "1.2" tracing = { version = "0.1", default-features = false } tracing-log = "0.2" tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "ansi", "env-filter"] } tracing-appender = "0.2" -prometheus = { version = "0.14", default-features = false } urlencoding = "2.1" motosan-ai-oauth = { version = "0.2", features = ["codex"] } thiserror = "2.0" ring = "0.17" -prost = { version = "0.14", default-features = false } -postgres = { version = "0.19", features = ["with-chrono-0_4"] } chrono-tz = "0.10" -dialoguer = { version = "0.12", features = ["fuzzy-select"] } dotenvy = "0.15" console = "0.16" regex = "1.10" @@ -206,14 +194,6 @@ regex = "1.10" aho-corasick = "1.1" walkdir = "2" glob = "0.3" -unicode-segmentation = "1" -unicode-width = "0.2" -# NFKC + combining-mark detection for the cross-thread search inverted -# index (`memory_conversations::tokenize`). NFKC unifies CJK half/full- -# width variants and Arabic presentation forms; `canonical_combining_class` -# lets us strip diacritics across all scripts (Polish ą→a, Arabic harakat, -# Hebrew niqqud, etc.) without per-language tables. -unicode-normalization = "0.1" hostname = "0.4.2" rustls = { version = "0.23", features = ["ring"] } rustls-pki-types = "1.14.0" @@ -222,15 +202,9 @@ webpki-roots = "1.0.6" sysinfo = { version = "0.33", default-features = false, features = ["system"] } keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] } clap = { version = "4.5", features = ["derive"] } -clap_complete = "4.5" lettre = { version = "0.11.22", default-features = false, features = ["builder", "smtp-transport", "rustls-tls"], optional = true } -mail-parser = "0.11.2" -async-imap = { version = "0.11", features = ["runtime-tokio"], default-features = false } axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio", "query", "ws", "macros"] } tower = { version = "0.5", default-features = false } -opentelemetry = { version = "0.32", default-features = false, features = ["trace", "metrics"] } -opentelemetry_sdk = { version = "0.32", default-features = false, features = ["trace", "metrics"] } -opentelemetry-otlp = { version = "0.32", default-features = false, features = ["trace", "metrics", "http-proto", "reqwest-client", "reqwest-rustls-webpki-roots"] } sentry = { version = "0.47.0", default-features = false, features = ["backtrace", "contexts", "panic", "tracing", "debug-images", "reqwest", "rustls"] } tokio-stream = { version = "0.1.18", features = ["full"] } url = "2" @@ -255,11 +229,9 @@ ethers-signers = { version = "2.0.14", default-features = false } # - bitcoin: P2WPKH PSBT build/sign/broadcast (includes secp256k1). # - ed25519-dalek: Solana transaction signing. # - bs58: Solana base58 addresses + Tron base58check addresses. -# - ripemd: RIPEMD160 for BTC HASH160 (P2WPKH) and Tron address hash. bitcoin = { version = "0.32", default-features = false, features = ["std", "secp-recovery", "rand-std"], optional = true } ed25519-dalek = { version = "2", default-features = false, features = ["std", "rand_core"] } bs58 = { version = "0.5", default-features = false, features = ["std", "check"] } -ripemd = "0.1" # Shared BIP-39 mnemonic → seed for non-EVM chains (BTC P2WPKH derivation, # Tron secp256k1 derivation, Solana ed25519 SLIP-0010 derivation). Same crate # ethers-signers uses internally, exposed as a direct dep so we can derive @@ -282,6 +254,9 @@ ppt-rs = "0.2.14" # `src/openhuman/tui/` behind `#[cfg(feature = "tui")]`. ratatui = { version = "0.30", optional = true } crossterm = { version = "0.29", optional = true } +# Terminal column-width measurement for the `tui` chat renderer +# (`src/openhuman/tui/render.rs`); only compiled behind `#[cfg(feature = "tui")]`. +unicode-width = { version = "0.2", optional = true } # Native-Rust `.docx` writer for the `generate_document` tool (GH #4847). # Pure Rust (no subprocess / managed runtime), MIT-licensed, actively # maintained (2.8M downloads, last release 0.4.20 — Apr 2026). Mirrors the @@ -478,7 +453,7 @@ mcp = [] # are `#[cfg(feature = "tui")]`; when off, `tui::stub::run_from_cli` returns a # build-fact "tui feature disabled at compile time" error from the untouched # `"tui" | "chat"` CLI arm (mirrors the `mcp` stub pattern). -tui = ["dep:ratatui", "dep:crossterm"] +tui = ["dep:ratatui", "dep:crossterm", "dep:unicode-width"] sandbox-landlock = ["dep:landlock"] sandbox-bubblewrap = [] peripheral-rpi = ["dep:rppal"] diff --git a/app/package.json b/app/package.json index 14ad445157..8875fb9868 100644 --- a/app/package.json +++ b/app/package.json @@ -78,8 +78,6 @@ "@noble/secp256k1": "^3.0.0", "@radix-ui/react-dialog": "^1.1.15", "@reduxjs/toolkit": "^2.11.2", - "@remotion/player": "4.0.454", - "@remotion/zod-types": "4.0.454", "@rive-app/react-webgl2": "^4.28.6", "@scure/base": "^2.2.0", "@scure/bip32": "^2.0.1", @@ -116,7 +114,6 @@ "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", - "remotion": "4.0.454", "socket.io-client": "^4.8.3", "tauri-plugin-ptt-api": "workspace:*", "three": "^0.183.2", diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index f4eefc9f3c..cdacc45430 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -1217,15 +1217,6 @@ dependencies = [ "strsim", ] -[[package]] -name = "clap_complete" -version = "4.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" -dependencies = [ - "clap", -] - [[package]] name = "clap_derive" version = "4.6.1" @@ -1262,12 +1253,6 @@ dependencies = [ "cc", ] -[[package]] -name = "cmov" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" - [[package]] name = "cocoa" version = "0.22.0" @@ -1292,7 +1277,7 @@ dependencies = [ "bs58", "coins-core", "digest 0.10.7", - "hmac 0.12.1", + "hmac", "k256", "serde", "sha2 0.10.9", @@ -1307,7 +1292,7 @@ checksum = "3db8fba409ce3dc04f7d804074039eb68b960b0829161f8e06c95fea3f122528" dependencies = [ "bitvec", "coins-bip32", - "hmac 0.12.1", + "hmac", "once_cell", "pbkdf2 0.12.2", "rand 0.8.6", @@ -1805,15 +1790,6 @@ dependencies = [ "cipher", ] -[[package]] -name = "ctutils" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", -] - [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -1999,19 +1975,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "dialoguer" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" -dependencies = [ - "console", - "fuzzy-matcher", - "shell-words", - "tempfile", - "zeroize", -] - [[package]] name = "digest" version = "0.10.7" @@ -2033,7 +1996,6 @@ dependencies = [ "block-buffer 0.12.0", "const-oid 0.10.2", "crypto-common 0.2.1", - "ctutils", ] [[package]] @@ -2536,7 +2498,7 @@ dependencies = [ "ctr", "digest 0.10.7", "hex", - "hmac 0.12.1", + "hmac", "pbkdf2 0.11.0", "rand 0.8.6", "scrypt", @@ -2687,12 +2649,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fallible-iterator" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" - [[package]] name = "fallible-iterator" version = "0.3.0" @@ -3047,15 +3003,6 @@ dependencies = [ "slab", ] -[[package]] -name = "fuzzy-matcher" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" -dependencies = [ - "thread_local", -] - [[package]] name = "fxhash" version = "0.2.1" @@ -3563,7 +3510,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac 0.12.1", + "hmac", ] [[package]] @@ -3575,15 +3522,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest 0.11.3", -] - [[package]] name = "hostname" version = "0.4.2" @@ -4598,7 +4536,7 @@ dependencies = [ "indexmap 2.14.0", "itoa", "log", - "md-5 0.10.6", + "md-5", "nom 8.0.0", "nom_locate", "rand 0.9.4", @@ -4748,16 +4686,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "md-5" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" -dependencies = [ - "cfg-if", - "digest 0.11.3", -] - [[package]] name = "memchr" version = "2.8.0" @@ -5452,15 +5380,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "objc2-system-configuration" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" -dependencies = [ - "objc2-core-foundation", -] - [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -5610,7 +5529,6 @@ dependencies = [ "anyhow", "arboard", "argon2", - "async-imap", "async-trait", "axum", "base64 0.22.1", @@ -5622,13 +5540,11 @@ dependencies = [ "chrono", "chrono-tz", "clap", - "clap_complete", "coins-bip39", "console", "cpal", "cron", "curve25519-dalek", - "dialoguer", "directories 6.0.0", "dirs 5.0.1", "docx-rs", @@ -5646,7 +5562,7 @@ dependencies = [ "glob", "hex", "hkdf", - "hmac 0.12.1", + "hmac", "hostname", "hound", "iana-time-zone", @@ -5655,28 +5571,20 @@ dependencies = [ "lettre", "libc", "log", - "mail-parser", "motosan-ai-oauth", "nu-ansi-term 0.46.0", "objc2 0.6.4", "objc2-contacts", "objc2-foundation 0.3.2", "once_cell", - "opentelemetry", - "opentelemetry-otlp", - "opentelemetry_sdk", "parking_lot", "pdf-extract", - "postgres", "ppt-rs", - "prometheus", - "prost", "rand 0.10.1", "rdev", "regex", "reqwest 0.12.28", "ring", - "ripemd", "rusqlite", "rustls", "rustls-pki-types", @@ -5688,7 +5596,6 @@ dependencies = [ "serde_yaml", "sha1", "sha2 0.10.9", - "shellexpand", "similar", "socketioxide", "starship-battery", @@ -5714,9 +5621,6 @@ dependencies = [ "tracing-log", "tracing-subscriber", "uiautomation", - "unicode-normalization", - "unicode-segmentation", - "unicode-width", "url", "urlencoding", "uuid 1.23.1", @@ -5774,75 +5678,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "opentelemetry" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682" -dependencies = [ - "futures-core", - "futures-sink", - "js-sys", - "pin-project-lite", - "thiserror 2.0.18", -] - -[[package]] -name = "opentelemetry-http" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331" -dependencies = [ - "async-trait", - "bytes", - "http", - "opentelemetry", - "reqwest 0.13.1", -] - -[[package]] -name = "opentelemetry-otlp" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" -dependencies = [ - "http", - "opentelemetry", - "opentelemetry-http", - "opentelemetry-proto", - "opentelemetry_sdk", - "prost", - "reqwest 0.13.1", - "thiserror 2.0.18", -] - -[[package]] -name = "opentelemetry-proto" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" -dependencies = [ - "opentelemetry", - "opentelemetry_sdk", - "prost", -] - -[[package]] -name = "opentelemetry_sdk" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368afaed344110f40b179bb8fbe54bc52d98f9bd2b281799ef32487c2650c956" -dependencies = [ - "futures-channel", - "futures-executor", - "futures-util", - "opentelemetry", - "percent-encoding", - "portable-atomic", - "rand 0.9.4", - "thiserror 2.0.18", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -6022,7 +5857,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ "digest 0.10.7", - "hmac 0.12.1", + "hmac", "password-hash 0.4.2", "sha2 0.10.9", ] @@ -6034,7 +5869,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", - "hmac 0.12.1", + "hmac", ] [[package]] @@ -6433,50 +6268,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "postgres" -version = "0.19.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aacf632d0554ff75f58183694f41dc8999c8a3a43a386994d0ec2d034f1dfbe1" -dependencies = [ - "bytes", - "fallible-iterator 0.2.0", - "futures-util", - "log", - "tokio", - "tokio-postgres", -] - -[[package]] -name = "postgres-protocol" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" -dependencies = [ - "base64 0.22.1", - "byteorder", - "bytes", - "fallible-iterator 0.2.0", - "hmac 0.13.0", - "md-5 0.11.0", - "memchr", - "rand 0.10.1", - "sha2 0.11.0", - "stringprep", -] - -[[package]] -name = "postgres-types" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" -dependencies = [ - "bytes", - "chrono", - "fallible-iterator 0.2.0", - "postgres-protocol", -] - [[package]] name = "postscript" version = "0.14.1" @@ -6617,20 +6408,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "prometheus" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a" -dependencies = [ - "cfg-if", - "fnv", - "lazy_static", - "memchr", - "parking_lot", - "thiserror 2.0.18", -] - [[package]] name = "proptest" version = "1.11.0" @@ -7146,7 +6923,6 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.7", ] [[package]] @@ -7169,7 +6945,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac 0.12.1", + "hmac", "subtle", ] @@ -7303,7 +7079,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b3492ea85308705c3a5cc24fb9b9cf77273d30590349070db42991202b214c4" dependencies = [ "bitflags 2.11.1", - "fallible-iterator 0.3.0", + "fallible-iterator", "fallible-streaming-iterator", "hashlink", "libsqlite3-sys", @@ -7576,7 +7352,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f9e24d2b632954ded8ab2ef9fea0a0c769ea56ea98bddbafbad22caeeadf45d" dependencies = [ - "hmac 0.12.1", + "hmac", "pbkdf2 0.11.0", "salsa20", "sha2 0.10.9", @@ -8076,21 +7852,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - -[[package]] -name = "shellexpand" -version = "3.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" -dependencies = [ - "dirs 6.0.0", -] - [[package]] name = "shlex" version = "1.3.0" @@ -9258,7 +9019,7 @@ dependencies = [ "futures", "futures-util", "hex", - "hmac 0.12.1", + "hmac", "lettre", "mail-parser", "parking_lot", @@ -9365,7 +9126,7 @@ dependencies = [ "ed25519-dalek", "futures-util", "hkdf", - "hmac 0.12.1", + "hmac", "rand 0.8.6", "reqwest 0.12.28", "serde", @@ -9442,32 +9203,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-postgres" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" -dependencies = [ - "async-trait", - "byteorder", - "bytes", - "fallible-iterator 0.2.0", - "futures-channel", - "futures-util", - "log", - "parking_lot", - "percent-encoding", - "phf 0.13.1", - "pin-project-lite", - "postgres-protocol", - "postgres-types", - "rand 0.10.1", - "socket2", - "tokio", - "tokio-util", - "whoami", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -10364,15 +10099,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" @@ -10391,15 +10117,6 @@ dependencies = [ "wit-bindgen 0.51.0", ] -[[package]] -name = "wasite" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" -dependencies = [ - "wasi 0.14.7+wasi-0.2.4", -] - [[package]] name = "wasm-bindgen" version = "0.2.121" @@ -10728,19 +10445,6 @@ dependencies = [ "semver", ] -[[package]] -name = "whoami" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" -dependencies = [ - "libc", - "libredox", - "objc2-system-configuration", - "wasite", - "web-sys", -] - [[package]] name = "winapi" version = "0.3.9" @@ -11915,7 +11619,7 @@ dependencies = [ "crc32fast", "crossbeam-utils", "flate2", - "hmac 0.12.1", + "hmac", "pbkdf2 0.11.0", "sha1", "time", diff --git a/package.json b/package.json index e87ca4f8cc..f38a97b6a0 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,6 @@ "ws": "^8.20.0" }, "dependencies": { - "@rive-app/react-canvas": "^4.28.6", "@tauri-apps/api": "2.10.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6af4d50258..39b69ad822 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: .: dependencies: - '@rive-app/react-canvas': - specifier: ^4.28.6 - version: 4.28.6(react@19.2.5) '@tauri-apps/api': specifier: 2.10.1 version: 2.10.1 @@ -48,12 +45,6 @@ importers: '@reduxjs/toolkit': specifier: ^2.11.2 version: 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5) - '@remotion/player': - specifier: 4.0.454 - version: 4.0.454(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@remotion/zod-types': - specifier: 4.0.454 - version: 4.0.454(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.3.6) '@rive-app/react-webgl2': specifier: ^4.28.6 version: 4.28.6(react@19.2.5) @@ -162,9 +153,6 @@ importers: remark-math: specifier: ^6.0.0 version: 6.0.0 - remotion: - specifier: 4.0.454 - version: 4.0.454(react-dom@19.2.5(react@19.2.5))(react@19.2.5) socket.io-client: specifier: ^4.8.3 version: 4.8.3 @@ -1407,25 +1395,6 @@ packages: react-redux: optional: true - '@remotion/player@4.0.454': - resolution: {integrity: sha512-Q4fEWapH21uEErdOfZxagOQcxbf/JGxPwqBOrW/NG9Y5qHgMMe5tBUCnr8rEqOA4txW/S4QHnN9HGWfV2nIvTg==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@remotion/zod-types@4.0.454': - resolution: {integrity: sha512-nNXcHF4AmgihwI7sMx50rSgFuCfNyroP0OsgN/mMkQI7Y66uPWBh4Pcytlxs039EShvsiiH621E2TKZOX8ciRA==} - peerDependencies: - zod: 4.3.6 - - '@rive-app/canvas@2.37.8': - resolution: {integrity: sha512-nffrPG+VkBKHAxZdcqYlP5M+n/mOAoagj774HH397UPTsfC27gwQURg64i6dX7WswMc5qIVX0rzCrZ86Wb2HOA==} - - '@rive-app/react-canvas@4.28.6': - resolution: {integrity: sha512-tMEb7uDr+xuPny4HRVnkfGDHgTCkTFn15u7GHj12hC94N59g/dO15TiGr9312GVTO82FulOymOKy6sgxZyi5rw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0 - '@rive-app/react-webgl2@4.28.6': resolution: {integrity: sha512-QqtlD7bm01SxMLuEHE/BfhiPoX3RZ2/B32nF0IR0IRPe2ykxMeebWPtsX50AL9EKLbOh3Fgk3PJ7smkkvQwD2w==} peerDependencies: @@ -5196,12 +5165,6 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} - remotion@4.0.454: - resolution: {integrity: sha512-NGzA7HLBpzRPo1jAyLGXG7AaAdUYHJ18n06GM3nBvFkC5zVZua+2rhezxDTkMJFEMgL54cRQ2Zw/2yd3cWCRbg==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -7097,27 +7060,6 @@ snapshots: react: 19.2.5 react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1) - '@remotion/player@4.0.454(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - remotion: 4.0.454(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - - '@remotion/zod-types@4.0.454(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.3.6)': - dependencies: - remotion: 4.0.454(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - zod: 4.3.6 - transitivePeerDependencies: - - react - - react-dom - - '@rive-app/canvas@2.37.8': {} - - '@rive-app/react-canvas@4.28.6(react@19.2.5)': - dependencies: - '@rive-app/canvas': 2.37.8 - react: 19.2.5 - '@rive-app/react-webgl2@4.28.6(react@19.2.5)': dependencies: '@rive-app/webgl2': 2.37.8 @@ -11677,11 +11619,6 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 - remotion@4.0.454(react-dom@19.2.5(react@19.2.5))(react@19.2.5): - dependencies: - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - require-directory@2.1.1: {} require-from-string@2.0.2: {} diff --git a/scripts/generate-test-inventory.mjs b/scripts/generate-test-inventory.mjs index 83880dfc16..61f0f86655 100644 --- a/scripts/generate-test-inventory.mjs +++ b/scripts/generate-test-inventory.mjs @@ -68,7 +68,6 @@ const DOMAIN_ALLOWLIST = new Set([ 'plan_review', 'provider_surfaces', 'recall_calendar', - 'redirect_links', 'referral', 'session_import', 'skill_runtime', diff --git a/src/core/all.rs b/src/core/all.rs index b5821c13da..6fd7fd648e 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -575,12 +575,6 @@ fn build_registered_controllers() -> Vec { DomainGroup::Memory, crate::openhuman::memory_diff::all_memory_diff_registered_controllers(), ); - // Link shortener for long tracking URLs — saves LLM tokens - push( - &mut controllers, - DomainGroup::Platform, - crate::openhuman::redirect_links::all_redirect_links_registered_controllers(), - ); // Referral and growth tracking push( &mut controllers, @@ -951,9 +945,6 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> { "memory_diff" => Some( "Snapshot-based change tracking for memory sources — capture state, compute diffs, and surface changes to agents.", ), - "redirect_links" => Some( - "Shorten long tracking URLs to `openhuman://link/` placeholders (SQLite-backed) to save tokens in prompts, with round-trip rewrite helpers.", - ), "referral" => Some("Referral codes, stats, and apply flows via the hosted backend API."), "run_ledger" => Some( "Durable agent and workflow run state, child lineage, events, telemetry, and checkpoint references.", diff --git a/src/core/all_tests.rs b/src/core/all_tests.rs index a562c711e5..5667eabeb6 100644 --- a/src/core/all_tests.rs +++ b/src/core/all_tests.rs @@ -119,7 +119,6 @@ fn registered_controller_rpc_method_name() { fn namespace_description_known_namespaces() { assert!(namespace_description("memory").is_some()); assert!(namespace_description("memory_tree").is_some()); - assert!(namespace_description("redirect_links").is_some()); assert!(namespace_description("billing").is_some()); assert!(namespace_description("config").is_some()); assert!(namespace_description("health").is_some()); diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index dc26574017..3bff808817 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -100,7 +100,6 @@ pub mod profiles; pub mod prompt_injection; pub mod provider_surfaces; pub mod recall_calendar; -pub mod redirect_links; pub mod referral; #[cfg(feature = "flows")] pub mod rhai_workflows; diff --git a/src/openhuman/redirect_links/README.md b/src/openhuman/redirect_links/README.md deleted file mode 100644 index 1f5d8984d2..0000000000 --- a/src/openhuman/redirect_links/README.md +++ /dev/null @@ -1,82 +0,0 @@ -# redirect_links - -Redirect-link shortener for token-heavy URLs. Long tracking URLs (e.g. `trip.com/forward/...?bizData=...`) burn model tokens every time they pass through a prompt. This domain encodes them to a short `openhuman://link/` placeholder on inbound text, keeps the full URL in a local SQLite store, and expands the placeholder back to the original URL on outbound messages so the user never sees the placeholder. It also has a separate helper for tagging public `openhm.xyz` short links with a `?u=` attribution param on the way out. - -## Responsibilities - -- **Shorten** a long URL to a content-addressed `openhuman://link/` form and persist it (idempotent / deterministic by URL). -- **Expand** a short id back to its full URL, bumping a hit counter + `last_used_at`. -- **Inbound rewrite**: replace every long URL (≥ `min_len`, default 80) in a text blob with its placeholder, preserving surrounding prose and trailing sentence punctuation. -- **Outbound rewrite**: replace every `openhuman://link/` placeholder in text back to the stored URL; unknown ids are left untouched (nothing silently disappears). -- **Public-link attribution**: append `?u=` (URL-encoded, idempotent, fragment-safe) to `openhm.xyz/` URLs, guarding against lookalike domains. -- List and remove stored links; expose all of the above over JSON-RPC. - -## Key files - -| File | Role | -| --- | --- | -| `src/openhuman/redirect_links/mod.rs` | Export-focused: module docstring, `mod` decls, `pub use` re-exports of ops + schemas + types; aliases `ops as rpc`. | -| `src/openhuman/redirect_links/types.rs` | Serde types: `RedirectLink`, `RewriteReplacement`, `RewriteResult`. | -| `src/openhuman/redirect_links/ops.rs` | Business logic: URL/short-URL/public-URL regexes, inbound/outbound rewrite, `append_user_id_to_public_links`, and the `rl_*` RPC handlers returning `RpcOutcome`. Holds `DEFAULT_MIN_URL_LEN = 80`. | -| `src/openhuman/redirect_links/store.rs` | SQLite persistence: `shorten`/`expand`/`peek`/`list`/`remove`, content-addressed id allocation (SHA-256 hex prefix), schema bootstrap, id<->short-URL helpers (`short_url_for`, `id_from_short`, `SHORT_URL_PREFIX`). | -| `src/openhuman/redirect_links/schemas.rs` | Controller schemas (`all_controller_schemas`, `all_registered_controllers`, `schemas`) + `handle_*` fns delegating to `ops.rs`. | - -## Public surface - -Re-exported from `mod.rs`: - -- Functions (via `pub use ops::…`): `shorten_url`, `expand_link`, `rewrite_inbound`, `rewrite_outbound`, `rewrite_outbound_for_user`, `append_user_id_to_public_links`. -- `pub use ops as rpc` — exposes the `rl_*` handlers under `redirect_links::rpc`. -- Schemas: `all_redirect_links_controller_schemas`, `all_redirect_links_registered_controllers`, `redirect_links_schemas`. -- Types: `RedirectLink`, `RewriteReplacement`, `RewriteResult`. - -Also defined (not re-exported through `mod.rs`): `ops::rewrite_inbound_with_threshold`, `ops::DEFAULT_MIN_URL_LEN`; `store::{shorten, expand, peek, list, remove, short_url_for, id_from_short, SHORT_URL_PREFIX}`. - -## RPC / controllers - -Namespace `redirect_links` (RPC methods `openhuman.redirect_links_`): - -| Function | Inputs | Output | -| --- | --- | --- | -| `shorten` | `url: String` | `link: RedirectLink` | -| `expand` | `id: String` | `link: RedirectLink` (errors if not found) | -| `list` | `limit?: u64` (default 50, max 1000) | `links: RedirectLink[]` (newest first) | -| `remove` | `id: String` | `{ id, removed: bool }` | -| `rewrite_inbound` | `text: String`, `min_len?: u64` (default 80) | `result: RewriteResult` | -| `rewrite_outbound` | `text: String` | `result: RewriteResult` | - -Handlers load config via `config::rpc::load_config_with_timeout()` and return `RpcOutcome` serialized with `into_cli_compatible_json()`. - -## Persistence - -SQLite DB at `{config.workspace_dir}/redirect_links/links.db`, table `redirect_links`: - -| Column | Notes | -| --- | --- | -| `id` | TEXT PRIMARY KEY — SHA-256(url) hex prefix, 8 chars default, grown by 2 up to 32 on prefix collision with a different URL. | -| `url` | TEXT NOT NULL UNIQUE (indexed). | -| `created_at` | RFC3339 TEXT. | -| `last_used_at` | RFC3339 TEXT, nullable; set on each expand. | -| `hit_count` | INTEGER, bumped on each expand. | - -Insert is atomic (`ON CONFLICT DO NOTHING`) so concurrent shortens of the same URL converge on one id with no PRIMARY KEY / UNIQUE error (regression-tested). The connection is opened per call and the schema is created if missing. - -## Dependencies - -- `crate::openhuman::config::Config` — supplies `workspace_dir` for the DB path; handlers call `config::rpc::load_config_with_timeout`. -- `crate::core::all` (`ControllerFuture`, `RegisteredController`) and `crate::core::{ControllerSchema, FieldSchema, TypeSchema}` — controller registry wiring. -- `crate::rpc::RpcOutcome` — RPC return envelope. -- External crates: `rusqlite` (SQLite), `sha2` + `hex` (content-addressed ids), `regex` (URL matching), `chrono` (timestamps), `urlencoding` (user-id encoding), `serde`/`serde_json`, `anyhow`. - -## Used by - -- `src/core/all.rs` registers the controllers + schemas and maps the `"redirect_links"` namespace description; `src/openhuman/mod.rs` declares the module. No other in-tree Rust callers of the rewrite/shorten functions were found — the rewrite pipeline is currently reachable via RPC rather than wired into an inbound/outbound message path inside the core. - -## Notes / gotchas - -- **Ids are content-addressed, not random**: same URL → same id (deterministic, deduped). Removing a link and re-shortening the same URL yields the same id again. -- **Length threshold guards token waste**: the placeholder is ~24 bytes, so URLs below `DEFAULT_MIN_URL_LEN` (80) are left untouched by inbound rewrite. -- **Trailing punctuation handling**: inbound rewrite and public-link tagging strip trailing `. , ; : !` so prose with a URL followed by a period doesn't capture the period into the stored/tagged URL. -- **`append_user_id_to_public_links` is anchored to `openhm.xyz`** specifically and rejects lookalikes (`evil-openhm.xyz`, `openhm.xyz.evil.com`); it splits off `#fragment` so `?u=` always lands in the query, and is idempotent against existing `?u=`/`&u=`. -- **`id_from_short`** accepts both `openhuman://link/` and a bare hex ``, lowercasing the result; non-hex input returns `None`. -- **No agent tools, no event-bus subscribers, no `bus.rs`/`tools.rs`** — this domain is store + ops + RPC only. diff --git a/src/openhuman/redirect_links/mod.rs b/src/openhuman/redirect_links/mod.rs deleted file mode 100644 index 308424567a..0000000000 --- a/src/openhuman/redirect_links/mod.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! Redirect-link shortener for token-heavy URLs. -//! -//! Long tracking URLs (e.g. `trip.com/forward/...?bizData=...`) burn tokens -//! whenever they pass through a model. This domain encodes them to a short -//! `openhuman://link/` form for inbound prompts, keeps the full URL in -//! a local SQLite store, and expands them back on outbound messages so the -//! user never sees the placeholder. - -pub mod ops; -mod schemas; -mod store; -mod types; - -pub use ops as rpc; -pub use ops::{ - append_user_id_to_public_links, expand_link, rewrite_inbound, rewrite_outbound, - rewrite_outbound_for_user, shorten_url, -}; -pub use schemas::{ - all_controller_schemas as all_redirect_links_controller_schemas, - all_registered_controllers as all_redirect_links_registered_controllers, - schemas as redirect_links_schemas, -}; -pub use types::{RedirectLink, RewriteReplacement, RewriteResult}; diff --git a/src/openhuman/redirect_links/ops.rs b/src/openhuman/redirect_links/ops.rs deleted file mode 100644 index 2e4eb5e4c5..0000000000 --- a/src/openhuman/redirect_links/ops.rs +++ /dev/null @@ -1,464 +0,0 @@ -use anyhow::Result; -use regex::Regex; -use serde_json::{json, Value}; -use std::sync::OnceLock; - -use crate::openhuman::config::Config; -use crate::openhuman::redirect_links::store; -use crate::openhuman::redirect_links::types::{RedirectLink, RewriteReplacement, RewriteResult}; -use crate::rpc::RpcOutcome; - -/// URLs shorter than this are not worth rewriting — the `openhuman://link/` -/// placeholder is ~24 bytes, so shortening below this just wastes work and -/// tokens. Callers may override via `rewrite_inbound_with_threshold`. -pub const DEFAULT_MIN_URL_LEN: usize = 80; - -fn url_regex() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - // Wider than the reference regex to catch common tracking-URL characters - // (`#`, `:`, `+`, `@`, `~`, `!`, `,`, `;`). Trailing sentence punctuation - // is stripped below so regular prose doesn't get mangled. - RE.get_or_init(|| Regex::new(r#"https?://[\w\d./\?=%\-&#:+@~!,;]+"#).unwrap()) -} - -fn short_url_regex() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| Regex::new(r"openhuman://link/([0-9a-f]+)").unwrap()) -} - -fn public_url_regex() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - // Anchor on `https?://` and match the `openhm.xyz` domain specifically to - // avoid lookalikes (evil-openhm.xyz) or mid-token matches. Capture optional - // query and fragment as separate tail parts so callers can safely insert - // `?u=` into the query without polluting the fragment. - RE.get_or_init(|| { - Regex::new( - r#"https?://openhm\.xyz/[A-Za-z0-9_-]+(?:\?[\w\d./\?=%\-&:+@~!,;]*)?(?:#[\w\d./\?=%\-&:+@~!,;]*)?"#, - ) - .unwrap() - }) -} - -/// Strip trailing sentence punctuation (`.`, `,`, `;`, `:`, `!`) so that -/// "see https://example.com/path." doesn't capture the period. -fn trim_trailing_punct(s: &str) -> &str { - s.trim_end_matches(['.', ',', ';', ':', '!']) -} - -/// Shorten a single URL, persisting it in the global store. Idempotent. -pub fn shorten_url(config: &Config, url: &str) -> Result { - store::shorten(config, url) -} - -/// Expand a previously-shortened id back to its full URL. Bumps hit count. -pub fn expand_link(config: &Config, id: &str) -> Result> { - store::expand(config, id) -} - -/// Rewrite every long URL in `text` to `openhuman://link/`, using the -/// default length threshold. -pub fn rewrite_inbound(config: &Config, text: &str) -> Result { - rewrite_inbound_with_threshold(config, text, DEFAULT_MIN_URL_LEN) -} - -pub fn rewrite_inbound_with_threshold( - config: &Config, - text: &str, - min_len: usize, -) -> Result { - let re = url_regex(); - let mut replacements: Vec = Vec::new(); - let mut out = String::with_capacity(text.len()); - let mut cursor = 0usize; - - for m in re.find_iter(text) { - out.push_str(&text[cursor..m.start()]); - let raw = m.as_str(); - let url = trim_trailing_punct(raw); - let trailing = &raw[url.len()..]; - - if url.len() >= min_len { - let link = store::shorten(config, url)?; - out.push_str(&link.short_url); - replacements.push(RewriteReplacement { - original: url.to_string(), - replacement: link.short_url, - id: link.id, - }); - } else { - out.push_str(url); - } - out.push_str(trailing); - cursor = m.end(); - } - out.push_str(&text[cursor..]); - - Ok(RewriteResult { - text: out, - replacements, - }) -} - -/// Replace every `openhuman://link/` placeholder with its stored URL. -/// Unknown ids are left as-is so nothing silently disappears. -pub fn rewrite_outbound(config: &Config, text: &str) -> Result { - let re = short_url_regex(); - let mut replacements: Vec = Vec::new(); - let mut out = String::with_capacity(text.len()); - let mut cursor = 0usize; - - for caps in re.captures_iter(text) { - let whole = caps.get(0).unwrap(); - let id = caps.get(1).unwrap().as_str(); - out.push_str(&text[cursor..whole.start()]); - - match store::expand(config, id)? { - Some(link) => { - out.push_str(&link.url); - replacements.push(RewriteReplacement { - original: whole.as_str().to_string(), - replacement: link.url, - id: link.id, - }); - } - None => { - out.push_str(whole.as_str()); - } - } - cursor = whole.end(); - } - out.push_str(&text[cursor..]); - - Ok(RewriteResult { - text: out, - replacements, - }) -} - -/// Convenience wrapper that runs `rewrite_outbound` and then appends the -/// `user_id` to any public `openhm.xyz` links in the result. -pub fn rewrite_outbound_for_user( - config: &Config, - text: &str, - user_id: Option<&str>, -) -> Result { - let mut result = rewrite_outbound(config, text)?; - result.text = append_user_id_to_public_links(&result.text, user_id); - Ok(result) -} - -/// Append `?u=` to every `openhm.xyz/` URL in a string. -/// If `user_id` is `None`, the text is returned unchanged. -/// Idempotent: URLs already containing a `u=` query parameter are left alone. -pub fn append_user_id_to_public_links(text: &str, user_id: Option<&str>) -> String { - let Some(user_id) = user_id else { - return text.to_string(); - }; - - let re = public_url_regex(); - let encoded_user_id = urlencoding::encode(user_id); - let mut out = String::with_capacity(text.len()); - let mut cursor = 0usize; - - for m in re.find_iter(text) { - out.push_str(&text[cursor..m.start()]); - let raw = m.as_str(); - let url = trim_trailing_punct(raw); - let trailing = &raw[url.len()..]; - - // Split off any fragment (#…) so `?u=` lands in the query, not the fragment. - let (base, fragment) = match url.split_once('#') { - Some((b, f)) => (b, Some(f)), - None => (url, None), - }; - - if !base.contains("?u=") && !base.contains("&u=") { - let separator = if base.contains('?') { "&" } else { "?" }; - out.push_str(base); - out.push_str(separator); - out.push_str("u="); - out.push_str(&encoded_user_id); - } else { - out.push_str(base); - } - if let Some(frag) = fragment { - out.push('#'); - out.push_str(frag); - } - out.push_str(trailing); - cursor = m.end(); - } - out.push_str(&text[cursor..]); - out -} - -// ── RPC handlers ──────────────────────────────────────────────────────── - -pub async fn rl_shorten(config: &Config, url: &str) -> Result, String> { - let link = store::shorten(config, url).map_err(|e| e.to_string())?; - Ok(RpcOutcome::single_log( - link.clone(), - format!( - "[redirect_links][rpc][shorten] id={} short_url={} original_url_len={}", - link.id, - link.short_url, - link.url.len() - ), - )) -} - -pub async fn rl_expand(config: &Config, id: &str) -> Result, String> { - match store::expand(config, id).map_err(|e| e.to_string())? { - Some(link) => Ok(RpcOutcome::new( - serde_json::to_value(&link).map_err(|e| e.to_string())?, - vec![format!( - "[redirect_links][rpc][expand] id={} hit_count={}", - link.id, link.hit_count - )], - )), - None => Err(format!("[redirect_links][rpc][expand] not found: id={id}")), - } -} - -pub async fn rl_list(config: &Config, limit: Option) -> Result, String> { - let limit = limit.unwrap_or(50).clamp(1, 1_000); - let links = store::list(config, limit).map_err(|e| e.to_string())?; - Ok(RpcOutcome::new( - json!({ "links": links }), - vec![format!("[redirect_links][rpc][list] count={}", links.len())], - )) -} - -pub async fn rl_remove(config: &Config, id: &str) -> Result, String> { - let removed = store::remove(config, id).map_err(|e| e.to_string())?; - Ok(RpcOutcome::new( - json!({ "id": id, "removed": removed }), - vec![format!( - "[redirect_links][rpc][remove] id={id} removed={removed}" - )], - )) -} - -pub async fn rl_rewrite_inbound( - config: &Config, - text: &str, - min_len: Option, -) -> Result, String> { - let result = - rewrite_inbound_with_threshold(config, text, min_len.unwrap_or(DEFAULT_MIN_URL_LEN)) - .map_err(|e| e.to_string())?; - let count = result.replacements.len(); - Ok(RpcOutcome::single_log( - result, - format!("[redirect_links][rpc][rewrite_inbound] replaced={count}"), - )) -} - -pub async fn rl_rewrite_outbound( - config: &Config, - text: &str, -) -> Result, String> { - let result = rewrite_outbound(config, text).map_err(|e| e.to_string())?; - let count = result.replacements.len(); - Ok(RpcOutcome::single_log( - result, - format!("[redirect_links][rpc][rewrite_outbound] expanded={count}"), - )) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::openhuman::config::Config; - use tempfile::TempDir; - - fn test_config(tmp: &TempDir) -> Config { - let mut cfg = Config::default(); - cfg.workspace_dir = tmp.path().join("workspace"); - std::fs::create_dir_all(&cfg.workspace_dir).unwrap(); - cfg - } - - const LONG: &str = - "https://www.trip.com/forward/middlepages/channel/openEdm.gif?bizData=eyJldmVudCI6Im9wZW4iLCJmaWxlSWQiOiJmaWxlX2EwOD"; - - #[test] - fn inbound_shortens_long_urls_and_preserves_surrounding_text() { - let tmp = TempDir::new().unwrap(); - let cfg = test_config(&tmp); - let text = format!("click here: {LONG} thanks"); - let result = rewrite_inbound(&cfg, &text).unwrap(); - assert!(result.text.starts_with("click here: openhuman://link/")); - assert!(result.text.ends_with(" thanks")); - assert_eq!(result.replacements.len(), 1); - } - - #[test] - fn inbound_leaves_short_urls_untouched() { - let tmp = TempDir::new().unwrap(); - let cfg = test_config(&tmp); - let text = "see https://a.co/x for more"; - let result = rewrite_inbound(&cfg, text).unwrap(); - assert_eq!(result.text, text); - assert!(result.replacements.is_empty()); - } - - #[test] - fn inbound_trims_trailing_sentence_punctuation() { - let tmp = TempDir::new().unwrap(); - let cfg = test_config(&tmp); - let text = format!("open {LONG}."); - let result = rewrite_inbound(&cfg, &text).unwrap(); - assert!(result.text.ends_with(".")); - // The stored URL must not carry the trailing period. - let link = &result.replacements[0]; - assert!(!link.original.ends_with('.')); - } - - #[test] - fn outbound_expands_placeholders_roundtrip() { - let tmp = TempDir::new().unwrap(); - let cfg = test_config(&tmp); - let text = format!("go: {LONG}"); - let inbound = rewrite_inbound(&cfg, &text).unwrap(); - let outbound = rewrite_outbound(&cfg, &inbound.text).unwrap(); - assert_eq!(outbound.text, text); - } - - #[test] - fn outbound_leaves_unknown_ids_unchanged() { - let tmp = TempDir::new().unwrap(); - let cfg = test_config(&tmp); - let text = "no match: openhuman://link/ffffffff"; - let result = rewrite_outbound(&cfg, text).unwrap(); - assert_eq!(result.text, text); - assert!(result.replacements.is_empty()); - } - - #[test] - fn inbound_handles_multiple_urls_in_one_string() { - let tmp = TempDir::new().unwrap(); - let cfg = test_config(&tmp); - let text = format!("{LONG} and also {LONG}?extra=1234567890abcdef"); - let result = rewrite_inbound(&cfg, &text).unwrap(); - assert_eq!(result.replacements.len(), 2); - } - - #[test] - fn append_user_id_to_public_links_bare() { - let text = "https://openhm.xyz/abc"; - let got = append_user_id_to_public_links(text, Some("nikhil")); - assert_eq!(got, "https://openhm.xyz/abc?u=nikhil"); - } - - #[test] - fn append_user_id_to_public_links_query() { - let text = "https://openhm.xyz/abc?foo=bar"; - let got = append_user_id_to_public_links(text, Some("nikhil")); - assert_eq!(got, "https://openhm.xyz/abc?foo=bar&u=nikhil"); - } - - #[test] - fn append_user_id_to_public_links_idempotent() { - // Already ?u= - let text = "https://openhm.xyz/abc?u=existing"; - let got = append_user_id_to_public_links(text, Some("nikhil")); - assert_eq!(got, text); - - // Already &u= - let text = "https://openhm.xyz/abc?foo=bar&u=existing"; - let got = append_user_id_to_public_links(text, Some("nikhil")); - assert_eq!(got, text); - } - - #[test] - fn append_user_id_to_public_links_none() { - let text = "https://openhm.xyz/abc"; - let got = append_user_id_to_public_links(text, None); - assert_eq!(got, text); - } - - #[test] - fn append_user_id_to_public_links_no_match() { - let text = "https://example.com/abc"; - let got = append_user_id_to_public_links(text, Some("nikhil")); - assert_eq!(got, text); - } - - #[test] - fn append_user_id_to_public_links_multiple() { - let text = "https://openhm.xyz/a and https://openhm.xyz/b?x=y"; - let got = append_user_id_to_public_links(text, Some("nikhil")); - assert_eq!( - got, - "https://openhm.xyz/a?u=nikhil and https://openhm.xyz/b?x=y&u=nikhil" - ); - } - - #[test] - fn append_user_id_to_public_links_encoding() { - let text = "https://openhm.xyz/abc"; - let got = append_user_id_to_public_links(text, Some("nikhil@example.com + space")); - assert_eq!( - got, - "https://openhm.xyz/abc?u=nikhil%40example.com%20%2B%20space" - ); - } - - #[test] - fn append_user_id_to_public_links_lookalikes() { - let text = "https://evil-openhm.xyz/abc and openhm.xyz.evil.com/abc"; - let got = append_user_id_to_public_links(text, Some("nikhil")); - assert_eq!(got, text); - } - - #[test] - fn append_user_id_to_public_links_punctuation() { - let text = "Click https://openhm.xyz/abc."; - let got = append_user_id_to_public_links(text, Some("nikhil")); - assert_eq!(got, "Click https://openhm.xyz/abc?u=nikhil."); - } - - #[test] - fn append_user_id_to_public_links_query_with_fragment() { - let text = "https://openhm.xyz/abc?foo=bar#frag"; - let got = append_user_id_to_public_links(text, Some("nikhil")); - assert_eq!(got, "https://openhm.xyz/abc?foo=bar&u=nikhil#frag"); - } - - #[test] - fn append_user_id_to_public_links_bare_with_fragment() { - let text = "https://openhm.xyz/abc#frag"; - let got = append_user_id_to_public_links(text, Some("nikhil")); - assert_eq!(got, "https://openhm.xyz/abc?u=nikhil#frag"); - } - - #[test] - fn rewrite_outbound_for_user_expands_placeholder_and_tags_public_url() { - let tmp = TempDir::new().unwrap(); - let cfg = test_config(&tmp); - - // Shorten LONG into an openhuman:// placeholder, then craft outbound text - // that mixes the placeholder with a public openhm.xyz URL. - let inbound = rewrite_inbound(&cfg, LONG).unwrap(); - let placeholder = &inbound.replacements[0].replacement; - let text = format!("see {placeholder} and https://openhm.xyz/abc"); - - let result = rewrite_outbound_for_user(&cfg, &text, Some("nikhil")).unwrap(); - assert!( - result.text.contains(LONG), - "placeholder must expand back to LONG" - ); - assert!( - result.text.contains("https://openhm.xyz/abc?u=nikhil"), - "openhm.xyz URL must carry ?u= tag" - ); - - // None user_id leaves openhm.xyz untouched but still expands placeholder. - let result_none = rewrite_outbound_for_user(&cfg, &text, None).unwrap(); - assert!(result_none.text.contains(LONG)); - assert!(result_none.text.contains("https://openhm.xyz/abc")); - assert!(!result_none.text.contains("?u=")); - } -} diff --git a/src/openhuman/redirect_links/schemas.rs b/src/openhuman/redirect_links/schemas.rs deleted file mode 100644 index 8e94adfe2d..0000000000 --- a/src/openhuman/redirect_links/schemas.rs +++ /dev/null @@ -1,315 +0,0 @@ -use serde::de::DeserializeOwned; -use serde_json::{Map, Value}; - -use crate::core::all::{ControllerFuture, RegisteredController}; -use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; -use crate::openhuman::config::rpc as config_rpc; -use crate::openhuman::redirect_links::ops as rl_ops; -use crate::rpc::RpcOutcome; - -pub fn all_controller_schemas() -> Vec { - vec![ - schemas("shorten"), - schemas("expand"), - schemas("list"), - schemas("remove"), - schemas("rewrite_inbound"), - schemas("rewrite_outbound"), - ] -} - -pub fn all_registered_controllers() -> Vec { - vec![ - RegisteredController { - schema: schemas("shorten"), - handler: handle_shorten, - }, - RegisteredController { - schema: schemas("expand"), - handler: handle_expand, - }, - RegisteredController { - schema: schemas("list"), - handler: handle_list, - }, - RegisteredController { - schema: schemas("remove"), - handler: handle_remove, - }, - RegisteredController { - schema: schemas("rewrite_inbound"), - handler: handle_rewrite_inbound, - }, - RegisteredController { - schema: schemas("rewrite_outbound"), - handler: handle_rewrite_outbound, - }, - ] -} - -pub fn schemas(function: &str) -> ControllerSchema { - match function { - "shorten" => ControllerSchema { - namespace: "redirect_links", - function: "shorten", - description: "Persist a long URL and return its `openhuman://link/` short form.", - inputs: vec![FieldSchema { - name: "url", - ty: TypeSchema::String, - comment: "The full URL to shorten.", - required: true, - }], - outputs: vec![FieldSchema { - name: "link", - ty: TypeSchema::Ref("RedirectLink"), - comment: "The stored redirect link record.", - required: true, - }], - }, - "expand" => ControllerSchema { - namespace: "redirect_links", - function: "expand", - description: "Resolve a short id back to its full URL and bump hit count.", - inputs: vec![FieldSchema { - name: "id", - ty: TypeSchema::String, - comment: "The short id (the hex portion after `openhuman://link/`).", - required: true, - }], - outputs: vec![FieldSchema { - name: "link", - ty: TypeSchema::Ref("RedirectLink"), - comment: "The resolved redirect link record.", - required: true, - }], - }, - "list" => ControllerSchema { - namespace: "redirect_links", - function: "list", - description: "List stored redirect links, newest first.", - inputs: vec![FieldSchema { - name: "limit", - ty: TypeSchema::Option(Box::new(TypeSchema::U64)), - comment: "Maximum number of links to return (default 50, max 1000).", - required: false, - }], - outputs: vec![FieldSchema { - name: "links", - ty: TypeSchema::Array(Box::new(TypeSchema::Ref("RedirectLink"))), - comment: "Stored redirect links.", - required: true, - }], - }, - "remove" => ControllerSchema { - namespace: "redirect_links", - function: "remove", - description: "Delete a redirect link by id.", - inputs: vec![FieldSchema { - name: "id", - ty: TypeSchema::String, - comment: "Redirect link id to remove.", - required: true, - }], - outputs: vec![FieldSchema { - name: "result", - ty: TypeSchema::Object { - fields: vec![ - FieldSchema { - name: "id", - ty: TypeSchema::String, - comment: "Id requested for removal.", - required: true, - }, - FieldSchema { - name: "removed", - ty: TypeSchema::Bool, - comment: "True when a row was deleted.", - required: true, - }, - ], - }, - comment: "Removal result.", - required: true, - }], - }, - "rewrite_inbound" => ControllerSchema { - namespace: "redirect_links", - function: "rewrite_inbound", - description: - "Rewrite every long URL in `text` to an `openhuman://link/` placeholder \ - to save tokens before a prompt hits the model. URLs shorter than `min_len` \ - are left untouched.", - inputs: vec![ - FieldSchema { - name: "text", - ty: TypeSchema::String, - comment: "Text to rewrite.", - required: true, - }, - FieldSchema { - name: "min_len", - ty: TypeSchema::Option(Box::new(TypeSchema::U64)), - comment: "Minimum URL length to shorten; defaults to 80.", - required: false, - }, - ], - outputs: vec![FieldSchema { - name: "result", - ty: TypeSchema::Ref("RewriteResult"), - comment: "Rewritten text and per-URL replacement records.", - required: true, - }], - }, - "rewrite_outbound" => ControllerSchema { - namespace: "redirect_links", - function: "rewrite_outbound", - description: - "Expand every `openhuman://link/` placeholder in `text` back to its full \ - URL before the message reaches the user.", - inputs: vec![FieldSchema { - name: "text", - ty: TypeSchema::String, - comment: "Text to rewrite.", - required: true, - }], - outputs: vec![FieldSchema { - name: "result", - ty: TypeSchema::Ref("RewriteResult"), - comment: "Rewritten text and per-placeholder expansion records.", - required: true, - }], - }, - _other => ControllerSchema { - namespace: "redirect_links", - function: "unknown", - description: "Unknown redirect_links controller function.", - inputs: vec![FieldSchema { - name: "function", - ty: TypeSchema::String, - comment: "Unknown function requested for schema lookup.", - required: true, - }], - outputs: vec![FieldSchema { - name: "error", - ty: TypeSchema::String, - comment: "Lookup error details.", - required: true, - }], - }, - } -} - -fn handle_shorten(params: Map) -> ControllerFuture { - Box::pin(async move { - let config = config_rpc::load_config_with_timeout().await?; - let url = read_required::(¶ms, "url")?; - to_json(rl_ops::rl_shorten(&config, url.trim()).await?) - }) -} - -fn handle_expand(params: Map) -> ControllerFuture { - Box::pin(async move { - let config = config_rpc::load_config_with_timeout().await?; - let id = read_required::(¶ms, "id")?; - to_json(rl_ops::rl_expand(&config, id.trim()).await?) - }) -} - -fn handle_list(params: Map) -> ControllerFuture { - Box::pin(async move { - let config = config_rpc::load_config_with_timeout().await?; - let limit = read_optional_u64(¶ms, "limit")? - .map(|raw| usize::try_from(raw).map_err(|_| "limit is too large for usize".to_string())) - .transpose()?; - to_json(rl_ops::rl_list(&config, limit).await?) - }) -} - -fn handle_remove(params: Map) -> ControllerFuture { - Box::pin(async move { - let config = config_rpc::load_config_with_timeout().await?; - let id = read_required::(¶ms, "id")?; - to_json(rl_ops::rl_remove(&config, id.trim()).await?) - }) -} - -fn handle_rewrite_inbound(params: Map) -> ControllerFuture { - Box::pin(async move { - let config = config_rpc::load_config_with_timeout().await?; - let text = read_required::(¶ms, "text")?; - let min_len = read_optional_u64(¶ms, "min_len")? - .map(|raw| usize::try_from(raw).map_err(|_| "min_len too large for usize".to_string())) - .transpose()?; - to_json(rl_ops::rl_rewrite_inbound(&config, &text, min_len).await?) - }) -} - -fn handle_rewrite_outbound(params: Map) -> ControllerFuture { - Box::pin(async move { - let config = config_rpc::load_config_with_timeout().await?; - let text = read_required::(¶ms, "text")?; - to_json(rl_ops::rl_rewrite_outbound(&config, &text).await?) - }) -} - -fn read_required(params: &Map, key: &str) -> Result { - let value = params - .get(key) - .cloned() - .ok_or_else(|| format!("missing required param '{key}'"))?; - serde_json::from_value(value).map_err(|e| format!("invalid '{key}': {e}")) -} - -fn read_optional_u64(params: &Map, key: &str) -> Result, String> { - match params.get(key) { - None => Ok(None), - Some(Value::Null) => Ok(None), - Some(Value::Number(n)) => n - .as_u64() - .map(Some) - .ok_or_else(|| format!("invalid '{key}': expected unsigned integer")), - Some(_) => Err(format!("invalid '{key}': expected unsigned integer")), - } -} - -fn to_json(outcome: RpcOutcome) -> Result { - outcome.into_cli_compatible_json() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn all_schemas_and_controllers_cover_every_function() { - let names: Vec<_> = all_controller_schemas() - .into_iter() - .map(|s| s.function) - .collect(); - assert_eq!( - names, - vec![ - "shorten", - "expand", - "list", - "remove", - "rewrite_inbound", - "rewrite_outbound", - ], - ); - assert_eq!(all_registered_controllers().len(), 6); - } - - #[test] - fn schemas_unknown_returns_placeholder() { - let s = schemas("does-not-exist"); - assert_eq!(s.function, "unknown"); - } - - #[test] - fn shorten_schema_requires_url() { - let s = schemas("shorten"); - assert_eq!(s.inputs.len(), 1); - assert!(s.inputs[0].required); - } -} diff --git a/src/openhuman/redirect_links/store.rs b/src/openhuman/redirect_links/store.rs deleted file mode 100644 index a88c78d55e..0000000000 --- a/src/openhuman/redirect_links/store.rs +++ /dev/null @@ -1,337 +0,0 @@ -use anyhow::{Context, Result}; -use chrono::{DateTime, Utc}; -use rusqlite::{params, Connection, OptionalExtension}; -use sha2::{Digest, Sha256}; - -use crate::openhuman::config::Config; -use crate::openhuman::redirect_links::types::RedirectLink; - -pub const SHORT_URL_PREFIX: &str = "openhuman://link/"; -const DEFAULT_ID_LEN: usize = 8; -const MAX_ID_LEN: usize = 32; - -/// Build the short URL representation for an id. -pub fn short_url_for(id: &str) -> String { - format!("{SHORT_URL_PREFIX}{id}") -} - -/// Parse a short URL back into its id component. Accepts both -/// `openhuman://link/` and bare `` (hex only). -pub fn id_from_short(short: &str) -> Option { - let trimmed = short.trim(); - let candidate = trimmed.strip_prefix(SHORT_URL_PREFIX).unwrap_or(trimmed); - if !candidate.is_empty() && candidate.chars().all(|c| c.is_ascii_hexdigit()) { - Some(candidate.to_ascii_lowercase()) - } else { - None - } -} - -fn content_id(url: &str, len: usize) -> String { - let digest = Sha256::digest(url.as_bytes()); - hex::encode(digest)[..len.min(64)].to_string() -} - -pub fn shorten(config: &Config, url: &str) -> Result { - let url = url.trim(); - if url.is_empty() { - anyhow::bail!("url must not be empty"); - } - - with_connection(config, |conn| { - let mut len = DEFAULT_ID_LEN; - let now = Utc::now(); - loop { - if len > MAX_ID_LEN { - anyhow::bail!("failed to allocate unique redirect id after expansion"); - } - let id = content_id(url, len); - - // Atomic insert. If either `id` or `url` already exists, the - // statement becomes a no-op — no PRIMARY KEY / UNIQUE error under - // concurrent calls, so we don't need a pre-read. - let affected = conn - .execute( - "INSERT INTO redirect_links - (id, url, created_at, last_used_at, hit_count) - VALUES (?1, ?2, ?3, NULL, 0) - ON CONFLICT DO NOTHING", - params![id, url, now.to_rfc3339()], - ) - .context("failed to insert redirect_link")?; - - if affected > 0 { - return Ok(RedirectLink { - id: id.clone(), - url: url.to_string(), - short_url: short_url_for(&id), - created_at: now, - last_used_at: None, - hit_count: 0, - }); - } - - // Insert was a no-op. Either the URL is already stored (possibly - // under a longer id from a concurrent writer — idempotent return) - // or this id prefix collides with a different URL. - if let Some(existing) = find_by_url(conn, url)? { - return Ok(existing); - } - match get_by_id(conn, &id)? { - Some(existing) if existing.url == url => return Ok(existing), - Some(_) => { - // Hash-prefix collision with a different URL — lengthen. - len += 2; - continue; - } - None => { - // Race with a concurrent delete; retry this same length. - continue; - } - } - } - }) -} - -pub fn expand(config: &Config, id: &str) -> Result> { - let id = id.trim(); - if id.is_empty() { - return Ok(None); - } - with_connection(config, |conn| { - let found = get_by_id(conn, id)?; - if found.is_some() { - let now = Utc::now().to_rfc3339(); - conn.execute( - "UPDATE redirect_links - SET hit_count = hit_count + 1, last_used_at = ?2 - WHERE id = ?1", - params![id, now], - ) - .context("failed to bump redirect_link hit count")?; - } - Ok(found) - }) -} - -pub fn peek(config: &Config, id: &str) -> Result> { - let id = id.trim(); - if id.is_empty() { - return Ok(None); - } - with_connection(config, |conn| get_by_id(conn, id)) -} - -pub fn list(config: &Config, limit: usize) -> Result> { - with_connection(config, |conn| { - let mut stmt = conn.prepare( - "SELECT id, url, created_at, last_used_at, hit_count - FROM redirect_links - ORDER BY datetime(created_at) DESC - LIMIT ?1", - )?; - let rows = stmt - .query_map(params![limit as i64], row_to_link)? - .collect::, _>>()?; - Ok(rows) - }) -} - -pub fn remove(config: &Config, id: &str) -> Result { - with_connection(config, |conn| { - let affected = conn - .execute("DELETE FROM redirect_links WHERE id = ?1", params![id]) - .context("failed to delete redirect_link")?; - Ok(affected > 0) - }) -} - -fn get_by_id(conn: &Connection, id: &str) -> Result> { - conn.query_row( - "SELECT id, url, created_at, last_used_at, hit_count - FROM redirect_links WHERE id = ?1", - params![id], - row_to_link, - ) - .optional() - .map_err(Into::into) -} - -fn find_by_url(conn: &Connection, url: &str) -> Result> { - conn.query_row( - "SELECT id, url, created_at, last_used_at, hit_count - FROM redirect_links WHERE url = ?1", - params![url], - row_to_link, - ) - .optional() - .map_err(Into::into) -} - -fn row_to_link(row: &rusqlite::Row<'_>) -> rusqlite::Result { - let id: String = row.get(0)?; - let url: String = row.get(1)?; - let created_at: String = row.get(2)?; - let last_used_at: Option = row.get(3)?; - let hit_count: i64 = row.get(4)?; - let created_at = parse_ts(&created_at)?; - let last_used_at = last_used_at.as_deref().map(parse_ts).transpose()?; - Ok(RedirectLink { - short_url: short_url_for(&id), - id, - url, - created_at, - last_used_at, - hit_count: hit_count.max(0) as u64, - }) -} - -fn parse_ts(s: &str) -> rusqlite::Result> { - DateTime::parse_from_rfc3339(s) - .map(|t| t.with_timezone(&Utc)) - .map_err(|e| { - rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e)) - }) -} - -fn with_connection(config: &Config, f: impl FnOnce(&Connection) -> Result) -> Result { - let db_path = config.workspace_dir.join("redirect_links").join("links.db"); - if let Some(parent) = db_path.parent() { - std::fs::create_dir_all(parent).with_context(|| { - format!( - "Failed to create redirect_links directory: {}", - parent.display() - ) - })?; - } - - let conn = Connection::open(&db_path) - .with_context(|| format!("Failed to open redirect_links DB: {}", db_path.display()))?; - - conn.execute_batch( - "PRAGMA foreign_keys = ON; - CREATE TABLE IF NOT EXISTS redirect_links ( - id TEXT PRIMARY KEY, - url TEXT NOT NULL UNIQUE, - created_at TEXT NOT NULL, - last_used_at TEXT, - hit_count INTEGER NOT NULL DEFAULT 0 - ); - CREATE INDEX IF NOT EXISTS idx_redirect_links_url ON redirect_links(url);", - ) - .context("Failed to initialize redirect_links schema")?; - - f(&conn) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - fn test_config(tmp: &TempDir) -> Config { - let mut cfg = Config::default(); - cfg.workspace_dir = tmp.path().join("workspace"); - std::fs::create_dir_all(&cfg.workspace_dir).unwrap(); - cfg - } - - #[test] - fn shorten_is_deterministic_and_dedupes() { - let tmp = TempDir::new().unwrap(); - let cfg = test_config(&tmp); - let url = "https://www.trip.com/forward/middlepages/channel/openEdm.gif?bizData=eyJldmVudCI6Im9wZW4ifQ"; - let a = shorten(&cfg, url).unwrap(); - let b = shorten(&cfg, url).unwrap(); - assert_eq!(a.id, b.id); - assert_eq!(a.short_url, format!("openhuman://link/{}", a.id)); - assert_eq!(a.id.len(), DEFAULT_ID_LEN); - } - - #[test] - fn expand_returns_original_url_and_bumps_hits() { - let tmp = TempDir::new().unwrap(); - let cfg = test_config(&tmp); - let link = shorten(&cfg, "https://example.com/a?x=1").unwrap(); - let got = expand(&cfg, &link.id).unwrap().expect("link exists"); - assert_eq!(got.url, "https://example.com/a?x=1"); - assert_eq!(got.hit_count, 0); - let got2 = expand(&cfg, &link.id).unwrap().unwrap(); - assert_eq!(got2.hit_count, 1); - } - - #[test] - fn expand_unknown_id_returns_none() { - let tmp = TempDir::new().unwrap(); - let cfg = test_config(&tmp); - assert!(expand(&cfg, "deadbeef").unwrap().is_none()); - } - - #[test] - fn id_from_short_accepts_scheme_and_rejects_others() { - assert_eq!( - id_from_short("openhuman://link/abc123"), - Some("abc123".into()) - ); - assert!(id_from_short("https://example.com/").is_none()); - assert!(id_from_short("openhuman://link/").is_none()); - assert!(id_from_short("openhuman://link/not-hex!").is_none()); - } - - #[test] - fn id_from_short_accepts_bare_id_and_normalizes_case() { - // The docstring promises bare-id acceptance — lock it in. - assert_eq!(id_from_short("abc123").as_deref(), Some("abc123")); - assert_eq!(id_from_short(" ABC123 ").as_deref(), Some("abc123")); - assert!(id_from_short("").is_none()); - assert!(id_from_short("not-hex").is_none()); - } - - #[test] - fn shorten_handles_concurrent_calls_without_primary_key_error() { - // Regression test: the previous check-then-insert path raced under - // concurrent calls and hit a PRIMARY KEY constraint error. The - // ON CONFLICT DO NOTHING path must return the same link for every - // concurrent caller with the same URL. - use std::sync::Arc; - use std::thread; - - let tmp = TempDir::new().unwrap(); - let cfg = Arc::new(test_config(&tmp)); - let url = "https://example.com/concurrent?x=1".to_string(); - - let mut handles = Vec::new(); - for _ in 0..8 { - let cfg = Arc::clone(&cfg); - let url = url.clone(); - handles.push(thread::spawn(move || shorten(&cfg, &url).unwrap())); - } - let ids: Vec = handles.into_iter().map(|h| h.join().unwrap().id).collect(); - // Every concurrent writer must agree on a single id for the URL. - assert!(ids.iter().all(|id| id == &ids[0])); - } - - #[test] - fn list_orders_newest_first_and_respects_limit() { - let tmp = TempDir::new().unwrap(); - let cfg = test_config(&tmp); - for i in 0..5 { - shorten( - &cfg, - &format!("https://example.com/{i}?v=xxxxxxxxxxxxxxxxxxxx"), - ) - .unwrap(); - } - let rows = list(&cfg, 3).unwrap(); - assert_eq!(rows.len(), 3); - } - - #[test] - fn remove_deletes_and_reports_affected() { - let tmp = TempDir::new().unwrap(); - let cfg = test_config(&tmp); - let link = shorten(&cfg, "https://example.com/rm").unwrap(); - assert!(remove(&cfg, &link.id).unwrap()); - assert!(!remove(&cfg, &link.id).unwrap()); - } -} diff --git a/src/openhuman/redirect_links/types.rs b/src/openhuman/redirect_links/types.rs deleted file mode 100644 index 6d631a7671..0000000000 --- a/src/openhuman/redirect_links/types.rs +++ /dev/null @@ -1,25 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RedirectLink { - pub id: String, - pub url: String, - pub short_url: String, - pub created_at: DateTime, - pub last_used_at: Option>, - pub hit_count: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RewriteReplacement { - pub original: String, - pub replacement: String, - pub id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RewriteResult { - pub text: String, - pub replacements: Vec, -} diff --git a/src/openhuman/security/policy/types.rs b/src/openhuman/security/policy/types.rs index e6f02ab7ad..317eb3bb74 100644 --- a/src/openhuman/security/policy/types.rs +++ b/src/openhuman/security/policy/types.rs @@ -184,6 +184,10 @@ pub(super) const WORKSPACE_INTERNAL_DIRS: &[&str] = &[ "vault", "task_sources", "whatsapp_data", + // The redirect_links domain was removed (#5051), but an upgraded profile can + // still hold a legacy `redirect_links/links.db` (stored URL history) written + // by an older version. Keep the directory on the internal denylist so agents + // with workspace access cannot read or overwrite that leftover state. "redirect_links", "codegraph", ".openhuman", From bea380d954cc73a2c9910e210216bea3d3283af5 Mon Sep 17 00:00:00 2001 From: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:31:51 +0530 Subject: [PATCH 14/56] feat(bench): embedded-RSS benchmark harness + report-only CI (#5046) (#5060) --- .github/workflows/ci-lite.yml | 48 ++ Cargo.toml | 12 + scripts/__tests__/feature-forwarding.test.mjs | 4 + scripts/ci/check-feature-forwarding.mjs | 15 +- scripts/lib/feature-forwarding.mjs | 17 + src/bin/rss_bench.rs | 436 +++++++++++++++++ src/openhuman/mod.rs | 1 + src/openhuman/proc_metrics/mod.rs | 446 ++++++++++++++++++ 8 files changed, 965 insertions(+), 14 deletions(-) create mode 100644 src/bin/rss_bench.rs create mode 100644 src/openhuman/proc_metrics/mod.rs diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index 4ae6ae2029..4cfd70c64d 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -470,6 +470,54 @@ jobs: fi echo "gate-contract test coverage allowlist is current" + # Report-only (#5046). Measures steady-state RSS of an embedded openhuman_core + # agent roster and uploads the raw samples + a human summary. Deliberately NOT + # in `pr-ci-gate.needs`, so it can never fail a PR — it exists to accrue a + # baseline and its runner variance before the 30 MiB gate is flipped to + # blocking in a follow-up (add this job to pr-ci-gate + a threshold step then). + rust-rss-bench: + name: Rust RSS Benchmark (report-only) + needs: [changes] + if: needs.changes.outputs['rust-core'] == 'true' + runs-on: ubuntu-22.04 + timeout-minutes: 40 + container: + image: ghcr.io/tinyhumansai/openhuman_ci:rust-1.93.0 + env: + CARGO_INCREMENTAL: "0" + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 1 + persist-credentials: false + submodules: recursive + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . -> target + cache-on-failure: true + shared-key: pr-rust-rss-bench + + # Fixture-contract signal: the gated bin's unit tests (build_roster, + # warm-up turn) never enter the default coverage lane, so run them here. + - name: Run rss-bench fixture tests + run: bash scripts/ci-cancel-aware.sh cargo test --features rss-bench --bin rss-bench + + - name: Build stripped-release rss-bench + run: bash scripts/ci-cancel-aware.sh cargo build --release --features rss-bench --bin rss-bench + + - name: Measure embedded RSS (5 fresh procs x {1,8} agents) + run: ./target/release/rss-bench --out bench-rss.json | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Upload raw RSS samples + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: rss-bench-report + path: bench-rss.json + rust-core-coverage: name: Rust Core Coverage (cargo-llvm-cov) needs: [changes, rust-quality] diff --git a/Cargo.toml b/Cargo.toml index 51d395f834..4dcbbf1914 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,14 @@ path = "src/bin/test_mcp_stub.rs" name = "openhuman-fleet" path = "src/bin/fleet.rs" +# Embedded-RSS benchmark harness (#5046). Gated behind the default-OFF +# `rss-bench` feature so no benchmark code enters the shipped build. Build with +# `cargo build --release --features rss-bench --bin rss-bench`. +[[bin]] +name = "rss-bench" +path = "src/bin/rss_bench.rs" +required-features = ["rss-bench"] + [lib] name = "openhuman_core" crate-type = ["rlib"] @@ -466,6 +474,10 @@ whatsapp-web = ["tinychannels/whatsapp-web"] # build (app/scripts/e2e-build.sh) flips it on. Shipped binaries never have # this feature so the wipe RPC isn't even registered, let alone reachable. e2e-test-support = [] +# Builds the `rss-bench` benchmark binary (#5046). Default-OFF, so it is never +# part of the shipped desktop/library build and the feature-forwarding gate +# (which only inspects the `default` list) never requires forwarding it. +rss-bench = [] [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage)'] } diff --git a/scripts/__tests__/feature-forwarding.test.mjs b/scripts/__tests__/feature-forwarding.test.mjs index 745c3a6c82..3bdd62379d 100644 --- a/scripts/__tests__/feature-forwarding.test.mjs +++ b/scripts/__tests__/feature-forwarding.test.mjs @@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'; import { diffForwarding, + INTENTIONALLY_NOT_FORWARDED, parseCoreDefaultFeatures, parseShellForwardedFeatures, stripComments, @@ -179,6 +180,9 @@ test('the real shell manifest forwards every real core default', () => { assert.ok(coreDefaults.length > 0, 'expected to parse at least one core default gate'); assert.equal(shell.defaultFeatures, false, 'shell is expected to set default-features = false'); for (const gate of coreDefaults) { + // Gates the shell intentionally does not forward (e.g. `tui` — a terminal + // subcommand the desktop app never runs) are exempt, matching the checker. + if (INTENTIONALLY_NOT_FORWARDED[gate]) continue; assert.ok( shell.features.includes(gate), `core default gate not forwarded to the shell: ${gate}` diff --git a/scripts/ci/check-feature-forwarding.mjs b/scripts/ci/check-feature-forwarding.mjs index a346f4c7dd..4a8e410dad 100644 --- a/scripts/ci/check-feature-forwarding.mjs +++ b/scripts/ci/check-feature-forwarding.mjs @@ -15,26 +15,13 @@ import { fileURLToPath } from 'node:url'; import { diffForwarding, formatReport, + INTENTIONALLY_NOT_FORWARDED, parseCoreDefaultFeatures, parseShellForwardedFeatures, } from '../lib/feature-forwarding.mjs'; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); -/** - * Gates the desktop shell intentionally does NOT forward, mapped to why. - * - * Empty by design: every current default-ON gate belongs in the shipped app. - * Adding an entry is a deliberate product decision, not a way to silence this - * check — the reason string is what a future reader (and reviewer) relies on to - * tell "excluded on purpose" from "forgotten". That ambiguity is exactly what - * let #4918 sit unnoticed since #4123. - */ -const INTENTIONALLY_NOT_FORWARDED = { - // 'some-gate': 'Reason it must not ship in the desktop build.', - tui: 'Terminal UI subcommand (openhuman tui/chat); the desktop app ships its own Tauri UI and never runs the ratatui terminal front-end.', -}; - function usage() { return 'Usage: check-feature-forwarding.mjs [core-manifest] [shell-manifest]'; } diff --git a/scripts/lib/feature-forwarding.mjs b/scripts/lib/feature-forwarding.mjs index a105e0646b..91195ef39f 100644 --- a/scripts/lib/feature-forwarding.mjs +++ b/scripts/lib/feature-forwarding.mjs @@ -20,6 +20,23 @@ // only needs two well-known shapes, and the repo has no TOML dependency for // Node. It is regex/scanner-based in the same spirit as `checklist-parser.mjs`. +/** + * Gates the desktop shell intentionally does NOT forward, mapped to why. + * + * Adding an entry is a deliberate product decision, not a way to silence the + * forwarding guard — the reason string is what a future reader (and reviewer) + * relies on to tell "excluded on purpose" from "forgotten". That ambiguity is + * exactly what let #4918 sit unnoticed since #4123. + * + * Lives here (not in the checker) so both the CI checker and the self-test read + * the same source of truth — otherwise the self-test can demand a forward the + * checker legitimately exempts, which is exactly the drift #5084's `tui` gate hit. + */ +export const INTENTIONALLY_NOT_FORWARDED = { + // 'some-gate': 'Reason it must not ship in the desktop build.', + tui: 'Terminal UI subcommand (openhuman tui/chat); the desktop app ships its own Tauri UI and never runs the ratatui terminal front-end.', +}; + /** * Strip TOML `#` comments while respecting quoted strings, so a `#` inside a * value (or an issue number in a comment) can't truncate a real line. diff --git a/src/bin/rss_bench.rs b/src/bin/rss_bench.rs new file mode 100644 index 0000000000..493cc63df0 --- /dev/null +++ b/src/bin/rss_bench.rs @@ -0,0 +1,436 @@ +//! `rss-bench` — steady-state RSS benchmark for an embedded `openhuman_core` +//! agent roster (#5046). +//! +//! Mirrors the OpenCompany embedding contract: a bare [`Agent`] built directly +//! via [`Agent::builder`] (no `CoreBuilder`, no RPC, no background services) +//! with an injected mock provider, an in-process `"none"` memory backend, and a +//! per-agent temp workspace. Builds a 1-agent and an 8-agent roster, runs one +//! deterministic warm-up turn per agent to fault in lazy allocations, settles, +//! then samples `/proc/self/{status,smaps_rollup}`. +//! +//! Two modes: +//! * `--child --roster N` builds one roster in a **fresh process**, warms up, +//! settles, and prints one [`ProcSample`] JSON line. This is the isolated +//! measured workload. +//! * default (parent) re-execs itself `--repeat` times per roster size to get +//! independent cold samples, aggregates, writes the raw JSON report +//! (`--out`), and prints a human summary. +//! +//! Gated behind the default-OFF `rss-bench` feature so no benchmark code enters +//! the shipped desktop/library build. Build & run: +//! `cargo build --release --features rss-bench --bin rss-bench`. +//! +//! The pure sampling/aggregation logic lives in +//! [`openhuman_core::openhuman::proc_metrics`]; this binary is the fixture + +//! process driver. + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use openhuman_core::openhuman::agent::dispatcher::NativeToolDispatcher; +use openhuman_core::openhuman::agent::Agent; +use openhuman_core::openhuman::inference::provider::{ + ChatRequest, ChatResponse, Provider, UsageInfo, +}; +use openhuman_core::openhuman::memory::{ + Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts, +}; +use openhuman_core::openhuman::proc_metrics::{ + self, BenchReport, ProcSample, RosterResult, REPORT_SCHEMA_VERSION, RSS_BUDGET_KIB, + RSS_HARD_CAP_KIB, +}; +use openhuman_core::openhuman::tools::{Tool, ToolResult}; +use std::path::PathBuf; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; +use tempfile::TempDir; + +/// Roster sizes measured by default: the 1-agent baseline and the +/// representative 8-agent company roster from #5046. +const DEFAULT_ROSTER_SIZES: &[usize] = &[1, 8]; +/// Fresh processes sampled per roster size (≥ 5 per the issue). +const DEFAULT_REPEAT: usize = 5; +/// Per-child wall-clock budget. A child does bounded work (build a roster, one +/// warm-up turn, a ≤2 s settle), so anything beyond this is a stall — kill it and +/// fail the run rather than letting one bad child block the whole benchmark until +/// the outer CI job timeout. +const CHILD_TIMEOUT: Duration = Duration::from_secs(120); + +/// Provider that never touches the network: returns a fixed assistant message +/// with a `stop` shape (no tool calls) so a turn completes in one round-trip. +struct MockProvider; + +#[async_trait] +impl Provider for MockProvider { + async fn chat_with_system( + &self, + _system_prompt: Option<&str>, + _message: &str, + _model: &str, + _temperature: f64, + ) -> Result { + Ok("ok".into()) + } + + async fn chat( + &self, + _request: ChatRequest<'_>, + _model: &str, + _temperature: f64, + ) -> Result { + Ok(ChatResponse { + text: Some("ok".into()), + tool_calls: vec![], + usage: Some(UsageInfo { + input_tokens: 8, + output_tokens: 2, + context_window: 8000, + charged_amount_usd: 0.0, + ..Default::default() + }), + reasoning_content: None, + }) + } +} + +/// Trivial host-supplied tool so the roster mirrors a real embedding (the host +/// injects its own tools). Never invoked — the provider returns no tool calls. +struct EchoTool; + +#[async_trait] +impl Tool for EchoTool { + fn name(&self) -> &str { + "echo" + } + + fn description(&self) -> &str { + "echo" + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ "type": "object" }) + } + + async fn execute(&self, _args: serde_json::Value) -> Result { + Ok(ToolResult::success("echo")) + } +} + +/// Zero-allocation no-op `Memory` for the fixture. +/// +/// The benchmark measures a bare agent under the OpenCompany embedding contract +/// (host supplies its own `Memory` over its context store), *not* a memory +/// store. `create_memory(MemoryConfig{ backend: "none", .. })` does **not** +/// select a no-op backend — it always builds a `UnifiedMemory` (SQLite + the +/// default cloud embedder), which would inflate the measured RSS with +/// memory-store setup. Injecting a real no-op keeps the reading on the agent +/// harness itself. +struct NoopMemory; + +#[async_trait] +impl Memory for NoopMemory { + fn name(&self) -> &str { + "noop" + } + async fn store( + &self, + _namespace: &str, + _key: &str, + _content: &str, + _category: MemoryCategory, + _session_id: Option<&str>, + ) -> Result<()> { + Ok(()) + } + async fn recall( + &self, + _query: &str, + _limit: usize, + _opts: RecallOpts<'_>, + ) -> Result> { + Ok(Vec::new()) + } + async fn get(&self, _namespace: &str, _key: &str) -> Result> { + Ok(None) + } + async fn list( + &self, + _namespace: Option<&str>, + _category: Option<&MemoryCategory>, + _session_id: Option<&str>, + ) -> Result> { + Ok(Vec::new()) + } + async fn forget(&self, _namespace: &str, _key: &str) -> Result { + Ok(false) + } + async fn namespace_summaries(&self) -> Result> { + Ok(Vec::new()) + } + async fn count(&self) -> Result { + Ok(0) + } + async fn health_check(&self) -> bool { + true + } +} + +/// A built roster plus the temp workspaces that must outlive it — dropping the +/// `TempDir`s would delete the agents' workspaces mid-measurement. +struct Roster { + agents: Vec, + _workspaces: Vec, +} + +/// Build `n` bare agents, each with its own temp workspace, mock provider, +/// `"none"` memory backend, and a single host-supplied tool. +fn build_roster(n: usize) -> Result { + let mut agents = Vec::with_capacity(n); + let mut workspaces = Vec::with_capacity(n); + for i in 0..n { + let workspace = TempDir::new().context("create temp workspace")?; + let path = workspace.path().to_path_buf(); + + let memory: Arc = Arc::new(NoopMemory); + + let agent = Agent::builder() + .provider(Box::new(MockProvider)) + .tools(vec![Box::new(EchoTool)]) + .memory(memory) + .tool_dispatcher(Box::new(NativeToolDispatcher)) + .model_name("bench-mock".into()) + .agent_definition_name(format!("bench-{i}")) + .workspace_dir(path.clone()) + .action_dir(path) + .auto_save(false) + .build() + .context("build bench agent")?; + agents.push(agent); + workspaces.push(workspace); + } + Ok(Roster { + agents, + _workspaces: workspaces, + }) +} + +/// One deterministic warm-up turn per agent, forcing first-touch allocations +/// (prompt build, tokenizer, provider adapter) to fault in before measuring. +async fn warm_up(roster: &mut Roster) -> Result<()> { + for agent in &mut roster.agents { + let _ = agent.turn("warmup").await.context("warm-up turn")?; + } + Ok(()) +} + +/// Poll RSS until it stops climbing (Δ < 256 KiB between reads ~200 ms apart) +/// or a 2 s cap, draining async task-allocation jitter. No-op off Linux. +async fn settle() { + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + let mut last = proc_metrics::sample_self().map(|s| s.rss_kib).unwrap_or(0); + loop { + tokio::time::sleep(Duration::from_millis(200)).await; + let now = proc_metrics::sample_self() + .map(|s| s.rss_kib) + .unwrap_or(last); + if now.abs_diff(last) < 256 || tokio::time::Instant::now() >= deadline { + break; + } + last = now; + } +} + +/// Child mode: build one roster in this fresh process, warm up, settle, sample, +/// and print the sample as a single JSON line on stdout. +async fn run_child(roster_size: usize) -> Result<()> { + // Diagnostics go to stderr so they never corrupt the single JSON line the + // parent parses from stdout. + eprintln!("[rss-bench] child: building {roster_size}-agent roster"); + let mut roster = build_roster(roster_size)?; + eprintln!("[rss-bench] child: warming up {roster_size} agent(s)"); + warm_up(&mut roster).await?; + eprintln!("[rss-bench] child: settling"); + settle().await; + let sample = proc_metrics::sample_self()?; + eprintln!( + "[rss-bench] child: sampled rss={}KiB threads={}", + sample.rss_kib, sample.threads + ); + println!("{}", serde_json::to_string(&sample)?); + drop(roster); // keep the roster alive until after the sample is taken + Ok(()) +} + +/// Parent mode: re-exec the child `repeat` times per roster size, aggregate, and +/// write the report. +async fn run_parent(out: Option, repeat: usize, roster_sizes: &[usize]) -> Result<()> { + let exe = std::env::current_exe().context("resolve current exe")?; + let mut rosters = Vec::with_capacity(roster_sizes.len()); + for &size in roster_sizes { + let mut samples = Vec::with_capacity(repeat); + for run in 0..repeat { + eprintln!("[rss-bench] spawn child roster={size} run={run}"); + // `kill_on_drop` + a `timeout` around `wait_with_output` gives a + // robust kill-and-reap: on timeout the cancelled future drops the + // child, `kill_on_drop` sends SIGKILL, and the tokio runtime reaps it. + let child = tokio::process::Command::new(&exe) + .arg("--child") + .arg("--roster") + .arg(size.to_string()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .with_context(|| format!("spawn child roster={size} run={run}"))?; + let output = match tokio::time::timeout(CHILD_TIMEOUT, child.wait_with_output()).await { + Ok(res) => res.with_context(|| format!("await child roster={size} run={run}"))?, + Err(_) => { + eprintln!( + "[rss-bench] child roster={size} run={run} timed out after {}s; killed", + CHILD_TIMEOUT.as_secs() + ); + anyhow::bail!( + "child roster={size} run={run} timed out after {}s", + CHILD_TIMEOUT.as_secs() + ); + } + }; + if !output.status.success() { + eprintln!("[rss-bench] child roster={size} run={run} exited non-zero"); + anyhow::bail!( + "child roster={size} run={run} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let line = stdout + .lines() + .rev() + .find(|l| !l.trim().is_empty()) + .unwrap_or("") + .trim(); + let sample: ProcSample = serde_json::from_str(line) + .with_context(|| format!("parse child sample (roster={size}): {line:?}"))?; + eprintln!( + "[rss-bench] child roster={size} run={run} ok rss={}KiB", + sample.rss_kib + ); + samples.push(sample); + } + rosters.push(RosterResult::from_samples(size, samples)); + } + + let report = BenchReport { + schema_version: REPORT_SCHEMA_VERSION, + git_sha: git_sha(), + kernel: kernel(), + rss_budget_kib: RSS_BUDGET_KIB, + rss_hard_cap_kib: RSS_HARD_CAP_KIB, + rosters, + }; + + if let Some(path) = out { + std::fs::write(&path, serde_json::to_string_pretty(&report)?) + .with_context(|| format!("write report to {}", path.display()))?; + } + println!("{}", proc_metrics::human_summary(&report)); + Ok(()) +} + +/// Best-effort commit id for the report header (`GITHUB_SHA` in CI). +fn git_sha() -> String { + std::env::var("GITHUB_SHA").unwrap_or_else(|_| "unknown".into()) +} + +/// Best-effort kernel version for the report header. +fn kernel() -> String { + std::fs::read_to_string("/proc/sys/kernel/osrelease") + .map(|s| s.trim().to_string()) + .unwrap_or_else(|_| std::env::consts::OS.to_string()) +} + +/// Minimal flag parse — avoids a clap dependency for four flags. +struct Args { + child: bool, + roster: usize, + repeat: usize, + out: Option, +} + +fn parse_args() -> Result { + let mut child = false; + let mut roster = 1usize; + let mut repeat = DEFAULT_REPEAT; + let mut out = None; + let mut it = std::env::args().skip(1); + while let Some(arg) = it.next() { + match arg.as_str() { + "--child" => child = true, + "--roster" => { + roster = it + .next() + .context("--roster needs a value")? + .parse() + .context("--roster value")?; + } + "--repeat" => { + repeat = it + .next() + .context("--repeat needs a value")? + .parse() + .context("--repeat value")?; + } + "--out" => out = Some(PathBuf::from(it.next().context("--out needs a path")?)), + other => anyhow::bail!("unknown argument: {other}"), + } + } + Ok(Args { + child, + roster, + repeat, + out, + }) +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = parse_args()?; + if args.child { + run_child(args.roster).await + } else { + run_parent(args.out, args.repeat, DEFAULT_ROSTER_SIZES).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_roster_constructs_bare_agents_with_isolated_workspaces() { + let roster = build_roster(8).expect("8-agent roster builds"); + assert_eq!(roster.agents.len(), 8); + assert_eq!(roster._workspaces.len(), 8); + // Each agent got a distinct workspace directory. + let mut dirs: Vec<_> = roster + ._workspaces + .iter() + .map(|w| w.path().to_path_buf()) + .collect(); + dirs.sort(); + dirs.dedup(); + assert_eq!(dirs.len(), 8, "workspaces must be isolated per agent"); + } + + #[tokio::test] + async fn warm_up_turn_completes_without_network() { + let mut roster = build_roster(1).expect("1-agent roster builds"); + warm_up(&mut roster).await.expect("warm-up turn completes"); + // The mock provider reports usage, so last_turn_usage is populated — + // proving the embedding cost-metering contract works on the bare Agent. + assert!( + roster.agents[0].last_turn_usage().is_some(), + "usage should be readable after a turn" + ); + } +} diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 3bff808817..72ac30aef8 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -96,6 +96,7 @@ pub mod orchestration; pub mod overlay; pub mod people; pub mod plan_review; +pub mod proc_metrics; pub mod profiles; pub mod prompt_injection; pub mod provider_surfaces; diff --git a/src/openhuman/proc_metrics/mod.rs b/src/openhuman/proc_metrics/mod.rs new file mode 100644 index 0000000000..44b0365310 --- /dev/null +++ b/src/openhuman/proc_metrics/mod.rs @@ -0,0 +1,446 @@ +//! Process memory sampling from Linux `/proc`. +//! +//! Reads the current process's resident-memory breakdown from +//! `/proc/self/smaps_rollup` + `/proc/self/status` and aggregates repeated +//! samples into a [`RosterResult`] / [`BenchReport`]. Written for the +//! `rss-bench` benchmark harness (#5046), which measures the steady-state RSS +//! of an embedded `openhuman_core` agent roster against the 20–30 MiB budget, +//! but [`sample_self`] is a general capability: any caller wanting this +//! process's RSS / PSS / private-page / peak-RSS figures on Linux can use it. +//! +//! The parsers ([`parse_status`], [`parse_smaps_rollup`]) are OS-agnostic and +//! take `&str`, so they are unit-tested without a live `/proc`. [`sample_self`] +//! is Linux-only and returns a structured error elsewhere — it never fabricates +//! a reading (a macOS local run fails loudly rather than emitting garbage). + +use serde::{Deserialize, Serialize}; + +/// Product budget for the embedded roster, in KiB (#5046). Target the agent +/// roster should land under. +pub const RSS_BUDGET_KIB: u64 = 20 * 1024; +/// Hard cap for the embedded roster, in KiB (#5046). Steady-state RSS above +/// this fails the (eventually blocking) CI gate. +pub const RSS_HARD_CAP_KIB: u64 = 30 * 1024; + +/// One resident-memory sample of a single process. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProcSample { + /// Resident set size (`/proc/self/status` `VmRSS`). + pub rss_kib: u64, + /// Proportional set size (`/proc/self/smaps_rollup` `Pss`). + pub pss_kib: u64, + /// Private clean pages (`smaps_rollup` `Private_Clean`). + pub private_clean_kib: u64, + /// Private dirty pages (`smaps_rollup` `Private_Dirty`). + pub private_dirty_kib: u64, + /// Peak resident set size (`status` `VmHWM`). + pub vm_hwm_kib: u64, + /// Live thread count (`status` `Threads`). + pub threads: u64, + /// On-disk size of the running executable, in bytes. + pub binary_size_bytes: u64, +} + +/// Fields extracted from `/proc//status`. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct StatusFields { + pub vm_rss_kib: u64, + pub vm_hwm_kib: u64, + pub threads: u64, +} + +/// Fields extracted from `/proc//smaps_rollup`. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct SmapsRollupFields { + pub pss_kib: u64, + pub private_clean_kib: u64, + pub private_dirty_kib: u64, +} + +/// First whitespace-separated integer in a `/proc` value tail +/// (e.g. `"\t 1234 kB"` → `1234`). Zero when absent or unparsable. +fn first_u64(rest: &str) -> u64 { + rest.split_whitespace() + .next() + .and_then(|token| token.parse().ok()) + .unwrap_or(0) +} + +/// Parse the `VmRSS` / `VmHWM` / `Threads` lines out of `/proc//status`. +/// Missing keys stay zero. OS-agnostic — feed it the file contents. +pub fn parse_status(contents: &str) -> StatusFields { + let mut fields = StatusFields::default(); + for line in contents.lines() { + if let Some(rest) = line.strip_prefix("VmRSS:") { + fields.vm_rss_kib = first_u64(rest); + } else if let Some(rest) = line.strip_prefix("VmHWM:") { + fields.vm_hwm_kib = first_u64(rest); + } else if let Some(rest) = line.strip_prefix("Threads:") { + fields.threads = first_u64(rest); + } + } + fields +} + +/// Parse the `Pss` / `Private_Clean` / `Private_Dirty` lines out of +/// `/proc//smaps_rollup` (the pre-summed variant of `smaps`). Missing keys +/// stay zero. `strip_prefix` with the trailing colon avoids matching the +/// `Pss_Anon:` / `Pss_Dirty:` breakdown lines. +pub fn parse_smaps_rollup(contents: &str) -> SmapsRollupFields { + let mut fields = SmapsRollupFields::default(); + for line in contents.lines() { + if let Some(rest) = line.strip_prefix("Pss:") { + fields.pss_kib = first_u64(rest); + } else if let Some(rest) = line.strip_prefix("Private_Clean:") { + fields.private_clean_kib = first_u64(rest); + } else if let Some(rest) = line.strip_prefix("Private_Dirty:") { + fields.private_dirty_kib = first_u64(rest); + } + } + fields +} + +/// Sample this process's resident memory. Linux-only. +#[cfg(target_os = "linux")] +pub fn sample_self() -> anyhow::Result { + use anyhow::Context; + let status = std::fs::read_to_string("/proc/self/status").context("read /proc/self/status")?; + let smaps = std::fs::read_to_string("/proc/self/smaps_rollup") + .context("read /proc/self/smaps_rollup")?; + let status = parse_status(&status); + let smaps = parse_smaps_rollup(&smaps); + let binary_size_bytes = std::env::current_exe() + .and_then(std::fs::metadata) + .map(|meta| meta.len()) + .unwrap_or(0); + Ok(ProcSample { + rss_kib: status.vm_rss_kib, + pss_kib: smaps.pss_kib, + private_clean_kib: smaps.private_clean_kib, + private_dirty_kib: smaps.private_dirty_kib, + vm_hwm_kib: status.vm_hwm_kib, + threads: status.threads, + binary_size_bytes, + }) +} + +/// Sample this process's resident memory. Non-Linux stub — fails loudly rather +/// than fabricating a reading. +#[cfg(not(target_os = "linux"))] +pub fn sample_self() -> anyhow::Result { + anyhow::bail!( + "proc_metrics::sample_self requires Linux /proc/self/smaps_rollup + status (this is a {} build)", + std::env::consts::OS + ) +} + +/// Median of a slice of `u64`, averaging the two middle values for even counts. +/// Empty input yields zero. +fn median_u64(values: &[u64]) -> u64 { + if values.is_empty() { + return 0; + } + let mut sorted = values.to_vec(); + sorted.sort_unstable(); + let mid = sorted.len() / 2; + if sorted.len() % 2 == 1 { + sorted[mid] + } else { + // Average without overflow. + sorted[mid - 1] + (sorted[mid] - sorted[mid - 1]) / 2 + } +} + +/// Aggregated result for one roster size across several fresh-process samples. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RosterResult { + pub roster_size: usize, + pub sample_count: usize, + pub median_rss_kib: u64, + pub min_rss_kib: u64, + pub max_rss_kib: u64, + pub mean_rss_kib: u64, + pub median_pss_kib: u64, + pub max_vm_hwm_kib: u64, + pub median_threads: u64, + pub binary_size_bytes: u64, + /// The raw per-process samples, retained as a CI artifact. + pub samples: Vec, +} + +impl RosterResult { + /// Aggregate raw samples into the reported statistics. RSS is summarised as + /// median (steady-state), min/max/mean for spread; PSS/threads as median; + /// `VmHWM` as the max (peak) across processes. + pub fn from_samples(roster_size: usize, samples: Vec) -> Self { + let rss: Vec = samples.iter().map(|s| s.rss_kib).collect(); + let pss: Vec = samples.iter().map(|s| s.pss_kib).collect(); + let threads: Vec = samples.iter().map(|s| s.threads).collect(); + let count = samples.len(); + let mean_rss_kib = if count == 0 { + 0 + } else { + rss.iter().sum::() / count as u64 + }; + Self { + roster_size, + sample_count: count, + median_rss_kib: median_u64(&rss), + min_rss_kib: rss.iter().copied().min().unwrap_or(0), + max_rss_kib: rss.iter().copied().max().unwrap_or(0), + mean_rss_kib, + median_pss_kib: median_u64(&pss), + max_vm_hwm_kib: samples.iter().map(|s| s.vm_hwm_kib).max().unwrap_or(0), + median_threads: median_u64(&threads), + binary_size_bytes: samples.first().map(|s| s.binary_size_bytes).unwrap_or(0), + samples, + } + } +} + +/// The full benchmark report, serialized to the raw JSON CI artifact. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BenchReport { + pub schema_version: u32, + pub git_sha: String, + pub kernel: String, + pub rss_budget_kib: u64, + pub rss_hard_cap_kib: u64, + pub rosters: Vec, +} + +/// Current schema version for [`BenchReport`]; bump on any field change so +/// downstream trend tooling can detect format shifts. +pub const REPORT_SCHEMA_VERSION: u32 = 1; + +impl BenchReport { + /// Marginal steady-state RSS cost of each additional agent (#5046), derived + /// from the smallest and largest rosters measured: + /// `(median_rss(max) - median_rss(min)) / (max_size - min_size)`. + /// + /// Returns `(min_roster_size, max_roster_size, kib_per_agent)`, or `None` when + /// fewer than two distinct roster sizes were measured (no incremental cost is + /// derivable). For the default `{1, 8}` rosters this is the per-agent cost of + /// agents 2–8. + pub fn per_agent_increment_kib(&self) -> Option<(usize, usize, u64)> { + let min = self.rosters.iter().min_by_key(|r| r.roster_size)?; + let max = self.rosters.iter().max_by_key(|r| r.roster_size)?; + let span = max.roster_size.checked_sub(min.roster_size)?; + if span == 0 { + return None; + } + let per_agent = max.median_rss_kib.saturating_sub(min.median_rss_kib) / span as u64; + Some((min.roster_size, max.roster_size, per_agent)) + } +} + +fn kib_to_mib(kib: u64) -> f64 { + kib as f64 / 1024.0 +} + +/// Human-readable Markdown summary for stdout + `$GITHUB_STEP_SUMMARY`. +pub fn human_summary(report: &BenchReport) -> String { + use std::fmt::Write as _; + let mut out = String::new(); + let _ = writeln!(out, "### Embedded `openhuman_core` RSS benchmark (#5046)"); + let _ = writeln!(out); + let _ = writeln!( + out, + "kernel `{}` · git `{}` · target ≤ {:.0} MiB · hard cap ≤ {:.0} MiB", + report.kernel, + report.git_sha, + kib_to_mib(report.rss_budget_kib), + kib_to_mib(report.rss_hard_cap_kib), + ); + let _ = writeln!(out); + let _ = writeln!( + out, + "| roster | n | median RSS | min–max RSS | median PSS | peak VmHWM | threads | binary |" + ); + let _ = writeln!( + out, + "| ------ | - | ---------- | ----------- | ---------- | ---------- | ------- | ------ |" + ); + for r in &report.rosters { + let over = if r.median_rss_kib > report.rss_hard_cap_kib { + " ⚠️" + } else { + "" + }; + let _ = writeln!( + out, + "| {} agent{} | {} | {:.1} MiB{} | {:.1}–{:.1} MiB | {:.1} MiB | {:.1} MiB | {} | {:.1} MiB |", + r.roster_size, + if r.roster_size == 1 { "" } else { "s" }, + r.sample_count, + kib_to_mib(r.median_rss_kib), + over, + kib_to_mib(r.min_rss_kib), + kib_to_mib(r.max_rss_kib), + kib_to_mib(r.median_pss_kib), + kib_to_mib(r.max_vm_hwm_kib), + r.median_threads, + r.binary_size_bytes as f64 / (1024.0 * 1024.0), + ); + } + if let Some((min_size, max_size, per_agent_kib)) = report.per_agent_increment_kib() { + let _ = writeln!(out); + let _ = writeln!( + out, + "Per-agent increment (roster {min_size}→{max_size}): {:.2} MiB/agent", + kib_to_mib(per_agent_kib), + ); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_STATUS: &str = "Name:\trss-bench\nVmPeak:\t 123456 kB\nVmRSS:\t 20480 kB\nVmHWM:\t 24576 kB\nThreads:\t8\n"; + + const SAMPLE_SMAPS_ROLLUP: &str = "00400000-7fff00000000 ---p 00000000 00:00 0 [rollup]\nRss:\t 20480 kB\nPss:\t 18000 kB\nPss_Anon:\t 1000 kB\nPss_Dirty:\t 500 kB\nPrivate_Clean:\t 4096 kB\nPrivate_Dirty:\t 12000 kB\n"; + + #[test] + fn parse_status_extracts_rss_hwm_threads() { + let f = parse_status(SAMPLE_STATUS); + assert_eq!(f.vm_rss_kib, 20480); + assert_eq!(f.vm_hwm_kib, 24576); + assert_eq!(f.threads, 8); + } + + #[test] + fn parse_status_missing_keys_stay_zero() { + let f = parse_status("Name:\tx\nState:\tR\n"); + assert_eq!(f, StatusFields::default()); + } + + #[test] + fn parse_smaps_rollup_extracts_pss_and_private_pages() { + let f = parse_smaps_rollup(SAMPLE_SMAPS_ROLLUP); + assert_eq!(f.pss_kib, 18000); + assert_eq!(f.private_clean_kib, 4096); + assert_eq!(f.private_dirty_kib, 12000); + } + + #[test] + fn parse_smaps_rollup_does_not_match_pss_breakdown_lines() { + // `Pss_Anon:` / `Pss_Dirty:` must not be read as `Pss:`. + let f = parse_smaps_rollup("Pss_Anon:\t 9999 kB\nPss_Dirty:\t 8888 kB\n"); + assert_eq!(f.pss_kib, 0); + } + + fn sample(rss: u64, pss: u64, hwm: u64, threads: u64) -> ProcSample { + ProcSample { + rss_kib: rss, + pss_kib: pss, + private_clean_kib: 0, + private_dirty_kib: 0, + vm_hwm_kib: hwm, + threads, + binary_size_bytes: 1024, + } + } + + #[test] + fn median_handles_odd_and_even() { + assert_eq!(median_u64(&[]), 0); + assert_eq!(median_u64(&[5]), 5); + assert_eq!(median_u64(&[3, 1, 2]), 2); + assert_eq!(median_u64(&[1, 2, 3, 4]), 2); // (2+3)/2 floored -> 2 + } + + #[test] + fn from_samples_aggregates_rss_pss_and_peak() { + let samples = vec![ + sample(20000, 18000, 21000, 8), + sample(22000, 19000, 26000, 8), + sample(21000, 18500, 24000, 8), + ]; + let r = RosterResult::from_samples(8, samples); + assert_eq!(r.roster_size, 8); + assert_eq!(r.sample_count, 3); + assert_eq!(r.median_rss_kib, 21000); + assert_eq!(r.min_rss_kib, 20000); + assert_eq!(r.max_rss_kib, 22000); + assert_eq!(r.mean_rss_kib, 21000); + assert_eq!(r.max_vm_hwm_kib, 26000); // peak across processes + assert_eq!(r.median_threads, 8); + } + + #[test] + fn report_serde_round_trips() { + let report = BenchReport { + schema_version: REPORT_SCHEMA_VERSION, + git_sha: "abc123".into(), + kernel: "6.1.0".into(), + rss_budget_kib: RSS_BUDGET_KIB, + rss_hard_cap_kib: RSS_HARD_CAP_KIB, + rosters: vec![RosterResult::from_samples( + 1, + vec![sample(15000, 14000, 16000, 6)], + )], + }; + let json = serde_json::to_string(&report).unwrap(); + let back: BenchReport = serde_json::from_str(&json).unwrap(); + assert_eq!(back.rosters.len(), 1); + assert_eq!(back.rosters[0].samples[0], report.rosters[0].samples[0]); + assert_eq!(back.rss_hard_cap_kib, RSS_HARD_CAP_KIB); + } + + #[test] + fn human_summary_flags_over_cap_roster() { + let report = BenchReport { + schema_version: REPORT_SCHEMA_VERSION, + git_sha: "deadbeef".into(), + kernel: "6.1.0".into(), + rss_budget_kib: RSS_BUDGET_KIB, + rss_hard_cap_kib: RSS_HARD_CAP_KIB, + rosters: vec![RosterResult::from_samples( + 8, + vec![sample(40000, 30000, 42000, 12)], + )], + }; + let summary = human_summary(&report); + assert!(summary.contains("8 agents")); + assert!(summary.contains("⚠️"), "over-cap roster must be flagged"); + } + + #[test] + fn per_agent_increment_from_min_and_max_rosters() { + let report = BenchReport { + schema_version: REPORT_SCHEMA_VERSION, + git_sha: "x".into(), + kernel: "6.1.0".into(), + rss_budget_kib: RSS_BUDGET_KIB, + rss_hard_cap_kib: RSS_HARD_CAP_KIB, + rosters: vec![ + RosterResult::from_samples(1, vec![sample(20_000, 0, 0, 6)]), + RosterResult::from_samples(8, vec![sample(27_000, 0, 0, 6)]), + ], + }; + // (27000 - 20000) / (8 - 1) = 1000 KiB per agent. + assert_eq!(report.per_agent_increment_kib(), Some((1, 8, 1000))); + assert!(human_summary(&report).contains("Per-agent increment (roster 1→8)")); + } + + #[test] + fn per_agent_increment_none_for_single_roster() { + let report = BenchReport { + schema_version: REPORT_SCHEMA_VERSION, + git_sha: "x".into(), + kernel: "6.1.0".into(), + rss_budget_kib: RSS_BUDGET_KIB, + rss_hard_cap_kib: RSS_HARD_CAP_KIB, + rosters: vec![RosterResult::from_samples(1, vec![sample(20_000, 0, 0, 6)])], + }; + assert_eq!(report.per_agent_increment_kib(), None); + } + + #[cfg(not(target_os = "linux"))] + #[test] + fn sample_self_is_linux_only() { + assert!(sample_self().is_err()); + } +} From 4019a9a18b1e907a9903104ffd79abaad6f73799 Mon Sep 17 00:00:00 2001 From: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:33:22 +0530 Subject: [PATCH 15/56] feat(core): gate the desktop-automation cluster behind a default-ON feature (#5049) (#5061) Co-authored-by: Steven Enamakel <31011319+senamakel@users.noreply.github.com> --- .github/workflows/ci-lite.yml | 3 +- Cargo.toml | 27 +- app/src-tauri/Cargo.toml | 1 + src/core/all.rs | 7 +- src/core/all_tests.rs | 36 +++ src/core/autocomplete_cli_adapter.rs | 26 +- src/core/cli_tests.rs | 3 + src/core/jsonrpc_tests.rs | 4 + src/core/legacy_aliases.rs | 32 ++- src/core/runtime/builder.rs | 10 + src/openhuman/accessibility/globe.rs | 28 +- src/openhuman/accessibility/mod.rs | 66 ++++- src/openhuman/accessibility/stub.rs | 248 ++++++++++++++++++ src/openhuman/accessibility/types.rs | 23 ++ src/openhuman/autocomplete/core/engine.rs | 14 +- src/openhuman/autocomplete/core/mod.rs | 12 +- src/openhuman/autocomplete/core/text.rs | 2 +- src/openhuman/autocomplete/mod.rs | 37 +++ src/openhuman/autocomplete/stub.rs | 79 ++++++ .../autocomplete/{core => }/types.rs | 7 +- src/openhuman/desktop_companion/mod.rs | 24 +- src/openhuman/desktop_companion/stub.rs | 20 ++ src/openhuman/screen_intelligence/mod.rs | 43 ++- src/openhuman/screen_intelligence/stub.rs | 178 +++++++++++++ src/openhuman/tools/impl/mod.rs | 6 + src/openhuman/tools/impl/system/mod.rs | 5 +- src/openhuman/tools/mod.rs | 1 + src/openhuman/tools/ops.rs | 41 ++- src/openhuman/tools/ops_tests.rs | 55 ++++ 29 files changed, 975 insertions(+), 63 deletions(-) create mode 100644 src/openhuman/accessibility/stub.rs create mode 100644 src/openhuman/autocomplete/stub.rs rename src/openhuman/autocomplete/{core => }/types.rs (88%) create mode 100644 src/openhuman/desktop_companion/stub.rs create mode 100644 src/openhuman/screen_intelligence/stub.rs diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index 4cfd70c64d..6697cb8f02 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -443,6 +443,7 @@ jobs: set -euo pipefail EXPECTED=$(cat <<'EOF' core/all_tests.rs + core/autocomplete_cli_adapter.rs core/cli_tests.rs core/jsonrpc_tests.rs core/legacy_aliases.rs @@ -462,7 +463,7 @@ jobs: openhuman/x402/stub.rs EOF ) - ACTUAL=$(grep -rlE '#\[cfg\((not\()?feature = "(voice|media|web3|meet|mcp|skills|flows)"' src --include='*.rs' \ + ACTUAL=$(grep -rlE '#\[cfg\((not\()?feature = "(voice|media|web3|meet|mcp|skills|flows|desktop-automation)"' src --include='*.rs' \ | xargs grep -lE '#\[test\]|#\[tokio::test\]|fn .*_test' 2>/dev/null | sed 's|^src/||' | sort -u) if ! diff <(echo "$EXPECTED" | sed 's/^ *//' | sort -u) <(echo "$ACTUAL"); then echo "::error::Gated-test file set changed. Update the EXPECTED allowlist in the rust-feature-gate-smoke lane, and extend the scoped 'cargo test' filter if the new module can carry an ungated-assert regression (see #5022)." diff --git a/Cargo.toml b/Cargo.toml index 4dcbbf1914..c3251f5794 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -297,7 +297,7 @@ windows-sys = { version = "0.61", features = [ # Microsoft UI Automation (UIA) bindings — the Windows backend for the # `ax_interact` tool (`accessibility::uia_interact`). Safe Rust wrappers over # the UIA COM API; the Windows analogue of the macOS AXUIElement Swift helper. -uiautomation = "0.25" +uiautomation = { version = "0.25", optional = true } [target.'cfg(not(windows))'.dependencies] # macOS / Linux: keep rustls + Mozilla webpki-roots — the historical @@ -338,7 +338,7 @@ tokio = { version = "1", features = ["test-util"] } proptest = "1" [features] -default = ["tokenjuice-treesitter", "voice", "web3", "media", "meet", "skills", "flows", "mcp", "tui"] +default = ["tokenjuice-treesitter", "voice", "web3", "media", "meet", "skills", "flows", "mcp", "desktop-automation", "tui"] # 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 = [ @@ -449,6 +449,28 @@ skills = [] # `sanitize::sanitize_for_llm`). The gate follows the real dependency graph, # not the directory name. mcp = [] + +# Desktop-automation cluster (#5049): the five modules that read/drive the local +# desktop UI — `openhuman::accessibility` (macOS AX / Windows UIA FFI middleware), +# `openhuman::screen_intelligence` (capture + vision loop), `openhuman::autocomplete` +# (inline completion), `openhuman::desktop_companion` (Clicky-style loop), and the +# `computer` agent-tool family (`ax_interact` / `automate` / mouse / keyboard). +# Default-ON — the desktop app always ships with these. Slim / headless builds opt +# out via `--no-default-features --features ""`, +# which drops the exclusive `uiautomation` (Windows UI Automation COM bindings) +# dependency. Composes with the runtime `DomainSet::desktop_automation` flag: the +# feature narrows the compile-time surface, `DomainSet` gates it at runtime. +# +# CARVE-OUT: the inert type modules stay compiled in BOTH builds — +# `accessibility::types` (incl. the `GlobeHotkey*` structs), `autocomplete::types` +# (`AutocompleteStatus`), and `screen_intelligence::types` (`AccessibilityStatus`, +# `CaptureImageRefResult`) — because always-on callers (`text_input`, `voice`, +# `app_state`) name them. Only behaviour is gated; the stubs re-export the one real +# type definition. See AGENTS.md "skills gate — the type carve-out". +# +# NOTE: only `uiautomation` is exclusive and thus shed. `enigo` is shared with the +# `voice` domain; `rdev` / `arboard` are voice-owned — none of those are dropped here. +desktop-automation = ["dep:uiautomation"] # Terminal chat UI: the `openhuman tui` (alias `chat`) CLI subcommand, a # ratatui/crossterm terminal front-end onto the same `web_chat` surface the # desktop app drives. Default-ON for the standalone `openhuman-core` binary, but @@ -462,6 +484,7 @@ mcp = [] # build-fact "tui feature disabled at compile time" error from the untouched # `"tui" | "chat"` CLI arm (mirrors the `mcp` stub pattern). tui = ["dep:ratatui", "dep:crossterm", "dep:unicode-width"] + 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 16fe16764f..97dd397d4e 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -169,6 +169,7 @@ openhuman_core = { path = "../..", package = "openhuman", default-features = fal "meet", "skills", "mcp", + "desktop-automation", ] } tinyjuice = { version = "0.2.1", default-features = false } diff --git a/src/core/all.rs b/src/core/all.rs index 6fd7fd648e..9374b78bcf 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -82,6 +82,7 @@ pub enum DomainGroup { Web3, Voice, Media, + DesktopAutomation, // Everything not in a named family — always on in `full()`, off otherwise. Platform, } @@ -375,7 +376,7 @@ fn build_registered_controllers() -> Vec { // Inline autocomplete settings push( &mut controllers, - DomainGroup::Platform, + DomainGroup::DesktopAutomation, crate::openhuman::autocomplete::all_autocomplete_registered_controllers(), ); // External messaging channels (Web, Telegram, etc.) @@ -464,7 +465,7 @@ fn build_registered_controllers() -> Vec { // Screen capture and UI analysis push( &mut controllers, - DomainGroup::Platform, + DomainGroup::DesktopAutomation, crate::openhuman::screen_intelligence::all_screen_intelligence_registered_controllers(), ); // Sandbox execution backends (Docker, local jail, policy, cleanup) @@ -735,7 +736,7 @@ fn build_registered_controllers() -> Vec { // Desktop companion — Clicky-style interaction loop. push( &mut controllers, - DomainGroup::Platform, + DomainGroup::DesktopAutomation, crate::openhuman::desktop_companion::all_desktop_companion_registered_controllers(), ); // Structured WhatsApp Web data — agent-facing read-only controllers (list/search). diff --git a/src/core/all_tests.rs b/src/core/all_tests.rs index 5667eabeb6..cdad5265ce 100644 --- a/src/core/all_tests.rs +++ b/src/core/all_tests.rs @@ -1094,3 +1094,39 @@ fn meet_controllers_absent_when_feature_off() { ); } } + +/// All three desktop-automation namespaces register under +/// `DomainGroup::DesktopAutomation` when the `desktop-automation` feature is on +/// (#5049). Paired with `desktop_automation_controllers_absent_when_feature_off` +/// below: together they pin both directions of the compile-time gate. +#[cfg(feature = "desktop-automation")] +#[test] +fn desktop_automation_controllers_registered_when_feature_on() { + for ns in ["autocomplete", "screen_intelligence", "companion"] { + assert_eq!( + group_for_namespace(ns), + Some(DomainGroup::DesktopAutomation), + "`{ns}` must register under DomainGroup::DesktopAutomation when the \ + `desktop-automation` feature is on" + ); + } +} + +/// Negative half of the `desktop-automation` gate (#5049): with the cluster +/// compiled out, none of the `autocomplete` / `screen_intelligence` / `companion` +/// controllers register (unknown-method over `/rpc`, absent from `/schema`). +/// Pairs with `desktop_automation_controllers_registered_when_feature_on` above. +/// The `screen_intelligence_*` tool-absence half lives in +/// `tools::ops_tests::screen_intelligence_tools_absent_when_feature_off`, where +/// the full agent tool list can be built. +#[cfg(not(feature = "desktop-automation"))] +#[test] +fn desktop_automation_controllers_absent_when_feature_off() { + for ns in ["autocomplete", "screen_intelligence", "companion"] { + assert_eq!( + group_for_namespace(ns), + None, + "`{ns}` must not register when the `desktop-automation` feature is off" + ); + } +} diff --git a/src/core/autocomplete_cli_adapter.rs b/src/core/autocomplete_cli_adapter.rs index 53ba0488da..9271377850 100644 --- a/src/core/autocomplete_cli_adapter.rs +++ b/src/core/autocomplete_cli_adapter.rs @@ -5,6 +5,7 @@ use anyhow::Result; use crate::core::logging::CliLogDefault; +#[cfg(feature = "desktop-automation")] use crate::openhuman::autocomplete::ops::{autocomplete_start_cli, AutocompleteStartCliOptions}; pub struct NamespacePreparse { @@ -75,6 +76,7 @@ pub fn maybe_print_start_help(namespace: &str, function: &str) -> bool { } } +#[cfg(feature = "desktop-automation")] pub fn maybe_handle_namespace_start( namespace: &str, function: &str, @@ -94,7 +96,27 @@ pub fn maybe_handle_namespace_start( Ok(Some(value)) } +/// Disabled-build variant: the `autocomplete` engine is compiled out, so +/// `autocomplete start` reports the build fact rather than silently succeeding. +#[cfg(not(feature = "desktop-automation"))] +pub fn maybe_handle_namespace_start( + namespace: &str, + function: &str, + _args: &[String], +) -> Result> { + if namespace != "autocomplete" || function != "start" { + return Ok(None); + } + log::debug!( + "[autocomplete] `autocomplete start` rejected: desktop-automation disabled at compile time" + ); + Err(anyhow::anyhow!( + "autocomplete is disabled in this build (rebuild with --features desktop-automation)" + )) +} + /// Parses CLI options specific to the `autocomplete start` command. +#[cfg(feature = "desktop-automation")] fn parse_autocomplete_start_cli_options(args: &[String]) -> Result { let mut debounce_ms: Option = None; let mut serve = false; @@ -149,10 +171,10 @@ fn print_autocomplete_start_help() { #[cfg(test)] mod tests { - use super::parse_autocomplete_start_cli_options; - + #[cfg(feature = "desktop-automation")] #[test] fn parse_autocomplete_start_cli_options_rejects_serve_and_spawn() { + use super::parse_autocomplete_start_cli_options; let args = vec!["--serve".to_string(), "--spawn".to_string()]; let err = parse_autocomplete_start_cli_options(&args) .expect_err("must reject mutually exclusive flags"); diff --git a/src/core/cli_tests.rs b/src/core/cli_tests.rs index 2b8f94d63e..3354a333be 100644 --- a/src/core/cli_tests.rs +++ b/src/core/cli_tests.rs @@ -19,6 +19,9 @@ fn grouped_schemas_contains_migrated_namespaces() { assert!(grouped.contains_key("doctor")); assert!(grouped.contains_key("encrypt")); assert!(grouped.contains_key("decrypt")); + // `autocomplete` is gated behind `desktop-automation` (#5049); only present + // when that feature is enabled (it is in the default/shipped build). + #[cfg(feature = "desktop-automation")] assert!(grouped.contains_key("autocomplete")); assert!(grouped.contains_key("config")); assert!(grouped.contains_key("auth")); diff --git a/src/core/jsonrpc_tests.rs b/src/core/jsonrpc_tests.rs index c711fc3ace..8b189229a2 100644 --- a/src/core/jsonrpc_tests.rs +++ b/src/core/jsonrpc_tests.rs @@ -295,6 +295,10 @@ async fn invoke_config_get_runtime_flags_via_registry() { assert!(result.get("result").is_some()); } +// `autocomplete_*` is gated behind `desktop-automation` (#5049); with the gate +// off the controller is unregistered, so this param-validation test only applies +// when the feature is enabled (it is in the default/shipped build). +#[cfg(feature = "desktop-automation")] #[tokio::test] async fn invoke_autocomplete_status_rejects_unknown_param() { let err = invoke_method( diff --git a/src/core/legacy_aliases.rs b/src/core/legacy_aliases.rs index f08bf5850d..ba293b5d5d 100644 --- a/src/core/legacy_aliases.rs +++ b/src/core/legacy_aliases.rs @@ -364,16 +364,30 @@ mod tests { /// /// Mirrors how the agent loader tolerates the orchestrator TOML's dangling /// `mcp_agent` subagent id (#4799). - #[cfg(feature = "mcp")] - fn is_compiled_out_method(_method: &str) -> bool { - false - } - - #[cfg(not(feature = "mcp"))] fn is_compiled_out_method(method: &str) -> bool { - // `mcp` feature OFF ⇒ the `mcp_clients` (dynamic registry) and - // `mcp_audit` (write log) controllers are unregistered. - method.starts_with("openhuman.mcp_clients_") || method.starts_with("openhuman.mcp_audit_") + // Each compile-time gate contributes the RPC namespaces whose controllers + // it unregisters. The frontend catalog is data (it always names the full + // shipped surface), so a slim build must ignore exactly those methods and + // keep asserting on everything else. + #[cfg(not(feature = "mcp"))] + // `mcp` OFF ⇒ the `mcp_clients` (dynamic registry) + `mcp_audit` (write + // log) controllers are unregistered. + if method.starts_with("openhuman.mcp_clients_") + || method.starts_with("openhuman.mcp_audit_") + { + return true; + } + #[cfg(not(feature = "desktop-automation"))] + // `desktop-automation` OFF ⇒ the autocomplete / screen_intelligence / + // desktop-companion controllers are unregistered (#5049). + if method.starts_with("openhuman.autocomplete_") + || method.starts_with("openhuman.screen_intelligence_") + || method.starts_with("openhuman.companion_") + { + return true; + } + let _ = method; + false } #[test] diff --git a/src/core/runtime/builder.rs b/src/core/runtime/builder.rs index d0188971a4..8af72b7d30 100644 --- a/src/core/runtime/builder.rs +++ b/src/core/runtime/builder.rs @@ -160,6 +160,9 @@ pub struct DomainSet { /// future backing controller would stay live. Fold the media-generation /// controller into this group when it lands. pub media: bool, + /// Accessibility middleware, screen intelligence, inline autocomplete, the + /// desktop companion loop, and the `computer` agent-tool family. + pub desktop_automation: bool, /// Everything not in a named family — always on in `full()`. pub platform: bool, } @@ -182,6 +185,7 @@ impl DomainSet { web3: true, voice: true, media: true, + desktop_automation: true, platform: true, } } @@ -204,6 +208,7 @@ impl DomainSet { web3: false, voice: false, media: false, + desktop_automation: false, platform: false, } } @@ -224,6 +229,7 @@ impl DomainSet { web3: false, voice: false, media: false, + desktop_automation: false, platform: false, } } @@ -244,6 +250,7 @@ impl DomainSet { DomainGroup::Web3 => self.web3, DomainGroup::Voice => self.voice, DomainGroup::Media => self.media, + DomainGroup::DesktopAutomation => self.desktop_automation, DomainGroup::Platform => self.platform, } } @@ -594,6 +601,7 @@ mod tests { DomainGroup::Web3, DomainGroup::Voice, DomainGroup::Media, + DomainGroup::DesktopAutomation, DomainGroup::Platform, ] { assert!(full.allows(group), "full() must allow {group:?}"); @@ -620,6 +628,7 @@ mod tests { DomainGroup::Web3, DomainGroup::Voice, DomainGroup::Media, + DomainGroup::DesktopAutomation, DomainGroup::Platform, ] { assert!(!harness.allows(off), "harness() must NOT allow {off:?}"); @@ -641,6 +650,7 @@ mod tests { DomainGroup::Web3, DomainGroup::Voice, DomainGroup::Media, + DomainGroup::DesktopAutomation, DomainGroup::Platform, ] { assert!(!none.allows(group), "none() must NOT allow {group:?}"); diff --git a/src/openhuman/accessibility/globe.rs b/src/openhuman/accessibility/globe.rs index e5a440e39c..60ee75e3ff 100644 --- a/src/openhuman/accessibility/globe.rs +++ b/src/openhuman/accessibility/globe.rs @@ -3,7 +3,14 @@ //! The listener runs as a tiny Swift process that monitors `flagsChanged` //! events globally and reports `FN_DOWN` / `FN_UP` lines over stdout. -use super::{detect_permissions, PermissionState}; +use super::detect_permissions; +// `PermissionState` (the type) is named only by the macOS listener's permission +// check; the non-macOS stubs read `detect_permissions().input_monitoring` without +// naming the type. Gating the import keeps the Linux/Windows build warning-clean +// (the `GlobeHotkey*` structs that referenced it unconditionally now live in +// `super::types`). +#[cfg(target_os = "macos")] +use super::PermissionState; #[cfg(target_os = "macos")] use std::collections::VecDeque; @@ -23,20 +30,11 @@ use std::sync::{Arc, Mutex as StdMutex}; const LOG_PREFIX: &str = "[globe_hotkey]"; const MAX_PENDING_EVENTS: usize = 64; -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct GlobeHotkeyStatus { - pub supported: bool, - pub running: bool, - pub input_monitoring_permission: PermissionState, - pub last_error: Option, - pub events_pending: usize, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct GlobeHotkeyPollResult { - pub status: GlobeHotkeyStatus, - pub events: Vec, -} +// The inert status/result structs live in `types.rs` (dep-free, no FFI) so they +// stay compiled when `desktop-automation` is off. Re-export here so existing +// `accessibility::globe::{GlobeHotkeyStatus, GlobeHotkeyPollResult}` paths and +// the `accessibility::mod.rs` re-export keep resolving. +pub use super::types::{GlobeHotkeyPollResult, GlobeHotkeyStatus}; #[cfg(target_os = "macos")] struct GlobeListenerProcess { diff --git a/src/openhuman/accessibility/mod.rs b/src/openhuman/accessibility/mod.rs index f690fe577b..9ae78cf68c 100644 --- a/src/openhuman/accessibility/mod.rs +++ b/src/openhuman/accessibility/mod.rs @@ -4,66 +4,114 @@ //! Centralises all macOS AX/CGEvent/IOKit FFI and the unified Swift helper process. //! Consumer modules (autocomplete, screen_intelligence, voice) call into this module //! instead of owning platform-specific code directly. +//! +//! Facade for the `desktop-automation` gate (#5049): the FFI-bearing submodules are +//! `#[cfg(feature = "desktop-automation")]`; the inert `types` module (and the +//! `GlobeHotkey*` structs it now owns) stay compiled in both directions so the +//! always-on callers (`text_input`, `voice`, `app_state`, …) keep the one real type +//! definition. When the feature is off, `stub` re-exposes the public *behaviour* +//! surface those callers reach, with disabled-error / no-op / denied bodies. + +// Inert, dependency-free platform types — compiled in BOTH builds (type carve-out). +// The `GlobeHotkeyStatus` / `GlobeHotkeyPollResult` structs live here too (moved out +// of the FFI `globe` module, which re-exports them) so they resolve with the feature +// off. See AGENTS.md "skills gate — the type carve-out". +pub mod types; +#[cfg(feature = "desktop-automation")] pub mod app_fastpaths; +#[cfg(feature = "desktop-automation")] pub mod automate; +#[cfg(feature = "desktop-automation")] mod automation_state; +#[cfg(feature = "desktop-automation")] pub mod ax_interact; +#[cfg(feature = "desktop-automation")] mod capture; // Pure ranked/normalized matching of listed AX elements against a target label // — the "reliable UI element clicking" selection primitive (no FFI). Consumed by // `ax_interact.rs` to order filtered results best-first. +#[cfg(feature = "desktop-automation")] mod element_match; +#[cfg(feature = "desktop-automation")] mod focus; +#[cfg(feature = "desktop-automation")] mod globe; +#[cfg(feature = "desktop-automation")] mod helper; +#[cfg(feature = "desktop-automation")] mod keys; +#[cfg(feature = "desktop-automation")] mod overlay; +#[cfg(feature = "desktop-automation")] mod paste; +#[cfg(feature = "desktop-automation")] mod permissions; +#[cfg(feature = "desktop-automation")] mod terminal; +#[cfg(feature = "desktop-automation")] mod text_util; -mod types; // Vision fallback for `automate`: screenshot → vision-locate → guarded click, // for Electron/partial-AX apps. Consumed by `automate.rs`'s `RealBackend`. +#[cfg(feature = "desktop-automation")] mod vision_click; // Windows accessibility backend for `ax_interact` (UI Automation). Sibling of // the macOS Swift-helper path; selected via cfg-dispatch in `ax_interact.rs`. -#[cfg(target_os = "windows")] +#[cfg(all(feature = "desktop-automation", target_os = "windows"))] mod uia_interact; +#[cfg(not(feature = "desktop-automation"))] +mod stub; +#[cfg(not(feature = "desktop-automation"))] +pub use stub::*; + +#[cfg(feature = "desktop-automation")] pub use automation_state::{ clear as clear_automation_denial, mark_system_events_denied, system_events_denied, }; +#[cfg(feature = "desktop-automation")] pub use capture::{capture_screen_image_ref_for_context, CaptureMode, MAX_SCREENSHOT_BYTES}; +#[cfg(feature = "desktop-automation")] pub use element_match::{best_match, ElementMatch, MatchTier}; +#[cfg(feature = "desktop-automation")] pub use focus::{ focused_text_context, focused_text_context_verbose, foreground_context, parse_foreground_output, validate_focused_target, }; -pub use globe::{ - globe_listener_poll, globe_listener_start, globe_listener_stop, GlobeHotkeyPollResult, - GlobeHotkeyStatus, -}; +#[cfg(feature = "desktop-automation")] +pub use globe::{globe_listener_poll, globe_listener_start, globe_listener_stop}; +#[cfg(feature = "desktop-automation")] pub use helper::precompile_helper_background; +#[cfg(feature = "desktop-automation")] pub use keys::{any_modifier_down, is_escape_key_down, is_tab_key_down}; +#[cfg(feature = "desktop-automation")] pub use overlay::{hide_overlay, quit_overlay, show_overlay}; +#[cfg(feature = "desktop-automation")] pub use paste::{apply_text_to_focused_field, send_backspace}; -#[cfg(target_os = "macos")] +#[cfg(all(feature = "desktop-automation", target_os = "macos"))] pub use permissions::{ detect_accessibility_permission, detect_input_monitoring_permission, detect_screen_recording_permission, open_macos_privacy_pane, request_accessibility_access, request_screen_recording_access, }; +#[cfg(feature = "desktop-automation")] pub use permissions::{ detect_microphone_permission, detect_permissions, microphone_denied_message, permission_to_str, request_microphone_access, }; +#[cfg(feature = "desktop-automation")] pub use terminal::{ extract_terminal_input_context, is_terminal_app, is_text_role, looks_like_terminal_buffer, }; +#[cfg(feature = "desktop-automation")] pub use text_util::{normalize_ax_value, parse_ax_number, truncate_tail}; + +// Carved types — compiled in BOTH builds. The `GlobeHotkey*` structs moved out of +// the FFI `globe` module into `types`; re-export them here (ungated) so +// `screen_intelligence::types` and other carved consumers resolve in both builds. +// With the feature ON, `globe` also re-exports them, so the enabled build keeps +// its historical `accessibility::globe::GlobeHotkeyStatus` path too. pub use types::{ - AppContext, ElementBounds, FocusedTextContext, PermissionKind, PermissionState, - PermissionStatus, + AppContext, ElementBounds, FocusedTextContext, GlobeHotkeyPollResult, GlobeHotkeyStatus, + PermissionKind, PermissionState, PermissionStatus, }; diff --git a/src/openhuman/accessibility/stub.rs b/src/openhuman/accessibility/stub.rs new file mode 100644 index 0000000000..09495d4ee3 --- /dev/null +++ b/src/openhuman/accessibility/stub.rs @@ -0,0 +1,248 @@ +//! Disabled-build stub for the `accessibility` domain (`desktop-automation` off). +//! +//! Reproduces the public *behaviour* surface that always-compiled / other-gated +//! callers reach — `text_input`, `voice`, `app_state`, `screen_intelligence` +//! (itself gated) — with disabled-error / no-op / denied bodies. The inert types +//! stay in `types.rs` and are re-exported by the facade, so there is zero type +//! duplication here (the carve-out from AGENTS.md). +//! +//! Signatures match the real ones byte-for-byte; the slim build +//! (`--no-default-features --features tokenjuice-treesitter`) is the only thing +//! that catches drift between this file and the real module. + +use super::types::{ + AppContext, ElementBounds, FocusedTextContext, GlobeHotkeyPollResult, GlobeHotkeyStatus, + PermissionKind, PermissionState, PermissionStatus, +}; + +const DISABLED: &str = + "desktop automation is disabled in this build (rebuild with --features desktop-automation)"; + +// ── focus ──────────────────────────────────────────────────────────────── + +/// Real: `focus::validate_focused_target`. The macOS arm inspects the AX tree; +/// the non-macOS arm returns `Ok(())`. With automation compiled out there is no +/// focus to validate, so treat it as inconclusive-pass (matching the non-macOS +/// real behaviour). +pub fn validate_focused_target( + _expected_app: Option<&str>, + _expected_role: Option<&str>, +) -> Result<(), String> { + Ok(()) +} + +/// Real: `focus::focused_text_context_verbose`. No AX backend when disabled. +pub fn focused_text_context_verbose() -> Result { + Err(DISABLED.to_string()) +} + +/// Real: `focus::focused_text_context`. +pub fn focused_text_context() -> Result { + Err(DISABLED.to_string()) +} + +/// Real: `focus::foreground_context`. +pub fn foreground_context() -> Option { + None +} + +// ── terminal ───────────────────────────────────────────────────────────── + +/// Real: `terminal::is_terminal_app`. Pure heuristic in the real module, but it +/// lives inside the gated tree; without automation there is no focused app, so +/// nothing is a terminal. +pub fn is_terminal_app(_app_name: Option<&str>) -> bool { + false +} + +// ── paste ──────────────────────────────────────────────────────────────── + +/// Real: `paste::apply_text_to_focused_field`. +pub fn apply_text_to_focused_field(_text: &str) -> Result<(), String> { + // Grep-friendly: never logs the text itself (PII). + log::debug!("[accessibility] apply_text_to_focused_field disabled: desktop-automation off"); + Err(DISABLED.to_string()) +} + +/// Real: `paste::send_backspace`. +pub fn send_backspace(_count: usize) -> Result<(), String> { + log::debug!("[accessibility] send_backspace disabled: desktop-automation off"); + Err(DISABLED.to_string()) +} + +// ── overlay ────────────────────────────────────────────────────────────── + +/// Real: `overlay::show_overlay`. The non-macOS real arm is a no-op `Ok(())`; +/// match it — with no overlay helper there is nothing to draw. +pub fn show_overlay( + _bounds: &ElementBounds, + _text: &str, + _ttl_ms: u32, + _tab_hint: &str, +) -> Result<(), String> { + Ok(()) +} + +/// Real: `overlay::hide_overlay`. +pub fn hide_overlay() -> Result<(), String> { + Ok(()) +} + +/// Real: `overlay::quit_overlay`. +pub fn quit_overlay() -> Result<(), String> { + Ok(()) +} + +// ── globe ──────────────────────────────────────────────────────────────── + +/// Real: `globe::globe_listener_start`. Re-exports the carved `GlobeHotkey*` +/// types; the listener helper is compiled out, so report unsupported. +pub fn globe_listener_start() -> Result { + log::debug!("[accessibility] globe_listener_start disabled: desktop-automation off"); + Ok(disabled_globe_status()) +} + +/// Real: `globe::globe_listener_poll`. +pub fn globe_listener_poll() -> Result { + Ok(GlobeHotkeyPollResult { + status: disabled_globe_status(), + events: Vec::new(), + }) +} + +/// Real: `globe::globe_listener_stop`. +pub fn globe_listener_stop() -> Result { + Ok(disabled_globe_status()) +} + +fn disabled_globe_status() -> GlobeHotkeyStatus { + GlobeHotkeyStatus { + supported: false, + running: false, + input_monitoring_permission: PermissionState::Unsupported, + last_error: Some(DISABLED.to_string()), + events_pending: 0, + } +} + +// ── permissions ────────────────────────────────────────────────────────── + +/// Real: `permissions::detect_microphone_permission`. Microphone capture lives in +/// the `voice` domain, which reads its permission through here — so this stub is +/// load-bearing when `voice` is enabled but `desktop-automation` is not. +/// +/// The real Linux / non-macOS implementation cpal-probes and returns `Granted` +/// when an input device is available; the voice recorder treats `Unknown` as +/// "request then re-check, else fail", so returning `Unknown` here would break +/// dictation before it ever opens the mic. Match the permissive non-desktop arm +/// (`Granted`) so voice proceeds and any real capture error surfaces from cpal. +pub fn detect_microphone_permission() -> PermissionState { + PermissionState::Granted +} + +/// Real: `permissions::request_microphone_access`. No-op when disabled. +pub fn request_microphone_access() {} + +/// Real: `permissions::microphone_denied_message`. +pub fn microphone_denied_message() -> String { + "Microphone permission could not be determined in this build.".to_string() +} + +/// Real: `permissions::permission_to_str`. +pub fn permission_to_str(permission: PermissionKind) -> &'static str { + match permission { + PermissionKind::ScreenRecording => "screen_recording", + PermissionKind::Accessibility => "accessibility", + PermissionKind::InputMonitoring => "input_monitoring", + PermissionKind::Microphone => "microphone", + } +} + +/// Real: `permissions::detect_permissions`. The desktop-automation permissions +/// (screen-recording / accessibility / input-monitoring) are `Unsupported` when +/// the gate is off — matching the real non-macOS `detect_permissions`, which +/// reports these as `Unsupported` so the disabled build "behaves like a +/// non-desktop platform". Microphone mirrors `detect_microphone_permission` +/// above so the `voice`-on / `desktop-automation`-off build keeps a usable mic +/// state. +pub fn detect_permissions() -> PermissionStatus { + PermissionStatus { + screen_recording: PermissionState::Unsupported, + accessibility: PermissionState::Unsupported, + input_monitoring: PermissionState::Unsupported, + microphone: PermissionState::Granted, + } +} + +// ── automation state ───────────────────────────────────────────────────── + +/// Real: `automation_state::mark_system_events_denied`. No shared denial state +/// exists when automation is compiled out, so these are no-ops / `false`. +pub fn mark_system_events_denied() {} + +/// Real: `automation_state::clear` (re-exported as `clear_automation_denial`). +pub fn clear_automation_denial() {} + +/// Real: `automation_state::system_events_denied`. +pub fn system_events_denied() -> bool { + false +} + +// ── automate ───────────────────────────────────────────────────────────── + +/// Disabled-build mirror of `accessibility::automate`. Reached by +/// `voice::always_on::execute_intent`, which builds a `RealBackend`, calls `run`, +/// and reads `.success` / `.summary` off the returned outcome. +pub mod automate { + use crate::openhuman::config::Config; + + use super::DISABLED; + + /// Real: `automate::AutomateOutcome`. + #[derive(Debug, Clone, PartialEq)] + pub struct AutomateOutcome { + pub success: bool, + pub summary: String, + pub steps: Vec, + } + + /// Real: `automate::AutomateOptions`. The real module hand-writes `Default` + /// (a non-zero `DEFAULT_STEP_BUDGET`); the disabled path never steps, so the + /// derived zero-default is correct here. + #[derive(Debug, Clone, Copy, Default)] + pub struct AutomateOptions { + pub step_budget: u32, + } + + /// Real: `automate::RealBackend`. The config is retained to match the real + /// constructor signature exactly, even though the disabled path never uses it. + pub struct RealBackend { + #[allow(dead_code)] + config: Config, + } + + impl RealBackend { + pub fn new(config: Config) -> Self { + Self { config } + } + } + + /// Real: `automate::run`. The disabled build has no backend to drive, so it + /// reports a failed outcome rather than performing any UI automation. The + /// generic `backend` parameter keeps the caller's `&RealBackend` argument + /// compiling without stubbing the `AutomateBackend` trait. + pub async fn run( + _app: &str, + _goal: &str, + _backend: &B, + _opts: AutomateOptions, + ) -> AutomateOutcome { + // Grep-friendly: never logs `_app` / `_goal` (may carry user content). + log::debug!("[accessibility] automate::run disabled: desktop-automation off"); + AutomateOutcome { + success: false, + summary: DISABLED.to_string(), + steps: Vec::new(), + } + } +} diff --git a/src/openhuman/accessibility/types.rs b/src/openhuman/accessibility/types.rs index a4baf60bd0..88da102680 100644 --- a/src/openhuman/accessibility/types.rs +++ b/src/openhuman/accessibility/types.rs @@ -77,6 +77,29 @@ pub enum PermissionKind { Microphone, } +/// Status of the macOS Globe/Fn key listener helper. +/// +/// Inert serde data — no FFI. Lives here (rather than in the FFI-bearing +/// `globe` module) so it stays compiled when the `desktop-automation` feature +/// is off; `globe` re-exports it so existing paths still resolve. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GlobeHotkeyStatus { + pub supported: bool, + pub running: bool, + pub input_monitoring_permission: PermissionState, + pub last_error: Option, + pub events_pending: usize, +} + +/// Result of polling the Globe/Fn listener: current status plus drained events. +/// +/// Inert serde data — see [`GlobeHotkeyStatus`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GlobeHotkeyPollResult { + pub status: GlobeHotkeyStatus, + pub events: Vec, +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/openhuman/autocomplete/core/engine.rs b/src/openhuman/autocomplete/core/engine.rs index 8792e5f88c..886f256b04 100644 --- a/src/openhuman/autocomplete/core/engine.rs +++ b/src/openhuman/autocomplete/core/engine.rs @@ -7,6 +7,13 @@ use tokio::sync::Mutex; use tokio::task::JoinHandle; use tokio::time::{self, Duration, Instant}; +use super::super::types::{ + AutocompleteAcceptParams, AutocompleteAcceptResult, AutocompleteCurrentParams, + AutocompleteCurrentResult, AutocompleteDebugFocusResult, AutocompleteSetStyleParams, + AutocompleteSetStyleResult, AutocompleteStartParams, AutocompleteStartResult, + AutocompleteStatus, AutocompleteStopParams, AutocompleteStopResult, AutocompleteSuggestion, + FocusedTextContext, +}; #[cfg(target_os = "macos")] use super::focus::validate_focused_target; use super::focus::{ @@ -20,13 +27,6 @@ use super::terminal::{ extract_terminal_input_context, is_terminal_app, looks_like_terminal_buffer, }; use super::text::{is_no_text_candidate_error, sanitize_suggestion, truncate_tail}; -use super::types::{ - AutocompleteAcceptParams, AutocompleteAcceptResult, AutocompleteCurrentParams, - AutocompleteCurrentResult, AutocompleteDebugFocusResult, AutocompleteSetStyleParams, - AutocompleteSetStyleResult, AutocompleteStartParams, AutocompleteStartResult, - AutocompleteStatus, AutocompleteStopParams, AutocompleteStopResult, AutocompleteSuggestion, - FocusedTextContext, -}; const REFRESH_TIMEOUT_SECS: u64 = 120; diff --git a/src/openhuman/autocomplete/core/mod.rs b/src/openhuman/autocomplete/core/mod.rs index dae2482151..aa8d55b138 100644 --- a/src/openhuman/autocomplete/core/mod.rs +++ b/src/openhuman/autocomplete/core/mod.rs @@ -5,12 +5,10 @@ mod focus; mod overlay; mod terminal; mod text; -mod types; pub use engine::{global_engine, start_if_enabled, AutocompleteEngine, AUTOCOMPLETE_ENGINE}; -pub use types::{ - AutocompleteAcceptParams, AutocompleteAcceptResult, AutocompleteCurrentParams, - AutocompleteCurrentResult, AutocompleteDebugFocusResult, AutocompleteSetStyleParams, - AutocompleteSetStyleResult, AutocompleteStartParams, AutocompleteStartResult, - AutocompleteStatus, AutocompleteStopParams, AutocompleteStopResult, AutocompleteSuggestion, -}; +// The inert request/response + status types live one level up in +// `autocomplete::types` (dep-free) and are re-exported by the `autocomplete` +// facade (ungated). `core`'s own code reaches them via `super::super::types`; the +// facade owns the public `autocomplete::{AutocompleteStatus, …}` re-export, so we +// do not re-export them here (that would collide with the facade's `pub use`). diff --git a/src/openhuman/autocomplete/core/text.rs b/src/openhuman/autocomplete/core/text.rs index d369166d0a..43c3c0b021 100644 --- a/src/openhuman/autocomplete/core/text.rs +++ b/src/openhuman/autocomplete/core/text.rs @@ -1,6 +1,6 @@ //! Text utilities for autocomplete suggestions. -use super::types::MAX_SUGGESTION_CHARS; +use super::super::types::MAX_SUGGESTION_CHARS; pub(super) use crate::openhuman::accessibility::truncate_tail; diff --git a/src/openhuman/autocomplete/mod.rs b/src/openhuman/autocomplete/mod.rs index 4866b99151..f522f69829 100644 --- a/src/openhuman/autocomplete/mod.rs +++ b/src/openhuman/autocomplete/mod.rs @@ -1,15 +1,52 @@ +//! Inline autocomplete domain — facade for the `desktop-automation` gate (#5049). +//! +//! The real engine/history/ops/schemas are `#[cfg(feature = "desktop-automation")]`; +//! the inert `types` module stays compiled in both directions (carve-out), and +//! `stub` re-exposes the always-on caller surface (`all_autocomplete_*`, +//! `global_engine`, `start_if_enabled`) when the feature is off. + +// Inert request/response + status types (dep-free serde). Kept ungated and at +// the domain root so `autocomplete::AutocompleteStatus` (consumed by the +// always-compiled `app_state`) stays available when `desktop-automation` is +// off — the type carve-out from AGENTS.md. +pub mod types; + +// Re-export the carved types at the domain root (ungated) so callers keep the +// historical `autocomplete::{AutocompleteStatus, …}` paths in both builds. This +// is the single re-export of these names; `core` reaches `super::types` directly. +pub use types::{ + AutocompleteAcceptParams, AutocompleteAcceptResult, AutocompleteCurrentParams, + AutocompleteCurrentResult, AutocompleteDebugFocusResult, AutocompleteSetStyleParams, + AutocompleteSetStyleResult, AutocompleteStartParams, AutocompleteStartResult, + AutocompleteStatus, AutocompleteStopParams, AutocompleteStopResult, AutocompleteSuggestion, +}; + +#[cfg(feature = "desktop-automation")] mod core; +#[cfg(feature = "desktop-automation")] pub mod history; +#[cfg(feature = "desktop-automation")] pub mod ops; +#[cfg(feature = "desktop-automation")] mod schemas; +#[cfg(not(feature = "desktop-automation"))] +mod stub; +#[cfg(not(feature = "desktop-automation"))] +pub use stub::*; + +#[cfg(feature = "desktop-automation")] pub use core::*; +#[cfg(feature = "desktop-automation")] pub use history::{ clear_history, list_history, load_recent_examples, query_relevant_examples, save_accepted_completion, save_completion_to_local_docs, AcceptedCompletion, }; +#[cfg(feature = "desktop-automation")] pub use ops as rpc; +#[cfg(feature = "desktop-automation")] pub use ops::*; +#[cfg(feature = "desktop-automation")] pub use schemas::{ all_controller_schemas as all_autocomplete_controller_schemas, all_registered_controllers as all_autocomplete_registered_controllers, diff --git a/src/openhuman/autocomplete/stub.rs b/src/openhuman/autocomplete/stub.rs new file mode 100644 index 0000000000..02606dd8a3 --- /dev/null +++ b/src/openhuman/autocomplete/stub.rs @@ -0,0 +1,79 @@ +//! Disabled-build stub for the `autocomplete` domain (`desktop-automation` off). +//! +//! Re-exposes the always-on caller surface with empty / no-op / disabled bodies. +//! The status/param/result types are carved out in `super::types` and stay +//! compiled in both builds, so `app_state`'s literal `AutocompleteStatus` +//! construction needs no stub. Only behaviour lives here. + +use std::sync::Arc; + +use once_cell::sync::Lazy; + +use crate::core::all::RegisteredController; +use crate::core::ControllerSchema; +use crate::openhuman::config::Config; + +use super::types::{AutocompleteStatus, AutocompleteStopParams, AutocompleteStopResult}; + +/// Real: `schemas::all_registered_controllers` (re-exported as +/// `all_autocomplete_registered_controllers`). Registration site wants absence: +/// an empty vec leaves `autocomplete.*` unregistered. +pub fn all_autocomplete_registered_controllers() -> Vec { + Vec::new() +} + +/// Real: `schemas::all_controller_schemas` (re-exported as +/// `all_autocomplete_controller_schemas`). +pub fn all_autocomplete_controller_schemas() -> Vec { + Vec::new() +} + +/// Real: `engine::start_if_enabled`. No engine to start when disabled. +pub async fn start_if_enabled(_app_config: &Config) {} + +/// Real: `engine::AutocompleteEngine`. An inert handle exposing only the methods +/// reached by always-on callers (`app_state`, `credentials`, the shutdown hook). +pub struct AutocompleteEngine; + +impl AutocompleteEngine { + /// Real: `AutocompleteEngine::status`. Reports a disabled, not-running engine. + pub async fn status(&self) -> AutocompleteStatus { + disabled_status() + } + + /// Real: `AutocompleteEngine::status_with_config`. + pub async fn status_with_config(&self, _config: &Config) -> AutocompleteStatus { + disabled_status() + } + + /// Real: `AutocompleteEngine::stop`. + pub async fn stop(&self, _params: Option) -> AutocompleteStopResult { + AutocompleteStopResult { stopped: false } + } +} + +static AUTOCOMPLETE_ENGINE: Lazy> = + Lazy::new(|| Arc::new(AutocompleteEngine)); + +/// Real: `engine::global_engine`. Returns the inert singleton handle. +pub fn global_engine() -> Arc { + AUTOCOMPLETE_ENGINE.clone() +} + +fn disabled_status() -> AutocompleteStatus { + AutocompleteStatus { + platform_supported: false, + enabled: false, + running: false, + phase: "disabled".to_string(), + debounce_ms: 0, + model_id: String::new(), + app_name: None, + last_error: Some( + "autocomplete is disabled in this build (rebuild with --features desktop-automation)" + .to_string(), + ), + updated_at_ms: None, + suggestion: None, + } +} diff --git a/src/openhuman/autocomplete/core/types.rs b/src/openhuman/autocomplete/types.rs similarity index 88% rename from src/openhuman/autocomplete/core/types.rs rename to src/openhuman/autocomplete/types.rs index 3c3aee9615..a5e41f61e1 100644 --- a/src/openhuman/autocomplete/core/types.rs +++ b/src/openhuman/autocomplete/types.rs @@ -1,9 +1,14 @@ use crate::openhuman::config::AutocompleteConfig; use serde::{Deserialize, Serialize}; -// Re-export platform types from the accessibility middleware. +// Re-export platform types from the accessibility middleware. Consumed only by +// the gated `core` engine, so gate the re-export + the const in lockstep to keep +// the disabled build warning-clean (this file itself stays compiled for the +// carved-out serde types below). +#[cfg(feature = "desktop-automation")] pub(crate) use crate::openhuman::accessibility::FocusedTextContext; +#[cfg(feature = "desktop-automation")] pub(crate) const MAX_SUGGESTION_CHARS: usize = 64; #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/openhuman/desktop_companion/mod.rs b/src/openhuman/desktop_companion/mod.rs index 821c96070b..2e7cb06f77 100644 --- a/src/openhuman/desktop_companion/mod.rs +++ b/src/openhuman/desktop_companion/mod.rs @@ -12,15 +12,37 @@ //! //! This module is export-focused. Operational code lives in `session.rs`, //! `pipeline.rs`, and `pointing.rs`. +//! +//! Facade for the `desktop-automation` gate (#5049): the behavioural submodules +//! (`handoff`, `pipeline`, `pointing`, `schemas`, `session`) are +//! `#[cfg(feature = "desktop-automation")]`. `types` and `bus` stay compiled in +//! both directions — both are dependency-free (serde / tokio broadcast only), and +//! the always-on `core::socketio` subscribes to `bus::subscribe_state_changed()`. +//! When off, nobody publishes, so the broadcast channel simply never fires. Only +//! the controller aggregators are stubbed. +// Dependency-free, compiled in BOTH builds: the inert types and the state-change +// broadcast bus (the always-on `core::socketio` subscribes to it). pub mod bus; +pub mod types; + +#[cfg(feature = "desktop-automation")] pub mod handoff; +#[cfg(feature = "desktop-automation")] pub mod pipeline; +#[cfg(feature = "desktop-automation")] pub mod pointing; +#[cfg(feature = "desktop-automation")] pub mod schemas; +#[cfg(feature = "desktop-automation")] pub mod session; -pub mod types; +#[cfg(not(feature = "desktop-automation"))] +mod stub; +#[cfg(not(feature = "desktop-automation"))] +pub use stub::*; + +#[cfg(feature = "desktop-automation")] pub use schemas::{ all_desktop_companion_controller_schemas, all_desktop_companion_registered_controllers, }; diff --git a/src/openhuman/desktop_companion/stub.rs b/src/openhuman/desktop_companion/stub.rs new file mode 100644 index 0000000000..4ad6bc7df4 --- /dev/null +++ b/src/openhuman/desktop_companion/stub.rs @@ -0,0 +1,20 @@ +//! Disabled-build stub for the `desktop_companion` domain (`desktop-automation` +//! off). Supplies only the controller aggregators — the registration site in +//! `core::all` wants absence, so both return empty vecs. `types` and `bus` stay +//! compiled (dep-free), so the always-on `core::socketio` subscriber and any +//! type consumer need no stub. + +use crate::core::all::RegisteredController; +use crate::core::ControllerSchema; + +/// Real: `schemas::all_desktop_companion_registered_controllers`. Empty ⇒ the +/// `companion.*` controllers are unregistered (unknown-method over `/rpc`, +/// absent from `/schema`). +pub fn all_desktop_companion_registered_controllers() -> Vec { + Vec::new() +} + +/// Real: `schemas::all_desktop_companion_controller_schemas`. +pub fn all_desktop_companion_controller_schemas() -> Vec { + Vec::new() +} diff --git a/src/openhuman/screen_intelligence/mod.rs b/src/openhuman/screen_intelligence/mod.rs index 5fabf0ae3b..8db4cb8e1f 100644 --- a/src/openhuman/screen_intelligence/mod.rs +++ b/src/openhuman/screen_intelligence/mod.rs @@ -1,32 +1,71 @@ //! Screen capture, accessibility automation, and vision summaries (macOS-focused). +//! +//! Facade for the `desktop-automation` gate (#5049): the capture/vision/engine/ +//! server/cli/tools submodules are `#[cfg(feature = "desktop-automation")]`; the +//! inert `types` module (`AccessibilityStatus`, `CaptureImageRefResult`, the +//! session/permission structs) stays compiled in both directions (carve-out), and +//! `stub` re-exposes the always-on caller surface (`all_screen_intelligence_*`, +//! `global_engine`, `server::{start_if_enabled, try_global_server}`, +//! `rpc::accessibility_capture_image_ref`, `cli`) when the feature is off. +// Inert serde types — compiled in BOTH builds (type carve-out). Consumed by the +// always-on `app_state` (literal `AccessibilityStatus`) and `tools::local_cli` +// (`CaptureImageRefResult`). Re-exports the carved `accessibility` types. +mod types; + +#[cfg(feature = "desktop-automation")] pub(crate) mod cli; +#[cfg(feature = "desktop-automation")] pub mod ops; +#[cfg(feature = "desktop-automation")] mod schemas; +#[cfg(feature = "desktop-automation")] pub mod server; +#[cfg(feature = "desktop-automation")] pub mod tools; +#[cfg(feature = "desktop-automation")] mod capture; +#[cfg(feature = "desktop-automation")] mod capture_worker; +#[cfg(feature = "desktop-automation")] mod engine; +#[cfg(feature = "desktop-automation")] mod helpers; +#[cfg(feature = "desktop-automation")] mod image_processing; +#[cfg(feature = "desktop-automation")] mod input; +#[cfg(feature = "desktop-automation")] mod limits; +#[cfg(feature = "desktop-automation")] mod permissions; +#[cfg(feature = "desktop-automation")] mod processing_worker; +#[cfg(feature = "desktop-automation")] mod state; -mod types; +#[cfg(feature = "desktop-automation")] mod vision; +#[cfg(not(feature = "desktop-automation"))] +mod stub; +#[cfg(not(feature = "desktop-automation"))] +pub use stub::*; + +#[cfg(feature = "desktop-automation")] pub use ops as rpc; +#[cfg(feature = "desktop-automation")] pub use ops::*; +#[cfg(feature = "desktop-automation")] pub use schemas::{ all_controller_schemas as all_screen_intelligence_controller_schemas, all_registered_controllers as all_screen_intelligence_registered_controllers, }; +#[cfg(feature = "desktop-automation")] pub use state::{global_engine, AccessibilityEngine}; + +// Carved types — compiled in BOTH builds. pub use types::*; -#[cfg(test)] +#[cfg(all(test, feature = "desktop-automation"))] mod tests; diff --git a/src/openhuman/screen_intelligence/stub.rs b/src/openhuman/screen_intelligence/stub.rs new file mode 100644 index 0000000000..a197b22faf --- /dev/null +++ b/src/openhuman/screen_intelligence/stub.rs @@ -0,0 +1,178 @@ +//! Disabled-build stub for the `screen_intelligence` domain (`desktop-automation` +//! off). Re-exposes the always-on caller surface — the controller/tool +//! aggregators, the `global_engine()` handle (`apply_config` / `status` / +//! `disable`), the `server` lifecycle, the `rpc` capture entry point, and the +//! `cli` subcommand — with empty / no-op / disabled bodies. +//! +//! The status/session/result types are carved out in `super::types` and stay +//! compiled in both builds, so `app_state`'s literal `AccessibilityStatus` +//! construction needs no stub. Only behaviour lives here. + +use std::sync::Arc; + +use once_cell::sync::Lazy; + +use crate::core::all::RegisteredController; +use crate::core::ControllerSchema; +use crate::openhuman::config::ScreenIntelligenceConfig; + +use super::types::{ + AccessibilityFeatures, AccessibilityStatus, PermissionState, PermissionStatus, SessionStatus, +}; + +const DISABLED: &str = + "screen intelligence is disabled in this build (rebuild with --features desktop-automation)"; + +/// Real: `schemas::all_registered_controllers` (re-exported as +/// `all_screen_intelligence_registered_controllers`). Empty ⇒ unregistered. +pub fn all_screen_intelligence_registered_controllers() -> Vec { + Vec::new() +} + +/// Real: `schemas::all_controller_schemas`. +pub fn all_screen_intelligence_controller_schemas() -> Vec { + Vec::new() +} + +/// Real: `state::AccessibilityEngine`. Inert handle exposing only the methods the +/// always-on callers (`app_state`, `config::ops::ui`) reach. +pub struct AccessibilityEngine; + +impl AccessibilityEngine { + /// Real: `AccessibilityEngine::apply_config` → `Result`. + pub async fn apply_config( + &self, + config: ScreenIntelligenceConfig, + ) -> Result { + Ok(disabled_status(config)) + } + + /// Real: `AccessibilityEngine::status`. + pub async fn status(&self) -> AccessibilityStatus { + disabled_status(ScreenIntelligenceConfig::default()) + } + + /// Real: `AccessibilityEngine::disable` → `SessionStatus`. + pub async fn disable(&self, reason: Option) -> SessionStatus { + disabled_session(reason) + } +} + +static ACCESSIBILITY_ENGINE: Lazy> = + Lazy::new(|| Arc::new(AccessibilityEngine)); + +/// Real: `state::global_engine`. +pub fn global_engine() -> Arc { + ACCESSIBILITY_ENGINE.clone() +} + +/// Disabled-build mirror of `screen_intelligence::ops` — the `rpc` alias. +/// `tools::local_cli` reaches `rpc::accessibility_capture_image_ref`. +pub mod rpc { + use super::DISABLED; + use crate::openhuman::screen_intelligence::types::CaptureImageRefResult; + use crate::rpc::RpcOutcome; + + /// Real: `ops::accessibility_capture_image_ref`. + pub async fn accessibility_capture_image_ref( + ) -> Result, String> { + log::debug!( + "[screen_intelligence] capture_image_ref rejected: desktop-automation disabled at compile time" + ); + Ok(RpcOutcome::new( + CaptureImageRefResult { + ok: false, + image_ref: None, + mime_type: "image/png".to_string(), + bytes_estimate: None, + message: DISABLED.to_string(), + }, + vec![DISABLED.to_string()], + )) + } +} + +/// Disabled-build mirror of `screen_intelligence::server`. +pub mod server { + use crate::openhuman::config::Config; + + /// Real: `server::start_if_enabled`. No server to start when disabled. + pub async fn start_if_enabled(_app_config: &Config) {} + + /// Real: `server::try_global_server`. Never a running server when disabled; + /// returning `None` short-circuits the `if let Some(server)` stop path in + /// `credentials::ops`. + pub fn try_global_server() -> Option> { + None + } + + /// Real: `server::SiServer`. Only reached via `try_global_server()`, which the + /// stub always returns `None` for, so no method body is ever invoked — but the + /// `server.stop()` call site still needs the method to exist to type-check. + pub struct SiServer; + + impl SiServer { + pub async fn stop(&self) {} + } +} + +/// Disabled-build mirror of `screen_intelligence::cli`. +pub mod cli { + use anyhow::Result; + + /// Real: `cli::run_screen_intelligence_command`. Reports the build fact rather + /// than running a no-op command. + pub(crate) fn run_screen_intelligence_command(_args: &[String]) -> Result<()> { + log::debug!( + "[screen_intelligence] CLI command rejected: desktop-automation disabled at compile time" + ); + Err(anyhow::anyhow!(super::DISABLED)) + } +} + +fn disabled_status(config: ScreenIntelligenceConfig) -> AccessibilityStatus { + AccessibilityStatus { + platform_supported: false, + permissions: PermissionStatus { + screen_recording: PermissionState::Unknown, + accessibility: PermissionState::Unknown, + input_monitoring: PermissionState::Unknown, + microphone: PermissionState::Unknown, + }, + features: AccessibilityFeatures { + screen_monitoring: false, + }, + session: disabled_session(None), + foreground_context: None, + config, + denylist: Vec::new(), + is_context_blocked: false, + permission_check_process_path: None, + core_process: None, + } +} + +fn disabled_session(reason: Option) -> SessionStatus { + SessionStatus { + active: false, + started_at_ms: None, + expires_at_ms: None, + remaining_ms: None, + ttl_secs: 0, + panic_hotkey: String::new(), + stop_reason: reason, + capture_count: 0, + frames_in_memory: 0, + last_capture_at_ms: None, + last_context: None, + last_window_title: None, + vision_enabled: false, + vision_state: "disabled".to_string(), + vision_queue_depth: 0, + last_vision_at_ms: None, + last_vision_summary: None, + vision_persist_count: 0, + last_vision_persisted_key: None, + last_vision_persist_error: None, + } +} diff --git a/src/openhuman/tools/impl/mod.rs b/src/openhuman/tools/impl/mod.rs index 0dbd542fc1..fa92ff96c5 100644 --- a/src/openhuman/tools/impl/mod.rs +++ b/src/openhuman/tools/impl/mod.rs @@ -1,4 +1,9 @@ pub mod browser; +// The `computer` agent-tool family (ax_interact / automate / mouse / keyboard) is +// compiled out with the `desktop-automation` feature (#5049). Leaf gate: the tool +// registrations in `tools/ops.rs` carry matching `#[cfg]` so the tools are absent +// (not error-degraded) when off. +#[cfg(feature = "desktop-automation")] pub mod computer; pub mod document; pub mod filesystem; @@ -7,6 +12,7 @@ pub mod presentation; pub mod system; pub use browser::*; +#[cfg(feature = "desktop-automation")] pub use computer::*; pub use document::DocumentTool; pub use filesystem::*; diff --git a/src/openhuman/tools/impl/system/mod.rs b/src/openhuman/tools/impl/system/mod.rs index 1610fc81b0..5435dc010e 100644 --- a/src/openhuman/tools/impl/system/mod.rs +++ b/src/openhuman/tools/impl/system/mod.rs @@ -26,7 +26,10 @@ pub use detect_tools::DetectToolsTool; pub use insert_sql_record::InsertSqlRecordTool; pub use install_tool::InstallToolTool; pub use launch_app::LaunchAppTool; -// Reused by the `automate` inner loop to launch an app mid-flow. +// Reused by the `automate` inner loop (`desktop-automation`) and the always-on +// voice command router (`voice`) to launch an app mid-flow. Gated to the union of +// its consumers so the fully-slim build (both off) stays warning-clean. +#[cfg(any(feature = "desktop-automation", feature = "voice"))] pub(crate) use launch_app::launch_platform; pub use lsp::{lsp_capability_enabled, LspTool, LSP_ENABLED_ENV}; pub use node_exec::NodeExecTool; diff --git a/src/openhuman/tools/mod.rs b/src/openhuman/tools/mod.rs index 9326370ef5..7e97814c3c 100644 --- a/src/openhuman/tools/mod.rs +++ b/src/openhuman/tools/mod.rs @@ -47,6 +47,7 @@ pub use crate::openhuman::people::tools::*; pub use crate::openhuman::referral::tools::*; #[cfg(feature = "flows")] pub use crate::openhuman::rhai_workflows::tools::*; +#[cfg(feature = "desktop-automation")] pub use crate::openhuman::screen_intelligence::tools::*; pub use crate::openhuman::search::tools::*; pub use crate::openhuman::security::tools::*; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index a92b314c80..1b9f89430c 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -246,12 +246,17 @@ pub fn all_tools_with_runtime( // value ready to paste into a tool argument. Box::new(ResolveTimeTool::new()), Box::new(LaunchAppTool::new()), + // `ax_interact` + `automate` are the `computer`-family tools — compiled + // out with the `desktop-automation` feature (same idiom as the + // `screen_intelligence_*` block below). + #[cfg(feature = "desktop-automation")] Box::new(AxInteractTool::new( root_config.computer_control.ax_interact_mutations, )), // Multi-step UI automation in one call. Shares the ax_interact opt-in // (mutations) and sensitive-app denylist; runs a Rust perceive→act→verify // loop with a fast model so the chat model stays out of the click loop. + #[cfg(feature = "desktop-automation")] Box::new(AutomateTool::new( root_config.computer_control.ax_interact_mutations, )), @@ -685,20 +690,39 @@ pub fn all_tools_with_runtime( // call tools default-ON; OS permission prompts (screen_permissions), // MCP install/uninstall (mcp_manage), and persona/workspace writers // (workspace_manage) ship default-OFF via `tools::user_filter`. + // + // The 15 `screen_intelligence_*` tools are compiled out with the + // `desktop-automation` feature — the per-element attrs inside the + // `vec![]` mirror the `mcp` idiom below. + #[cfg(feature = "desktop-automation")] Box::new(ScreenStatusTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenCaptureImageRefTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenVisionRecentTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenVisionFlushTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenRefreshPermissionsTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenCaptureNowTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenCaptureTestTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenSessionStartTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenSessionStopTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenInputActionTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenGlobeStartTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenGlobePollTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenGlobeStopTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenRequestPermissionsTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenRequestPermissionTool), // MCP registry (dynamic, user-installed servers) — compiled out with // the `mcp` feature. Per-element attrs inside the `vec![]` mirror the @@ -1005,7 +1029,10 @@ pub fn all_tools_with_runtime( tools.push(Box::new(ScreenshotTool::new(security.clone()))); tools.push(Box::new(ImageInfoTool::new(security.clone()))); - // Native mouse + keyboard control (disabled by default) + // Native mouse + keyboard control (disabled by default). The `MouseTool` / + // `KeyboardTool` are `computer`-family tools — compiled out with the + // `desktop-automation` feature. + #[cfg(feature = "desktop-automation")] if root_config.computer_control.enabled { tools.push(Box::new(MouseTool::new(security.clone()))); tools.push(Box::new(KeyboardTool::new(security.clone()))); @@ -1353,7 +1380,17 @@ fn tool_group(name: &str) -> crate::core::all::DomainGroup { if name.starts_with("thread_") || name.starts_with("todo_") || THREADS_EXTRA.contains(&name) { return DomainGroup::Threads; } - // Everything else — shell/file/screen/config/security/agent/billing/… — is + // Desktop-automation family: the 15 `screen_intelligence_*` tools plus the + // four `computer`-family tools (`ax_interact`, `automate`, `mouse`, + // `keyboard`). Compiled out with the `desktop-automation` feature; tagged so + // the runtime `DomainSet::desktop_automation` axis gates them consistently + // with the autocomplete/screen_intelligence/desktop_companion controllers. + if name.starts_with("screen_intelligence_") + || matches!(name, "ax_interact" | "automate" | "mouse" | "keyboard") + { + return DomainGroup::DesktopAutomation; + } + // Everything else — shell/file/config/security/agent/billing/… — is // Platform: present under full(), absent under harness()/none(). DomainGroup::Platform } diff --git a/src/openhuman/tools/ops_tests.rs b/src/openhuman/tools/ops_tests.rs index bd1b0d7ae2..414830009c 100644 --- a/src/openhuman/tools/ops_tests.rs +++ b/src/openhuman/tools/ops_tests.rs @@ -972,6 +972,9 @@ fn all_tools_excludes_computer_control_when_disabled() { ); } +// The `mouse` / `keyboard` computer-control tools are gated behind +// `desktop-automation` (#5049), so this test only applies when the feature is on. +#[cfg(feature = "desktop-automation")] #[test] fn all_tools_includes_computer_control_when_enabled() { let tmp = TempDir::new().unwrap(); @@ -1617,10 +1620,15 @@ async fn readonly_acting_tools_carry_policy_blocked_marker() { Box::new(CsvExportTool::new(sec.clone())), serde_json::json!({ "data": "col1\nval1", "filename": "x.csv" }), ), + // The `computer`-family tools are compiled out with the + // `desktop-automation` feature; gate these two cases per-element so the + // rest of the read-only policy assertions still run in the slim build. + #[cfg(feature = "desktop-automation")] ( Box::new(KeyboardTool::new(sec.clone())), serde_json::json!({}), ), + #[cfg(feature = "desktop-automation")] (Box::new(MouseTool::new(sec.clone())), serde_json::json!({})), ( Box::new(BrowserOpenTool::new(sec.clone(), vec![])), @@ -2160,20 +2168,39 @@ fn money_default_off_tools_retained_when_opted_in() { // ── Theme: Desktop perception, MCP registry, workspace ────────────────────── const DESKTOP_TOOLS: &[&str] = &[ + // The 15 `screen_intelligence_*` tools are compiled out with the + // `desktop-automation` feature, so these expectations are gated per-element + // (same idiom as the `mcp_registry_*` block below) rather than gating the + // `desktop_tools_are_registered` test away wholesale. + #[cfg(feature = "desktop-automation")] "screen_intelligence_status", + #[cfg(feature = "desktop-automation")] "screen_intelligence_capture_image_ref", + #[cfg(feature = "desktop-automation")] "screen_intelligence_vision_recent", + #[cfg(feature = "desktop-automation")] "screen_intelligence_vision_flush", + #[cfg(feature = "desktop-automation")] "screen_intelligence_refresh_permissions", + #[cfg(feature = "desktop-automation")] "screen_intelligence_capture_now", + #[cfg(feature = "desktop-automation")] "screen_intelligence_capture_test", + #[cfg(feature = "desktop-automation")] "screen_intelligence_session_start", + #[cfg(feature = "desktop-automation")] "screen_intelligence_session_stop", + #[cfg(feature = "desktop-automation")] "screen_intelligence_input_action", + #[cfg(feature = "desktop-automation")] "screen_intelligence_globe_listener_start", + #[cfg(feature = "desktop-automation")] "screen_intelligence_globe_listener_poll", + #[cfg(feature = "desktop-automation")] "screen_intelligence_globe_listener_stop", + #[cfg(feature = "desktop-automation")] "screen_intelligence_request_permissions", + #[cfg(feature = "desktop-automation")] "screen_intelligence_request_permission", // The `mcp_registry_*` desktop surface is compiled out with the `mcp` // feature, so these expectations are gated per-element rather than gating @@ -2206,7 +2233,9 @@ const DESKTOP_TOOLS: &[&str] = &[ ]; const DESKTOP_DEFAULT_OFF: &[&str] = &[ + #[cfg(feature = "desktop-automation")] "screen_intelligence_request_permissions", + #[cfg(feature = "desktop-automation")] "screen_intelligence_request_permission", #[cfg(feature = "mcp")] "mcp_registry_install", @@ -2218,7 +2247,9 @@ const DESKTOP_DEFAULT_OFF: &[&str] = &[ ]; const DESKTOP_ALWAYS_ON: &[&str] = &[ + #[cfg(feature = "desktop-automation")] "screen_intelligence_status", + #[cfg(feature = "desktop-automation")] "screen_intelligence_capture_now", #[cfg(feature = "mcp")] "mcp_registry_search", @@ -2236,6 +2267,30 @@ fn desktop_tools_are_registered() { assert_contains_all(&names, DESKTOP_TOOLS); } +/// Negative half of the `desktop-automation` gate (#5049): with the cluster +/// compiled out, no `screen_intelligence_*` tool and none of the `computer` +/// family (`ax_interact` / `automate` / `mouse` / `keyboard`) may be advertised — +/// they must be *absent*, not degraded to a runtime error. Pairs with +/// `desktop_tools_are_registered` above. +#[cfg(not(feature = "desktop-automation"))] +#[test] +fn screen_intelligence_tools_absent_when_feature_off() { + let tmp = TempDir::new().unwrap(); + let names = tool_names(&expansion_tools_for(&tmp)); + assert!( + !names.iter().any(|n| n.starts_with("screen_intelligence_")), + "no `screen_intelligence_*` tool may be advertised when \ + `desktop-automation` is off; got: {names:?}" + ); + for computer_tool in ["ax_interact", "automate", "mouse", "keyboard"] { + assert!( + !names.iter().any(|n| n == computer_tool), + "`computer` tool `{computer_tool}` must be absent when \ + `desktop-automation` is off; got: {names:?}" + ); + } +} + #[test] fn desktop_default_off_tools_are_filtered_when_not_opted_in() { let tmp = TempDir::new().unwrap(); From fc0222f3b6378671f91f94add1156ca64a9b2bfb Mon Sep 17 00:00:00 2001 From: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:33:39 +0530 Subject: [PATCH 16/56] perf(agent): share one Arc across per-build tool/provider/reflection (#5050) (#5062) --- .../harness/session/builder/builder_tests.rs | 68 ++++++++++++++++++ .../agent/harness/session/builder/factory.rs | 72 +++++++++++++------ 2 files changed, 118 insertions(+), 22 deletions(-) diff --git a/src/openhuman/agent/harness/session/builder/builder_tests.rs b/src/openhuman/agent/harness/session/builder/builder_tests.rs index 06bbd26cc6..7e44eebcda 100644 --- a/src/openhuman/agent/harness/session/builder/builder_tests.rs +++ b/src/openhuman/agent/harness/session/builder/builder_tests.rs @@ -240,3 +240,71 @@ async fn build_session_agent_falls_back_to_global_default_when_no_definition() { "with no definition, the global config default must be used unchanged" ); } + +// ── #5050 Fix 1: shared `Arc` for the per-build tool config ────────── + +#[test] +fn tool_config_shares_base_arc_when_ui_control_toggle_off() { + use super::factory::resolve_tool_config; + use std::sync::Arc; + + let tmp = tempfile::TempDir::new().unwrap(); + let mut cfg = test_config(&tmp); + cfg.computer_control.ax_interact_mutations = false; + let base = Arc::new(cfg); + + // No enabled tools → the App-UI-Control toggle does not fire → the tool + // registry shares the base `Arc` (a refcount bump), not a deep clone. + let resolved = resolve_tool_config(&base, &[]); + assert!( + Arc::ptr_eq(&base, &resolved), + "toggle off must reuse the base config Arc rather than deep-clone it" + ); +} + +#[test] +fn tool_config_grant_is_scoped_and_leaves_base_untouched() { + use super::factory::resolve_tool_config; + use std::sync::Arc; + + let tmp = tempfile::TempDir::new().unwrap(); + let mut cfg = test_config(&tmp); + cfg.computer_control.ax_interact_mutations = false; + let base = Arc::new(cfg); + + // Enabling `ax_interact` fires the toggle: the tool registry gets the mutation + // grant, but as a *distinct* instance — the base config (which feeds the turn + // provider + reflection hook) must stay ungranted so the grant cannot leak. + let resolved = resolve_tool_config(&base, &["ax_interact".to_string()]); + assert!( + resolved.computer_control.ax_interact_mutations, + "the tool-registry config must carry the granted mutation flag" + ); + assert!( + !Arc::ptr_eq(&base, &resolved), + "granting must produce a distinct config, not alias the shared base" + ); + assert!( + !base.computer_control.ax_interact_mutations, + "the base config must stay ungranted — the grant is scoped to the tool registry" + ); +} + +#[test] +fn tool_config_reuses_base_when_mutations_already_granted_globally() { + use super::factory::resolve_tool_config; + use std::sync::Arc; + + let tmp = tempfile::TempDir::new().unwrap(); + let mut cfg = test_config(&tmp); + cfg.computer_control.ax_interact_mutations = true; + let base = Arc::new(cfg); + + // Already granted globally (e.g. Full autonomy) → no clone even when the tool + // is enabled, since there is nothing to grant. + let resolved = resolve_tool_config(&base, &["ax_interact".to_string()]); + assert!( + Arc::ptr_eq(&base, &resolved), + "an already-granted base config must not be re-cloned" + ); +} diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index 2c02861a2a..a711f138b9 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -391,23 +391,17 @@ impl Agent { // (#3762). The actions stay approval-gated and bound by the // sensitive-app denylist; Full autonomy continues to grant this // independently via `app_control_enabled`. - let adjusted_config: Config; - let tool_config: &Config = if !config.computer_control.ax_interact_mutations - && tools::enables_app_ui_control_mutations(&enabled_tools) - { - let mut c = config.clone(); - c.computer_control.ax_interact_mutations = true; - log::debug!( - "[session-builder] action=grant_app_ui_control_mutations source=features_toggle" - ); - adjusted_config = c; - &adjusted_config - } else { - config - }; + // Share a single `Arc` across the heavyweight per-build consumers + // (the tool registry, the reflection hook, the turn provider) instead of + // deep-cloning the large `Config` at each site (#5050, Fix 1). `Config` is + // immutable after construction, so one refcounted instance is behaviourally + // identical to N independent clones. `resolve_tool_config` handles the one + // consumer that needs a *different* config — the App-UI-Control toggle. + let base_config: Arc = Arc::new(config.clone()); + let tool_config: Arc = resolve_tool_config(&base_config, &enabled_tools); let mut tools = tools::all_tools_with_runtime( - Arc::new(tool_config.clone()), + Arc::clone(&tool_config), &security, runtime, audit, @@ -416,7 +410,7 @@ impl Agent { &tool_config.http_request, &tool_config.action_dir, &tool_config.agents, - tool_config, + &tool_config, profile_skill_allowlist.as_ref(), profile_mcp_allowlist.as_deref(), ); @@ -694,11 +688,10 @@ impl Agent { Vec::new(); if config.learning.enabled { if config.learning.reflection_enabled { - // Only the reflection hook needs an owned snapshot of the - // full config, so create the `Arc` lazily inside this - // branch instead of paying for the clone whenever - // `learning.enabled` is true. - let full_config = Arc::new(config.clone()); + // The reflection hook needs an owned `Arc`; reuse the + // shared base config (a refcount bump) rather than a second deep + // clone of the full config (#5050, Fix 1). + let full_config = Arc::clone(&base_config); // For cloud reflection, wrap the provider in an Arc. // For local, no provider needed. let reflection_provider: Option< @@ -1164,7 +1157,7 @@ impl Agent { effective_agent_config.max_tool_iterations = def_cap; } let mut builder = Agent::builder() - .crate_native_provider(provider_role, std::sync::Arc::new(config.clone())) + .crate_native_provider(provider_role, Arc::clone(&base_config)) .tools(tools) .visible_tool_names(visible) .memory(memory) @@ -1221,6 +1214,41 @@ impl Agent { } } +/// Resolve the `Config` the tool registry is built from (#5050, Fix 1). +/// +/// Normally this is the shared `base_config` — returned as a refcount bump, not a +/// deep clone. The one exception is the App-UI-Control / App-Automation features +/// toggle (#3762): when the user enabled the `ax_interact` / `automate` tools in +/// Settings without global Full autonomy, the tool registry (and *only* the tool +/// registry) receives a copy of the config with `ax_interact_mutations` granted. +/// Scoping the grant here keeps the turn provider and reflection hook on the +/// ungranted base config, and clones at most once — only when the toggle fires. +pub(super) fn resolve_tool_config( + base_config: &Arc, + enabled_tools: &[String], +) -> Arc { + log::trace!( + "[session-builder] action=resolve_tool_config phase=enter enabled_tools_count={} base_ax_interact_mutations={}", + enabled_tools.len(), + base_config.computer_control.ax_interact_mutations + ); + if !base_config.computer_control.ax_interact_mutations + && tools::enables_app_ui_control_mutations(enabled_tools) + { + let mut granted = (**base_config).clone(); + granted.computer_control.ax_interact_mutations = true; + log::debug!( + "[session-builder] action=resolve_tool_config phase=exit outcome=granted_app_ui_control_mutations source=features_toggle" + ); + Arc::new(granted) + } else { + log::debug!( + "[session-builder] action=resolve_tool_config phase=exit outcome=reused_base_config" + ); + Arc::clone(base_config) + } +} + fn definition_disallows_tool(disallowed: &[String], name: &str) -> bool { disallowed.iter().any(|entry| { if let Some(prefix) = entry.strip_suffix('*') { From dfef12cac35b67178cab5a82e87920538d681a65 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 21 Jul 2026 16:21:44 +0000 Subject: [PATCH 17/56] chore(release): v0.63.0 [skip ci] --- Cargo.lock | 2 +- Cargo.toml | 2 +- app/package.json | 2 +- app/src-tauri/Cargo.lock | 4 ++-- app/src-tauri/Cargo.toml | 2 +- app/src-tauri/tauri.conf.json | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 989f24c3cf..453f3db8ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4701,7 +4701,7 @@ dependencies = [ [[package]] name = "openhuman" -version = "0.62.0" +version = "0.63.0" dependencies = [ "aes-gcm", "aho-corasick", diff --git a/Cargo.toml b/Cargo.toml index c8fbbac0d3..1a7e253409 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openhuman" -version = "0.62.0" +version = "0.63.0" edition = "2021" description = "OpenHuman core business logic and RPC server" autobins = false diff --git a/app/package.json b/app/package.json index 14ad445157..c89d6c1420 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "openhuman-app", - "version": "0.62.0", + "version": "0.63.0", "type": "module", "engines": { "node": ">=24.0.0" diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index f4eefc9f3c..0481fb2fa0 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "OpenHuman" -version = "0.62.0" +version = "0.63.0" dependencies = [ "anyhow", "async-trait", @@ -5603,7 +5603,7 @@ dependencies = [ [[package]] name = "openhuman" -version = "0.62.0" +version = "0.63.0" dependencies = [ "aes-gcm", "aho-corasick", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 16fe16764f..b9bca5904f 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "OpenHuman" -version = "0.62.0" +version = "0.63.0" description = "OpenHuman - AI-powered Super Assistant" authors = ["OpenHuman"] edition = "2021" diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 5ef257fa58..0e57858f74 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenHuman", - "version": "0.62.0", + "version": "0.63.0", "identifier": "com.openhuman.app", "build": { "beforeDevCommand": "pnpm run dev", From 846db8aadc15701c44fcc4ae718c1ee4df597789 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:39:35 +0300 Subject: [PATCH 18/56] feat(flows): render Workflow Copilot chat via the shared composer transcript (ChatThreadView) (#5097) --- .../flows/ToolActivityChip.test.tsx | 67 - app/src/components/flows/ToolActivityChip.tsx | 41 - .../flows/WorkflowCopilotPanel.test.tsx | 301 +---- .../components/flows/WorkflowCopilotPanel.tsx | 183 +-- .../features/conversations/Conversations.tsx | 991 +-------------- .../components/ChatThreadView.test.tsx | 233 ++++ .../components/ChatThreadView.tsx | 1131 +++++++++++++++++ .../toolCallEnvelope.test.ts} | 2 +- .../toolCallEnvelope.ts} | 11 +- 9 files changed, 1478 insertions(+), 1482 deletions(-) delete mode 100644 app/src/components/flows/ToolActivityChip.test.tsx delete mode 100644 app/src/components/flows/ToolActivityChip.tsx create mode 100644 app/src/features/conversations/components/ChatThreadView.test.tsx create mode 100644 app/src/features/conversations/components/ChatThreadView.tsx rename app/src/lib/{flows/copilotMessageSanitizer.test.ts => chat/toolCallEnvelope.test.ts} (98%) rename app/src/lib/{flows/copilotMessageSanitizer.ts => chat/toolCallEnvelope.ts} (91%) diff --git a/app/src/components/flows/ToolActivityChip.test.tsx b/app/src/components/flows/ToolActivityChip.test.tsx deleted file mode 100644 index d77a1be9df..0000000000 --- a/app/src/components/flows/ToolActivityChip.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; - -import ToolActivityChip from './ToolActivityChip'; - -vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) })); - -describe('ToolActivityChip', () => { - it('renders the proposing label for propose_workflow', () => { - render(); - expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent( - 'flows.copilot.tool.proposing' - ); - }); - - it('renders the proposing label for revise_workflow too', () => { - render(); - expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent( - 'flows.copilot.tool.proposing' - ); - }); - - it('renders the dry-running label for dry_run_workflow', () => { - render(); - expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent( - 'flows.copilot.tool.dryRunning' - ); - }); - - it('renders the saving label for save_workflow', () => { - render(); - expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent('flows.copilot.tool.saving'); - }); - - it('renders a generic "using tools" label for an unrecognized tool name', () => { - render(); - expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent( - 'flows.copilot.tool.usingTools' - ); - }); - - it('renders nothing for an empty toolNames array', () => { - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); - - it('renders the shared label when every tool name maps to the same label', () => { - render(); - expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent( - 'flows.copilot.tool.proposing' - ); - }); - - it('renders the generic label when tool names map to different labels', () => { - render(); - expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent( - 'flows.copilot.tool.usingTools' - ); - }); - - it('renders the generic label when one tool is unrecognized, even if another is recognized', () => { - render(); - expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent( - 'flows.copilot.tool.usingTools' - ); - }); -}); diff --git a/app/src/components/flows/ToolActivityChip.tsx b/app/src/components/flows/ToolActivityChip.tsx deleted file mode 100644 index 0d39c1ea61..0000000000 --- a/app/src/components/flows/ToolActivityChip.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/** - * ToolActivityChip — replaces raw tool-call JSON in the copilot chat with a - * compact, human-readable status pill (B25). Users should know the agent - * used a tool (it explains why the turn took longer), but they should never - * see the raw JSON arguments (e.g. the whole workflow graph payload). - */ -import { useT } from '../../lib/i18n/I18nContext'; - -interface Props { - /** Tool names extracted from the turn's tool-call envelope, in call order. */ - toolNames: string[]; -} - -/** Tools that map to a specific, more informative status label. */ -const KNOWN_TOOL_LABEL_KEYS: Record = { - propose_workflow: 'flows.copilot.tool.proposing', - revise_workflow: 'flows.copilot.tool.proposing', - dry_run_workflow: 'flows.copilot.tool.dryRunning', - save_workflow: 'flows.copilot.tool.saving', -}; - -export default function ToolActivityChip({ toolNames }: Props) { - const { t } = useT(); - if (toolNames.length === 0) return null; - - // Every tool must map to the SAME recognized label before we show a - // specific status (e.g. all of `propose_workflow`/`revise_workflow` map to - // "proposing..."); any unrecognized tool, or a mix of tools with different - // labels, falls back to a generic "Using tools..." pill rather than - // picking one tool's label arbitrarily or dumping tool names verbatim. - const labelKeys = toolNames.map(name => KNOWN_TOOL_LABEL_KEYS[name]); - const labelKey = labelKeys.every(key => key && key === labelKeys[0]) ? labelKeys[0] : undefined; - - return ( - - {t(labelKey ?? 'flows.copilot.tool.usingTools')} - - ); -} diff --git a/app/src/components/flows/WorkflowCopilotPanel.test.tsx b/app/src/components/flows/WorkflowCopilotPanel.test.tsx index f382f14692..1e896b19b0 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.test.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.test.tsx @@ -2,28 +2,30 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { WorkflowGraph, WorkflowNode } from '../../lib/flows/types'; -import type { ToolTimelineEntry, WorkflowProposal } from '../../store/chatRuntimeSlice'; +import type { WorkflowProposal } from '../../store/chatRuntimeSlice'; import WorkflowCopilotPanel from './WorkflowCopilotPanel'; vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) })); -interface MockMessage { - id: string; - content: string; - sender: 'user' | 'agent'; - extraMetadata?: { isInterim?: boolean }; -} +// The panel now delegates its entire transcript to the shared `ChatThreadView` +// (message bubbles, tool timeline, sub-agent drawer, streaming previews). That +// component reads the real Redux store; its rendering — including the B25 +// tool-call-envelope unwrap and interim-narration handling — is covered by +// `features/conversations/components/ChatThreadView.test.tsx`. Here we stub it +// so these tests stay focused on the copilot's OWN authoring behavior (the +// `flows_build` send path, seed auto-sends, and the proposal / capped cards) +// without needing a Redux Provider. +vi.mock('../../features/conversations/components/ChatThreadView', () => ({ + ChatThreadView: ({ emptyContent }: { emptyContent?: unknown }) => ( +
{emptyContent as never}
+ ), +})); const hookState = vi.hoisted(() => ({ + threadId: null as string | null, sending: false, proposal: null as WorkflowProposal | null, capped: false, - // Panel renders `displayMessages` (already interim-filtered upstream by - // `useWorkflowBuilderChat`) — kept separate from `messages` in these tests - // so a mismatch between the two proves the panel is reading the right field. - displayMessages: [] as MockMessage[], - toolTimeline: [] as ToolTimelineEntry[], - liveResponse: '', error: null as string | null, send: vi.fn(), clearProposal: vi.fn(), @@ -50,12 +52,10 @@ const baseGraph = graph(['a', 'b']); describe('WorkflowCopilotPanel', () => { beforeEach(() => { + hookState.threadId = null; hookState.sending = false; hookState.proposal = null; hookState.capped = false; - hookState.displayMessages = []; - hookState.toolTimeline = []; - hookState.liveResponse = ''; hookState.error = null; hookState.send = vi.fn().mockResolvedValue({ outcome: 'dispatched', proposed: false }); hookState.clearProposal = vi.fn(); @@ -147,268 +147,13 @@ describe('WorkflowCopilotPanel', () => { expect(thirdArg.request.instruction).toBe('also add a filter step'); }); - it('renders the conversation transcript (user + agent turns)', () => { - hookState.displayMessages = [ - { id: 'm1', content: 'add a Slack step', sender: 'user' }, - { id: 'm2', content: 'Done — proposed a Slack notification.', sender: 'agent' }, - ]; - render( - - ); - expect(screen.getByTestId('workflow-copilot-user')).toHaveTextContent('add a Slack step'); - expect(screen.getByTestId('workflow-copilot-agent')).toHaveTextContent( - 'Done — proposed a Slack notification.' - ); - // With a transcript present, the empty-state hint is gone. - expect(screen.queryByTestId('workflow-copilot-empty')).not.toBeInTheDocument(); - }); - - it('B25: unwraps a raw tool-call envelope message into clean text + a tool activity chip, never raw JSON', () => { - // Repro for B25: a turn that both talks and calls a tool can land in the - // thread transcript as the provider wire-format `{ content, tool_calls }` - // envelope. The panel must render only the human text — never the raw - // JSON — plus a compact status chip for the tool activity. - hookState.displayMessages = [ - { id: 'm1', content: 'build me a Slack digest', sender: 'user' }, - { - id: 'm2', - content: JSON.stringify({ - content: "Here's the workflow I propose.", - tool_calls: [{ id: 'call_1', name: 'propose_workflow', arguments: '{"nodes":[]}' }], - }), - sender: 'agent', - }, - ]; - render( - - ); - const bubble = screen.getByTestId('workflow-copilot-agent'); - expect(bubble).toHaveTextContent("Here's the workflow I propose."); - // The raw envelope must never reach the DOM as text. - expect(bubble).not.toHaveTextContent('tool_calls'); - expect(bubble).not.toHaveTextContent('"nodes":[]'); - expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent( - 'flows.copilot.tool.proposing' - ); - }); - - it('does not render an isInterim agent message as a bubble, only the terminal one', () => { - // The panel only ever renders `displayMessages` — the same filtered set - // `useWorkflowBuilderChat` computes from the raw transcript (isInterim - // agent messages dropped since that narration already streams live via - // the tool timeline / live text). Mirror that filter here so this test - // documents (and would catch a regression in) the composition: an - // isInterim message must never reach the panel as a bubble, while the - // terminal (non-interim) answer still does. - const raw: MockMessage[] = [ - { id: 'm1', content: 'build me a Slack digest', sender: 'user' }, - { - id: 'm2', - content: 'Let me check your calendar first.', - sender: 'agent', - extraMetadata: { isInterim: true }, - }, - { id: 'm3', content: 'Done — proposed a Slack notification.', sender: 'agent' }, - ]; - hookState.displayMessages = raw.filter(m => m.sender === 'user' || !m.extraMetadata?.isInterim); - - render( - - ); - expect(screen.queryByText('Let me check your calendar first.')).not.toBeInTheDocument(); - expect(screen.getByTestId('workflow-copilot-agent')).toHaveTextContent( - 'Done — proposed a Slack notification.' - ); - }); - - it('renders the shared tool timeline + streaming reply during a builder turn', () => { - hookState.sending = true; - hookState.toolTimeline = [ - { id: 'call-1', name: 'propose_workflow', round: 0, status: 'running' } as ToolTimelineEntry, - ]; - hookState.liveResponse = 'Drafting your workflow…'; - render( - - ); - // The shared ToolTimelineBlock renders (not the bespoke transcript), and the - // one-shot "thinking" placeholder is suppressed once activity is streaming. - expect(screen.getByTestId('workflow-copilot-timeline')).toBeInTheDocument(); - expect(screen.queryByTestId('workflow-copilot-thinking')).not.toBeInTheDocument(); - }); - - it('shows the live reply as a bubble before the first tool call streams', () => { - hookState.sending = true; - hookState.toolTimeline = []; - hookState.liveResponse = 'Thinking about your Slack digest…'; - render( - - ); - expect(screen.getByTestId('workflow-copilot-streaming')).toHaveTextContent( - 'Thinking about your Slack digest…' - ); - // No tool timeline yet, and the plain "thinking" line is replaced by the - // streamed text. - expect(screen.queryByTestId('workflow-copilot-timeline')).not.toBeInTheDocument(); - expect(screen.queryByTestId('workflow-copilot-thinking')).not.toBeInTheDocument(); - }); - - // Regression coverage for the "copilot chat gets stuck" bug: the panel used - // to force-scroll to the bottom on every render of a streaming turn (an - // unconditional `scrollTo` effect keyed on messages/tool timeline/live - // text), which fought a user trying to scroll up to read. The panel now - // delegates to the shared `useStickToBottom` hook (same one the main chat - // surfaces use) — these tests exercise the REAL hook (not mocked) wired - // through the actual transcript container. - describe('transcript scroll pinning (#regression: chat gets stuck)', () => { - function scrollContainer() { - return screen.getByTestId('workflow-copilot-transcript'); - } - - // jsdom performs no real layout, so scroll metrics are inert unless - // defined explicitly — mirrors the approach in useStickToBottom.test.ts. - function mockScrollMetrics( - el: HTMLElement, - metrics: { scrollTop: number; scrollHeight: number; clientHeight: number } - ) { - Object.defineProperty(el, 'scrollHeight', { - configurable: true, - value: metrics.scrollHeight, - }); - Object.defineProperty(el, 'clientHeight', { - configurable: true, - value: metrics.clientHeight, - }); - Object.defineProperty(el, 'scrollTop', { - configurable: true, - writable: true, - value: metrics.scrollTop, - }); - } - - function renderPanel() { - return render( - - ); - } - - it('keeps the transcript container freely scrollable (overflow-y-auto)', () => { - renderPanel(); - expect(scrollContainer()).toHaveClass('overflow-y-auto'); - }); - - it('auto-scrolls to the bottom when a new message arrives while the user is pinned to the bottom', () => { - hookState.displayMessages = [{ id: 'm1', content: 'hi', sender: 'user' }]; - const { rerender } = renderPanel(); - const container = scrollContainer(); - - mockScrollMetrics(container, { scrollTop: 50, scrollHeight: 100, clientHeight: 50 }); - rerender( - - ); - - // A new agent turn lands while the user never scrolled away. - hookState.displayMessages = [ - ...hookState.displayMessages, - { id: 'm2', content: 'Done — proposed a Slack notification.', sender: 'agent' }, - ]; - mockScrollMetrics(container, { scrollTop: 50, scrollHeight: 300, clientHeight: 50 }); - rerender( - - ); - - expect(container.scrollTop).toBe(300); - }); - - it('does NOT force-scroll the user back down once they have scrolled up to read history', () => { - hookState.displayMessages = [{ id: 'm1', content: 'hi', sender: 'user' }]; - const { rerender } = renderPanel(); - const container = scrollContainer(); - - mockScrollMetrics(container, { scrollTop: 50, scrollHeight: 100, clientHeight: 50 }); - rerender( - - ); - - // The user scrolls up to read earlier context, well past the stick - // threshold (400 - 0 - 50 = 350px from the bottom). - mockScrollMetrics(container, { scrollTop: 0, scrollHeight: 400, clientHeight: 50 }); - fireEvent.scroll(container); - - // A new agent turn streams in regardless — this is exactly the bug: - // the old unconditional `scrollTo` effect would yank the reader back - // to the bottom here. The container must stay put. - hookState.displayMessages = [ - ...hookState.displayMessages, - { id: 'm2', content: 'Still drafting…', sender: 'agent' }, - ]; - mockScrollMetrics(container, { scrollTop: 0, scrollHeight: 700, clientHeight: 50 }); - rerender( - - ); - - expect(container.scrollTop).toBe(0); - }); - }); + // Transcript rendering (message bubbles, the shared tool timeline + sub-agent + // drawer, streaming previews, the B25 tool-call-envelope unwrap, interim + // narration, and stick-to-bottom scroll pinning) now lives in the shared + // `ChatThreadView` and is covered by + // `features/conversations/components/ChatThreadView.test.tsx`. The panel here + // stubs that component (see the mock above), so these tests assert only the + // copilot's own authoring surface (send path, seeds, proposal / capped cards). it('surfaces a new proposal to the host and shows the added/removed diff', () => { const onProposal = vi.fn(); diff --git a/app/src/components/flows/WorkflowCopilotPanel.tsx b/app/src/components/flows/WorkflowCopilotPanel.tsx index 657e6bbb6a..af4a67ea36 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.tsx @@ -8,13 +8,16 @@ * transcript, surfaces each proposal's node-level diff, and hands Accept/Reject * up to the host, which applies it to the local draft overlay. * - * Chat UI parity: the copilot reuses the SHARED chat surface end-to-end — the - * same {@link ChatComposer} the main chat windows use (mic/attachments off - * here), turns render as bubbles via the shared {@link BubbleMarkdown}, and the - * builder turn's live tool activity + streaming reply render through the shared - * {@link ToolTimelineBlock} (fed from the runtime's `toolTimelineByThread` / - * `streamingAssistantByThread`, streamed here by Phase B). So the copilot reads - * like a real chat rather than a one-shot form. + * Chat UI parity: the copilot renders its transcript through the SAME + * {@link ChatThreadView} the home composer chat uses — message bubbles, + * past-turn insights, the shared tool timeline + sub-agent drawer, and the + * streaming / interrupted / parallel previews — driven by this copilot's + * DEDICATED thread. `flows_build` streams the `workflow_builder` turn onto + * that thread via the global `ChatRuntimeProvider` (Phase B), exactly as a + * normal chat turn streams, so the copilot reads like the real chat rather + * than a bespoke transcript. This panel keeps only the authoring concerns: + * the {@link ChatComposer} footer (mic/attachments off), the seed auto-sends, + * and the proposal-preview + capped cards pinned above the composer. * * Invariant: the copilot only PROPOSES — the agent turn itself never * persists. Accept applies the proposal to the local draft AND immediately @@ -27,18 +30,14 @@ import createDebug from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { BubbleMarkdown } from '../../features/conversations/components/AgentMessageBubble'; -import { ToolTimelineBlock } from '../../features/conversations/components/ToolTimelineBlock'; -import { useStickToBottom } from '../../hooks/useStickToBottom'; +import { ChatThreadView } from '../../features/conversations/components/ChatThreadView'; import { useWorkflowBuilderChat } from '../../hooks/useWorkflowBuilderChat'; -import { unwrapToolCallEnvelope } from '../../lib/flows/copilotMessageSanitizer'; import { diffGraphs } from '../../lib/flows/graphDiff'; import type { WorkflowGraph } from '../../lib/flows/types'; import { useT } from '../../lib/i18n/I18nContext'; import type { WorkflowProposal } from '../../store/chatRuntimeSlice'; import ChatComposer from '../chat/ChatComposer'; import Button from '../ui/Button'; -import ToolActivityChip from './ToolActivityChip'; const log = createDebug('app:flows:copilot-panel'); @@ -154,19 +153,8 @@ export default function WorkflowCopilotPanel({ fullWidth = false, }: Props) { const { t } = useT(); - const { - threadId, - sending, - turnActive, - proposal, - capped, - displayMessages, - toolTimeline, - liveResponse, - error, - send, - clearProposal, - } = useWorkflowBuilderChat(seedThreadId); + const { threadId, sending, proposal, capped, error, send, clearProposal } = + useWorkflowBuilderChat(seedThreadId); const [text, setText] = useState(''); // Report the (lazily-created) thread id up so the host persists it per flow — @@ -313,36 +301,11 @@ export default function WorkflowCopilotPanel({ onPrefillSeedConsumed?.(); }, [prefillSeed, onPrefillSeedConsumed]); - // Keep the transcript pinned to the newest message / streamed activity — - // but ONLY while the user is already at (or near) the bottom. The previous - // implementation here was an unconditional `scrollTo(bottom)` effect keyed - // on every streaming dependency (messages, tool timeline, live text, …): - // it fired on every streamed token and force-scrolled regardless of where - // the user was reading, which is what made the transcript feel "stuck" — - // any attempt to scroll up got yanked back down by the very next token. - // `useStickToBottom` is the same pinning hook the main chat surfaces use: - // it only auto-scrolls while `stickingRef` is true (user at/near bottom), - // and permanently disengages the moment the user scrolls away, so reading - // history is never fought. `resetKey` is a stable constant here — this - // panel is fully unmounted/remounted on close/reopen (see the seed refs - // above), so there's no in-place "navigation" case to reset for. - const { containerRef: scrollRef } = useStickToBottom( - displayMessages, - threadId, - 'workflow-copilot' - ); - useEffect(() => { - log( - 'scroll: stick-to-bottom deps changed messages=%d thread=%s sending=%s hasProposal=%s timeline=%d liveTextLen=%d', - displayMessages.length, - threadId ?? 'null', - sending, - Boolean(proposal), - toolTimeline.length, - liveResponse.length - ); - }, [displayMessages, threadId, sending, proposal, toolTimeline, liveResponse]); - + // Transcript rendering + scroll pinning (stick-to-bottom) are owned by the + // shared `ChatThreadView` below — the copilot no longer hand-rolls the + // transcript. This component keeps only the authoring concerns: the + // structured `flows_build` send path, the seed auto-sends, and the + // proposal / capped cards surfaced in the footer. const submit = useCallback( async (raw?: string) => { const trimmed = (raw ?? text).trim(); @@ -448,14 +411,6 @@ export default function WorkflowCopilotPanel({ }, [onReject, clearProposal]); const diff = proposal ? diffGraphs(graph, proposal.graph as WorkflowGraph) : null; - const hasTimeline = toolTimeline.length > 0; - // B25: the in-flight streaming text can also carry the raw tool-call - // envelope mid-turn — unwrap once and reuse the clean text everywhere below - // (the pre-tool streaming bubble and the shared `ToolTimelineBlock`). - const liveResponseText = unwrapToolCallEnvelope(liveResponse).text; - const hasLiveText = liveResponseText.trim().length > 0; - const isEmpty = - displayMessages.length === 0 && !proposal && !sending && !error && !hasTimeline && !hasLiveText; return (
-
(null); - - useEffect(() => { - const nextUrl = imageDataUriToObjectUrl(dataUri); - setObjectUrl(nextUrl); - return () => { - if (nextUrl) URL.revokeObjectURL(nextUrl); - }; - }, [dataUri]); - - return ( - - ); -} interface ConversationsProps { /** @@ -239,20 +170,6 @@ const EMPTY_ACTIVE_THREADS: Record = {}; // the same identity when the slice field is absent (narrow test stores). const EMPTY_QUEUED_FOLLOWUPS: Record = {}; -// Stable empty reference for the per-thread past-turn timelines map, so the -// derived value keeps the same identity when the slice field is absent. -const EMPTY_TURN_TIMELINES: Record = {}; -// Sibling stable empty for the per-thread past-turn processing transcripts map -// (restore-fidelity fix 1). -const EMPTY_TURN_TRANSCRIPTS: Record = {}; -// Stable empty transcript for a past turn that has tool rows but no persisted -// reasoning/narration trail (legacy snapshot), so `PastTurnInsights` falls back -// to the tool-only view without allocating a fresh array each render. -const EMPTY_TRANSCRIPT: ProcessingTranscriptItem[] = []; -// Stable empty tool-row list for a transcript-only past turn (agent thought / -// narrated but ran no tools). -const EMPTY_TRANSCRIPT_ENTRIES: ToolTimelineEntry[] = []; - export function isComposerInteractionBlocked(args: { /** Whether the *currently selected* thread has an in-flight inference turn. */ selectedThreadActive: boolean; @@ -333,19 +250,10 @@ const Conversations = ({ const [inputValue, setInputValue] = useState(''); const [attachments, setAttachments] = useState([]); const fileInputRef = useRef(null); - const [copiedMessageId, setCopiedMessageId] = useState(null); - // Sub-agent whose full live transcript is open in the drawer, keyed by the - // owning timeline row's spawn `taskId`. Null when the drawer is closed. - const [openSubagentTaskId, setOpenSubagentTaskId] = useState(null); - // Detached background sub-agents (spawn_async_subagent) panel visibility. - const [showBackgroundProcesses, setShowBackgroundProcesses] = useState(false); - // Whether the consolidated "Agent Process Source" panel is open (the full - // agent-run timeline + visited sources for the current thread). - const [showProcessSource, setShowProcessSource] = useState(false); - // When the user clicks a step's "View details →", the Agent Process Source - // panel is scoped to that single step. `null` = the whole-run overview - // (opened by the bottom "View full agent process Source" link). - const [scopedDetailEntryId, setScopedDetailEntryId] = useState(null); + // Imperative handle onto the transcript's own background-processes panel + // (its state now lives inside `ChatThreadView`) so the header badge below + // can still open it without lifting that state back up. + const threadViewRef = useRef(null); const [inputMode, setInputMode] = useState('text'); const [replyMode, setReplyMode] = useState('text'); const [isRecording, setIsRecording] = useState(false); @@ -410,10 +318,6 @@ const Conversations = ({ // behaviour stays intact. const uiLocale = useAppSelector(state => state.locale?.current ?? 'en'); const toolTimelineByThread = useAppSelector(state => state.chatRuntime.toolTimelineByThread); - const turnTimelinesByThread = useAppSelector(state => state.chatRuntime.turnTimelinesByThread); - const turnTranscriptsByThread = useAppSelector( - state => state.chatRuntime.turnTranscriptsByThread - ); const interruptedAssistantByThread = useAppSelector( state => state.chatRuntime.interruptedAssistantByThread ); @@ -447,17 +351,6 @@ const Conversations = ({ const inferenceHeartbeatByThread = useAppSelector( state => state.chatRuntime.inferenceHeartbeatByThread ); - const parallelStreamsByThread = useAppSelector( - state => state.chatRuntime.parallelStreamsByThread - ); - const agentMessageViewMode = useAppSelector( - state => state.theme?.agentMessageViewMode ?? 'bubbles' - ); - // When ON, the verbose per-agent "Agentic task insights" timeline is hidden - // from chat; a compact blinking "Processing" link (and the existing message - // bubble loading) stand in for it, with the full run one click away in the - // Agent Process Source side panel. See themeSlice.hideAgentInsights. - const hideAgentInsights = useAppSelector(state => state.theme?.hideAgentInsights ?? false); const inferenceTurnLifecycleByThread = useAppSelector( state => state.chatRuntime.inferenceTurnLifecycleByThread ); @@ -465,7 +358,6 @@ const Conversations = ({ state => state.chatRuntime.queuedFollowupsByThread ?? EMPTY_QUEUED_FOLLOWUPS ); const rustChat = useRustChat(); - const [reactionPickerMsgId, setReactionPickerMsgId] = useState(null); // Inline thread-title rename in the sidebar thread list — keyed by the // thread id being edited (null = none) so any row can rename in place. const [editingThreadId, setEditingThreadId] = useState(null); @@ -803,12 +695,6 @@ const Conversations = ({ }); }, [dispatch]); - const { containerRef: messagesContainerRef, endRef: messagesEndRef } = useStickToBottom( - messages, - selectedThreadId, - location.pathname - ); - useEffect(() => { const onDictationInsert = (event: Event) => { const customEvent = event as CustomEvent<{ text?: string; autoSend?: boolean }>; @@ -1776,16 +1662,12 @@ const Conversations = ({ } }; - const handleCopyMessage = async (messageId: string, content: string) => { - try { - await navigator.clipboard.writeText(content); - setCopiedMessageId(messageId); - setTimeout(() => setCopiedMessageId(null), 1500); - } catch { - // Clipboard API not available — silently fail - } - }; - + // NOTE: the transcript-local derivations that used to live here (copy, + // sub-agent drawer, past-turn timelines, agent insights, streaming preview, + // etc.) moved into `ChatThreadView` (`./components/ChatThreadView.tsx`), + // which now owns the message-list rendering keyed by `threadId` instead of + // the global `selectedThreadId`. What remains here is what the header badge + // and the composer footer still need directly. const selectedThreadToolTimeline = selectedThreadId ? (toolTimelineByThread[selectedThreadId] ?? []) : []; @@ -1793,6 +1675,9 @@ const Conversations = ({ ? (processingByThread[selectedThreadId] ?? []) : []; // Detached background sub-agents (mode === 'async') spawned in this thread. + // Kept here (in addition to ChatThreadView's own copy) because the header's + // background-processes badge needs the count/status without reaching into + // the transcript component. const backgroundProcesses = useMemo( () => selectBackgroundProcesses(selectedThreadToolTimeline), [selectedThreadToolTimeline] @@ -1801,12 +1686,6 @@ const Conversations = ({ // Poll-free live signal: lights the badge when memories are syncing even if // no sub-agent is running and the panel is closed. const memorySyncActive = useMemorySyncActive(); - // Re-derive the open subagent's live activity (and its row status) from the - // timeline on every render so the drawer streams token-by-token as - // subagent_text_delta / subagent_thinking_delta events land in Redux. - const openSubagentEntry = openSubagentTaskId - ? selectedThreadToolTimeline.find(entry => entry.subagent?.taskId === openSubagentTaskId) - : undefined; const selectedTaskBoard = selectedThreadId ? (taskBoardByThread[selectedThreadId] ?? null) : null; const hasTaskBoard = Boolean(selectedTaskBoard?.cards.length); // A plan the orchestrator parked for interactive review (request_plan_review @@ -1824,75 +1703,6 @@ const Conversations = ({ : null; const visibleMessages = messages.filter(msg => !msg.extraMetadata?.hidden); const hasVisibleMessages = visibleMessages.length > 0; - const latestVisibleMessage = visibleMessages[visibleMessages.length - 1] ?? null; - const latestVisibleAgentMessage = [...visibleMessages] - .reverse() - .find(msg => msg.sender === 'agent'); - // Message list sourced from the unified timeline projection — the single - // source of render order (see `docs/plans/conversations-timeline-refactor.md` - // Phase 2). With the streaming/tool inputs omitted the projection yields - // exactly the visible messages in order; the tool-timeline block and streaming - // previews stay anchored inline below, so the rendered DOM is unchanged. This - // routes the live render loop through the projection ahead of the per-turn - // grouping in Phase 5. - const timelineMessages = useMemo( - () => - buildThreadTimeline({ - threadId: selectedThreadId ?? '', - messages: visibleMessages, - toolTimeline: [], - streaming: null, - parallelStreams: [], - hideAgentInsights: false, - }) - .map(item => ('message' in item ? item.message : null)) - .filter((message): message is ThreadMessage => message !== null), - [selectedThreadId, visibleMessages] - ); - // Past-turn tool timelines (Phase 5): map the first assistant message of each - // older settled turn to that turn's timeline, so each past answer renders its - // own collapsed process trail above it. The latest turn is excluded upstream - // (it renders as the live "agent insights" anchor), so there is no double - // render. Empty for legacy messages without a `requestId`. - const selectedThreadTurnTimelines = selectedThreadId - ? (turnTimelinesByThread[selectedThreadId] ?? EMPTY_TURN_TIMELINES) - : EMPTY_TURN_TIMELINES; - // Sibling map: each past turn's persisted reasoning/narration trail, so a - // reopened turn replays its thoughts, not just its tool cards (fix 1). - const selectedThreadTurnTranscripts = selectedThreadId - ? (turnTranscriptsByThread[selectedThreadId] ?? EMPTY_TURN_TRANSCRIPTS) - : EMPTY_TURN_TRANSCRIPTS; - const pastTurnAnchors = useMemo(() => { - const anchors: Record< - string, - { entries: ToolTimelineEntry[]; transcript: ProcessingTranscriptItem[] } - > = {}; - const seen = new Set(); - for (const msg of timelineMessages) { - if (msg.sender !== 'agent') continue; - const requestId = msg.extraMetadata?.requestId; - if (typeof requestId !== 'string' || seen.has(requestId)) continue; - const entries = selectedThreadTurnTimelines[requestId] ?? EMPTY_TRANSCRIPT_ENTRIES; - const transcript = selectedThreadTurnTranscripts[requestId] ?? EMPTY_TRANSCRIPT; - // Anchor the turn when it has EITHER tool rows OR a reasoning/narration - // trail — a tool-less turn (agent only thought/narrated) must still - // render its restored thoughts above its answer (fix 1). - if (entries.length > 0 || transcript.length > 0) { - anchors[msg.id] = { entries, transcript }; - seen.add(requestId); - } - } - return anchors; - }, [timelineMessages, selectedThreadTurnTimelines, selectedThreadTurnTranscripts]); - const activeSubagentTimelineEntry = selectedThreadToolTimeline.find( - entry => entry.status === 'running' && entry.name.startsWith('subagent:') - ); - const activeToolTimelineEntry = [...selectedThreadToolTimeline] - .reverse() - .find(entry => entry.status === 'running' && !entry.name.startsWith('subagent:')); - const selectedInferenceStatus = selectedThreadId - ? (inferenceStatusByThread[selectedThreadId] ?? null) - : null; const selectedStreamingAssistant = selectedThreadId ? (streamingAssistantByThread[selectedThreadId] ?? null) : null; @@ -1902,11 +1712,6 @@ const Conversations = ({ const selectedInterruptedAssistant = selectedThreadId ? (interruptedAssistantByThread[selectedThreadId] ?? null) : null; - // Live streams for concurrent parallel (forked) turns on the selected thread, - // rendered as separate interleaved branch bubbles. - const selectedParallelStreams = selectedThreadId - ? Object.values(parallelStreamsByThread[selectedThreadId] ?? {}) - : []; const inlineCompletionSuffix = getInlineCompletionSuffix(inputValue, inlineSuggestionValue); // Blocks all composer interaction while a turn is in-flight or Rust chat is unavailable. // isSending: the *selected* thread is in-flight (drives selected-thread UI only). @@ -1956,8 +1761,6 @@ const Conversations = ({ inferenceTurnLifecycleByThread[selectedThreadId] === 'started' || inferenceTurnLifecycleByThread[selectedThreadId] === 'streaming') ); - const shouldRenderTimelineBeforeLatestAgentMessage = - selectedThreadToolTimeline.length > 0 && !isSending && Boolean(latestVisibleAgentMessage); // Live agent activity that must stay visible even before the thread's // message history has loaded: an in-flight turn, recorded tool steps, a @@ -1974,124 +1777,6 @@ const Conversations = ({ // before the durable message history loads (restore-fidelity fix 2). Boolean(selectedInterruptedAssistant); - // Anchor the "Agentic task insights" panel right after the latest turn's user - // message — processing happens *before* the answer, so it reads above the - // result (for both the live streaming preview and the settled agent bubbles). - // Anchoring on the user message (not the first/last agent message) avoids the - // multi-agent-message split from issue #3717. - const lastUserMessageId = [...visibleMessages].reverse().find(m => m.sender === 'user')?.id; - - // The insights panel (timeline + "View full agent process Source" opener), - // built once and rendered inline above the latest answer. `null` when there - // are no recorded steps for the thread. - // Open the Agent Process Source panel scoped to one step, or to the whole run. - const openScopedDetail = (entry: ToolTimelineEntry) => { - setScopedDetailEntryId(entry.id); - setShowProcessSource(true); - }; - const openWholeRunSource = () => { - setScopedDetailEntryId(null); - setShowProcessSource(true); - }; - const scopedDetailEntry = - scopedDetailEntryId != null - ? selectedThreadToolTimeline.find(e => e.id === scopedDetailEntryId) - : undefined; - - const agentInsights = - // Render when there are tool steps OR a persisted reasoning/narration - // transcript. A tool-less turn (the agent only thinks/narrates, no tool - // calls) has an empty timeline but still persists thoughts — without the - // transcript guard those thoughts would be unreachable. - selectedThreadToolTimeline.length > 0 || selectedThreadProcessing.length > 0 ? ( - <> - {hideAgentInsights ? ( - // "Hide agent thinking" is ON: suppress the verbose step rows. - // While in flight, surface a compact blinking "Processing" link; once - // settled the "View full agent process Source" opener below takes - // over (so only render this fallback when that opener won't). - isSending ? ( - - ) : !shouldRenderTimelineBeforeLatestAgentMessage ? ( - - ) : null - ) : selectedThreadToolTimeline.length > 0 ? ( - - ) : ( - // Transcript-only turn: reasoning/narration was streamed but no tool - // calls were made, so the inline step timeline is empty. The thoughts - // are still persisted — surface a standalone opener (matching the - // settled insights header) so the full-run panel stays reachable. - - )} - {/* "View full agent process Source" — only needed in the hidden-insights - settled state; when the timeline is visible the link lives in its - header (ToolTimelineBlock onViewWholeRun). */} - {shouldRenderTimelineBeforeLatestAgentMessage && hideAgentInsights && ( - - )} - - ) : null; - - // Standalone fallback slot (rendered once, below all messages) for the - // rare thread with no user message at all (e.g. a proactive-only run), so - // `agentInsights` is never unreachable. This slot sits at a fixed JSX - // position with no per-thread key of its own, so switching directly - // between two threads that both hit this fallback (e.g. two proactive-only - // threads) would otherwise reuse the same `ToolTimelineBlock` instance - // instead of remounting it — leaking its sticky `userOverrideOpen` - // disclosure state from the old thread into the new one (flagged in - // review on #4942). Keying on thread id forces a clean remount on every - // thread switch, matching the `key={msg.id}` pattern used for the in-flow - // timeline above. - const proactiveInsightsFallback = (() => { - if (lastUserMessageId) return null; - return {agentInsights}; - })(); - const filteredThreads = useMemo(() => { return threads.filter(t => isThreadVisibleInTab(t, selectedLabel)); }, [threads, selectedLabel]); @@ -2238,579 +1923,27 @@ const Conversations = ({ // the absolutely-positioned floating composer. 'relative flex-1 flex flex-col min-w-0' }> -
- {isLoadingMessages ? ( -
- {Array.from({ length: 4 }).map((_, i) => ( -
-
-
- ))} -
- ) : messagesError ? ( -
- - - -

{t('chat.failedToLoadMessages')}

-

{messagesError}

- -
- ) : hasVisibleMessages || hasTaskBoard || hasLiveAgentActivity ? ( -
- {timelineMessages.map(msg => { - const isAgentTextMode = msg.sender === 'agent' && agentMessageViewMode === 'text'; - // Parsed once per message: for current messages (extraMetadata - // present, or agent messages) msg.content already has no markers, - // so this is a no-op. For legacy persisted user messages with raw - // [IMAGE:...]/[FILE:...] markers and no extraMetadata, this is - // what keeps the marker text out of both the rendered bubble and - // the copy-to-clipboard action. - const parsedContent = parseMessageImages(msg.content ?? ''); - const pastTurn = pastTurnAnchors[msg.id]; - return ( - - {/* Past-turn process trail (Phase 5 + restore-fidelity fix 1): - each older settled turn's interleaved reasoning/narration + - tool steps (and restored sub-agent transcripts), collapsed, - above the answer it produced. Falls back to tool-cards-only - for legacy snapshots with no persisted transcript. */} - {pastTurn ? ( -
- -
- ) : null} -
-
-
- {msg.sender === 'agent' ? ( -
-
- {agentMessageViewMode === 'text' ? ( - - ) : ( - splitAgentMessageIntoBubbles(msg.content).map( - (segment, index, parts) => { - const position: AgentBubblePosition = - parts.length === 1 - ? 'single' - : index === 0 - ? 'first' - : index === parts.length - 1 - ? 'last' - : 'middle'; - - return ( - - ); - } - ) - )} - {/* Reaction affordance — the closed "+", the open picker, - and the resulting reaction chips all live here, tucked - onto the bubble's bottom-left corner so the control - never jumps to a separate row below the timestamp. */} - {latestVisibleMessage?.id === msg.id && - (() => { - const myReactions = - (msg.extraMetadata?.myReactions as string[] | undefined) ?? []; - const pickerOpen = reactionPickerMsgId === msg.id; - return ( -
- {myReactions.map(emoji => ( - - ))} - {pickerOpen ? ( -
- {['👍', '❤️', '😂', '🔥', '👀', '🎯'].map(emoji => ( - - ))} - -
- ) : ( - - )} -
- ); - })()} -
- {/* Stopped marker (#4862): the partial reply that was - preserved when the user hit Stop / ESC mid-stream. */} - {msg.extraMetadata?.stopped === true && ( -

- - - - {t('chat.stoppedByUser')} -

- )} - {(() => { - const raw = msg.extraMetadata?.citations; - if (!Array.isArray(raw)) return null; - const citations = raw.filter( - (item): item is MessageCitation => - typeof item === 'object' && - item !== null && - typeof (item as MessageCitation).id === 'string' && - typeof (item as MessageCitation).key === 'string' && - typeof (item as MessageCitation).snippet === 'string' && - typeof (item as MessageCitation).timestamp === 'string' - ); - if (citations.length === 0) return null; - return ; - })()} - {latestVisibleMessage?.id === msg.id && ( -

- {formatRelativeTime(msg.createdAt)} -

- )} -
- ) : ( -
- {(() => { - const displayText = parsedContent.text; - const dataUris = ( - Array.isArray(msg.extraMetadata?.attachmentDataUris) - ? (msg.extraMetadata.attachmentDataUris as string[]) - : parsedContent.dataUris - ).filter(src => SAFE_IMAGE_DATA_URI_RE.test(src)); - const hasImages = dataUris.length > 0; - // Document attachments carry no image data-URI (only - // images do); surface them as filename chips from the - // persisted attachmentKinds/attachmentNames metadata. - const kinds = Array.isArray(msg.extraMetadata?.attachmentKinds) - ? (msg.extraMetadata.attachmentKinds as string[]) - : []; - const names = Array.isArray(msg.extraMetadata?.attachmentNames) - ? (msg.extraMetadata.attachmentNames as string[]) - : []; - const fileNames = kinds - .map((k, i) => (k === 'file' ? names[i] : null)) - .filter((n): n is string => Boolean(n)); - const posters = Array.isArray(msg.extraMetadata?.attachmentPosters) - ? (msg.extraMetadata.attachmentPosters as (string | null)[]) - : []; - const videoItems = kinds - .map((k, i) => - k === 'video' - ? { name: names[i] ?? '', poster: posters[i] ?? null } - : null - ) - .filter((v): v is { name: string; poster: string | null } => - Boolean(v) - ); - const showTime = latestVisibleMessage?.id === msg.id; - return ( - <> - {hasImages && ( -
- {dataUris.map((uri, i) => ( - - ))} -
- )} - {videoItems.length > 0 && ( -
- {videoItems.map((video, i) => ( -
- {video.poster ? ( -
- - - - - - -
- ) : ( - - - - )} - {video.name} -
- ))} -
- )} - {fileNames.length > 0 && ( -
- {fileNames.map((name, i) => ( -
- - - - - {name} -
- ))} -
- )} - {(displayText || showTime) && ( -
- {displayText && ( - - )} - {showTime && ( -

- {formatRelativeTime(msg.createdAt)} -

- )} -
- )} - - ); - })()} -
- )} - - {msg.sender === 'agent' && ( - - )} -
-
-
- {msg.id === lastUserMessageId ? agentInsights : null} -
- ); - })} - {isSending && - // Suppress the legacy 3-dot placeholder once streaming - // output (visible text or thinking) has started — the - // streaming preview bubble below takes over as the - // activity indicator. - !( - (selectedStreamingAssistant?.content.length ?? 0) > 0 || - (selectedStreamingAssistant?.thinking.length ?? 0) > 0 - ) && ( -
-
-
- - - -
-
-
- )} - {/* Streaming assistant preview — compact trailing tail of the - in-flight response. Rendered as plain text (not Markdown) to - avoid jitter from partially-parsed fences. The final bubble - replaces this via addInferenceResponse on chat_done. */} - {selectedStreamingAssistant && - (selectedStreamingAssistant.thinking.length > 0 || - selectedStreamingAssistant.content.length > 0) && ( -
-
- {selectedStreamingAssistant.thinking.length > 0 && ( -
- - - {t('chat.thinking')} - -
-                          {selectedStreamingAssistant.thinking.slice(-STREAMING_PREVIEW_CHARS)}
-                        
-
- )} - {selectedStreamingAssistant.content.length > 0 && ( -
-

- {selectedStreamingAssistant.content.length > STREAMING_PREVIEW_CHARS && ( - - )} - {selectedStreamingAssistant.content.slice(-STREAMING_PREVIEW_CHARS)} - -

-
- )} -
-
- )} - {/* Interrupted turn's partial answer (restore-fidelity fix 2): - a settled, marked-interrupted bubble surfaced on restore. Only - when NOT streaming live (the buffer is cleared by any live turn - in the slice; this guard is belt-and-braces). */} - {!isSending && selectedInterruptedAssistant ? ( - - ) : null} - {/* Parallel (forked) branch streams — concurrent turns on this - thread, each its own labeled bubble so they don't collide with - the primary stream above. */} - {selectedParallelStreams.map( - branch => - (branch.content.length > 0 || branch.thinking.length > 0) && ( -
-
-
- - {t('chat.parallelBranchLabel')} -
- {branch.content.length > 0 && ( -
-

- {branch.content.length > STREAMING_PREVIEW_CHARS && ( - - )} - {branch.content.slice(-STREAMING_PREVIEW_CHARS)} - -

-
- )} -
-
- ) - )} - {/* Inference status indicator. - For the tool_use / subagent phases this line just restates the - active row already shown in the agentic-task-insights timeline, - so suppress it once that timeline is on screen — keep it only - for the `thinking` phase (which has no timeline row yet) or when - there is no timeline to fall back on. */} - {selectedInferenceStatus && - (selectedInferenceStatus.phase === 'thinking' || - selectedThreadToolTimeline.length === 0) && ( -
- - - {selectedInferenceStatus.phase === 'thinking' && - (selectedInferenceStatus.iteration > 0 - ? t('chat.thinkingIteration').replace( - '{n}', - String(selectedInferenceStatus.iteration) - ) - : t('chat.thinkingDots'))} - {selectedInferenceStatus.phase === 'tool_use' && - `${ - formatTimelineEntry( - activeToolTimelineEntry ?? { - id: 'active-tool', - name: selectedInferenceStatus.activeTool ?? 'tool', - round: selectedInferenceStatus.iteration, - seq: 0, - status: 'running', - } - ).title - }...`} - {selectedInferenceStatus.phase === 'subagent' && - `${ - formatTimelineEntry( - activeSubagentTimelineEntry ?? { - id: 'active-subagent', - name: `subagent:${selectedInferenceStatus.activeSubagent ?? ''}`, - round: selectedInferenceStatus.iteration, - seq: 0, - status: 'running', - } - ).title - }...`} - -
- )} - {/* The "Agentic task insights" panel is rendered inline *above* the - latest answer (right after the latest turn's user message) so - processing reads before the result. `proactiveInsightsFallback` - (defined above, near `agentInsights`) covers the rare thread - with no user message at all — see its doc comment for the - per-thread keying that fix keeps this remount-safe. */} - {proactiveInsightsFallback} -
-
- ) : isNewWindow ? ( - - ) : ( -
-

{t('chat.noMessages')}

-
- )} -
+ + ) : ( +
+

{t('chat.noMessages')}

+
+ ) + } + shareAgentName={shareAgentName} + scrollResetKey={location.pathname} + pendingSendActive={selectedThreadId ? pendingSendingThreadIds.has(selectedThreadId) : false} + /> {/* Full-width fade so messages dissolve into the background (black/white per theme) behind the floating composer. Page variant only. */} @@ -3322,7 +2455,7 @@ const Conversations = ({ type="button" data-testid="background-processes-toggle" data-analytics-id="chat-header-background-processes" - onClick={() => setShowBackgroundProcesses(true)} + onClick={() => threadViewRef.current?.openBackgroundProcesses()} aria-label={t('conversations.backgroundTasks.title')} title={ backgroundProcesses.length > 0 @@ -3398,50 +2531,6 @@ const Conversations = ({ modal={deleteModal} onClose={() => setDeleteModal(prev => ({ ...prev, isOpen: false }))} /> - setShowBackgroundProcesses(false)} - onOpenProcess={taskId => { - setShowBackgroundProcesses(false); - setOpenSubagentTaskId(taskId); - }} - /> - { - const taskId = openSubagentEntry.subagent!.taskId; - const result = await subagentApi.cancel(taskId); - // Only flip the row when something was actually aborted — a - // cancelled=false result means the run already finished/unknown, - // and overwriting its real terminal state would hide it. No - // terminal socket event arrives for an aborted run, so the - // optimistic mark is what surfaces the cancellation (the notice - // itself reaches chat via the idle-gated delivery path). - if (result.cancelled) { - dispatch( - markSubagentCancelled({ threadId: selectedThreadId, taskId: result.taskId }) - ); - } - } - : undefined - } - onClose={() => setOpenSubagentTaskId(null)} - /> - { - setShowProcessSource(false); - setScopedDetailEntryId(null); - }} - />
); }; diff --git a/app/src/features/conversations/components/ChatThreadView.test.tsx b/app/src/features/conversations/components/ChatThreadView.test.tsx new file mode 100644 index 0000000000..e2bffd5c7c --- /dev/null +++ b/app/src/features/conversations/components/ChatThreadView.test.tsx @@ -0,0 +1,233 @@ +/** + * Focused behavior tests for the extracted transcript scroll body. Mirrors + * the store-mounting conventions used by + * `src/pages/__tests__/Conversations.render.test.tsx` (the home chat's own + * smoke suite), but scoped to `ChatThreadView` in isolation so a second host + * (e.g. the Workflow Copilot) can be confident the component works when + * driven purely by its `threadId` prop rather than the global + * `state.thread.selectedThreadId`. + */ +import { combineReducers, configureStore } from '@reduxjs/toolkit'; +import { render, screen } from '@testing-library/react'; +import type { ComponentProps } from 'react'; +import { Provider } from 'react-redux'; +import { describe, expect, it, vi } from 'vitest'; + +import chatRuntimeReducer from '../../../store/chatRuntimeSlice'; +import themeReducer from '../../../store/themeSlice'; +import threadReducer from '../../../store/threadSlice'; +import type { Thread, ThreadMessage } from '../../../types/thread'; +import { ChatThreadView } from './ChatThreadView'; + +// useStickToBottom returns refs; mock it so layout-effects don't fire in +// jsdom (same stub the home-chat render suite uses). +vi.mock('../../../hooks/useStickToBottom', () => ({ + useStickToBottom: vi.fn(() => ({ containerRef: { current: null }, endRef: { current: null } })), +})); + +function makeThread(overrides: Partial = {}): Thread { + return { + id: 't-1', + title: 'Test thread', + chatId: null, + isActive: false, + messageCount: 0, + lastMessageAt: '2026-01-01T00:00:00.000Z', + createdAt: '2026-01-01T00:00:00.000Z', + labels: ['general'], + ...overrides, + }; +} + +function buildStore(preload: Record = {}) { + return configureStore({ + reducer: combineReducers({ + thread: threadReducer, + chatRuntime: chatRuntimeReducer, + theme: themeReducer, + }), + preloadedState: preload as never, + }); +} + +const emptyThreadState = { + threads: [], + selectedThreadId: null, + activeThreadIds: {}, + welcomeThreadId: null, + messagesByThreadId: {}, + messages: [], + isLoadingThreads: false, + isLoadingMessages: false, + messagesError: null, +}; + +function renderThreadView( + props: Partial> & { threadId: string | null }, + preload: Record = {} +) { + const store = buildStore(preload); + render( + + + + ); + return store; +} + +describe('ChatThreadView', () => { + it('renders the chat-messages-scroll container', () => { + renderThreadView({ threadId: null }, { thread: emptyThreadState }); + + expect(screen.getByTestId('chat-messages-scroll')).toBeInTheDocument(); + }); + + it('shows emptyContent when the thread has no messages, footer content, or live activity', () => { + const thread = makeThread({ id: 't-empty' }); + renderThreadView( + { threadId: thread.id, emptyContent:

Nothing here yet

}, + { + thread: { + ...emptyThreadState, + threads: [thread], + selectedThreadId: thread.id, + messagesByThreadId: { [thread.id]: [] }, + }, + } + ); + + expect(screen.getByText('Nothing here yet')).toBeInTheDocument(); + expect(screen.queryByTestId('chat-message-list')).not.toBeInTheDocument(); + }); + + it('renders a user and an agent message for the given threadId', () => { + const thread = makeThread({ id: 't-msgs' }); + const messages: ThreadMessage[] = [ + { + id: 'm-user', + sender: 'user', + type: 'text', + content: 'Hello there', + extraMetadata: {}, + createdAt: '2026-01-01T00:00:00.000Z', + }, + { + id: 'm-agent', + sender: 'agent', + type: 'text', + content: 'General Kenobi', + extraMetadata: {}, + createdAt: '2026-01-01T00:01:00.000Z', + }, + ]; + + renderThreadView( + { threadId: thread.id }, + { + thread: { + ...emptyThreadState, + threads: [thread], + selectedThreadId: thread.id, + messagesByThreadId: { [thread.id]: messages }, + }, + } + ); + + expect(screen.getByTestId('chat-message-list')).toBeInTheDocument(); + expect(screen.getByText('Hello there')).toBeInTheDocument(); + expect(screen.getByText('General Kenobi')).toBeInTheDocument(); + }); + + it('reads messages by the threadId prop, not a global selected thread', () => { + // Two threads hydrated in the store; only the prop-driven thread's + // messages should render — proving the component doesn't fall back to + // `state.thread.selectedThreadId`. + const threadA = makeThread({ id: 't-a' }); + const threadB = makeThread({ id: 't-b' }); + const messagesA: ThreadMessage[] = [ + { + id: 'a-1', + sender: 'user', + type: 'text', + content: 'Message in thread A', + extraMetadata: {}, + createdAt: '2026-01-01T00:00:00.000Z', + }, + ]; + const messagesB: ThreadMessage[] = [ + { + id: 'b-1', + sender: 'user', + type: 'text', + content: 'Message in thread B', + extraMetadata: {}, + createdAt: '2026-01-01T00:00:00.000Z', + }, + ]; + + renderThreadView( + { threadId: threadB.id }, + { + thread: { + ...emptyThreadState, + threads: [threadA, threadB], + // Deliberately select A globally — the component must still + // render B's messages because it reads `threadId` prop, not + // `selectedThreadId`. + selectedThreadId: threadA.id, + messagesByThreadId: { [threadA.id]: messagesA, [threadB.id]: messagesB }, + }, + } + ); + + expect(screen.getByText('Message in thread B')).toBeInTheDocument(); + expect(screen.queryByText('Message in thread A')).not.toBeInTheDocument(); + }); + + it('B25: unwraps a raw tool-call envelope agent message to clean text, never raw JSON', () => { + // A `workflow_builder` turn that both talks AND calls a tool can land in + // the transcript as the provider wire-format `{ content, tool_calls }` + // envelope. The shared renderer (used by both the home chat and the + // workflow copilot) must show only the human text — never the raw JSON. + const thread = makeThread({ id: 't-envelope' }); + const messages: ThreadMessage[] = [ + { + id: 'm-user', + sender: 'user', + type: 'text', + content: 'build me a Slack digest', + extraMetadata: {}, + createdAt: '2026-01-01T00:00:00.000Z', + }, + { + id: 'm-agent', + sender: 'agent', + type: 'text', + content: JSON.stringify({ + content: "Here's the workflow I propose.", + tool_calls: [{ id: 'call_1', name: 'propose_workflow', arguments: '{"nodes":[]}' }], + }), + extraMetadata: {}, + createdAt: '2026-01-01T00:01:00.000Z', + }, + ]; + + renderThreadView( + { threadId: thread.id }, + { + thread: { + ...emptyThreadState, + threads: [thread], + selectedThreadId: thread.id, + messagesByThreadId: { [thread.id]: messages }, + }, + } + ); + + const list = screen.getByTestId('chat-message-list'); + expect(list).toHaveTextContent("Here's the workflow I propose."); + // The raw envelope must never reach the DOM as text. + expect(list).not.toHaveTextContent('tool_calls'); + expect(list).not.toHaveTextContent('"nodes":[]'); + }); +}); diff --git a/app/src/features/conversations/components/ChatThreadView.tsx b/app/src/features/conversations/components/ChatThreadView.tsx new file mode 100644 index 0000000000..3fcded12b2 --- /dev/null +++ b/app/src/features/conversations/components/ChatThreadView.tsx @@ -0,0 +1,1131 @@ +import { + forwardRef, + Fragment, + type ReactNode, + useEffect, + useImperativeHandle, + useMemo, + useState, +} from 'react'; + +import { useStickToBottom } from '../../../hooks/useStickToBottom'; +import { parseMessageImages } from '../../../lib/attachments'; +import { unwrapToolCallEnvelope } from '../../../lib/chat/toolCallEnvelope'; +import { useT } from '../../../lib/i18n/I18nContext'; +import { subagentApi } from '../../../services/api/subagentApi'; +import { + markSubagentCancelled, + type ProcessingTranscriptItem, + type ToolTimelineEntry, +} from '../../../store/chatRuntimeSlice'; +import { useAppDispatch, useAppSelector } from '../../../store/hooks'; +import { persistReaction } from '../../../store/threadSlice'; +import type { ThreadMessage } from '../../../types/thread'; +import { splitAgentMessageIntoBubbles } from '../../../utils/agentMessageBubbles'; +import { formatTimelineEntry } from '../../../utils/toolTimelineFormatting'; +import { ShareMessageButton } from '../../share/ShareMessageButton'; +import { buildThreadTimeline } from '../timeline/selectors'; +import { type AgentBubblePosition, formatRelativeTime } from '../utils/format'; +import { AgentMessageBubble, AgentMessageText, BubbleMarkdown } from './AgentMessageBubble'; +import { AgentProcessSourcePanel } from './AgentProcessSourcePanel'; +import { BackgroundProcessesPanel, selectBackgroundProcesses } from './BackgroundProcessesPanel'; +import { CitationChips, type MessageCitation } from './CitationChips'; +import { InterruptedAnswer } from './InterruptedAnswer'; +import { PastTurnInsights } from './PastTurnInsights'; +import { SubagentDrawer } from './SubagentDrawer'; +import { ToolTimelineBlock } from './ToolTimelineBlock'; + +/** Maximum trailing characters rendered in the live-streaming assistant + * preview bubble. The full response is revealed via `addInferenceResponse` + * on `chat_done` — this is purely a ticker-tape affordance to signal + * progress without jumping the scroll position as tokens arrive. */ +const STREAMING_PREVIEW_CHARS = 120; + +// Matches only well-formed base64 image data URIs — guards against an +// `` XSS vector if a persisted message ever carried a crafted +// value in `attachmentDataUris`/legacy `[IMAGE:...]` markers. +const SAFE_IMAGE_DATA_URI_RE = + /^data:(image\/(?:png|jpe?g|gif|webp|bmp));base64,([a-z0-9+/=\s]+)$/i; +const EMPTY_IMAGE_SRC = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='; + +function imageDataUriToObjectUrl(src: string): string | null { + const match = SAFE_IMAGE_DATA_URI_RE.exec(src); + if (!match) return null; + try { + const mime = match[1]; + const binary = atob(match[2].replace(/\s/g, '')); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return URL.createObjectURL(new Blob([bytes], { type: mime })); + } catch { + return null; + } +} + +function AttachmentImage({ dataUri }: { dataUri: string }) { + const [objectUrl, setObjectUrl] = useState(null); + + useEffect(() => { + const nextUrl = imageDataUriToObjectUrl(dataUri); + setObjectUrl(nextUrl); + return () => { + if (nextUrl) URL.revokeObjectURL(nextUrl); + }; + }, [dataUri]); + + return ( + + ); +} + +// Stable empty reference for a thread with no persisted messages yet, so the +// selector below keeps the same identity when the slice field is absent +// (narrow test stores) or the thread hasn't hydrated any messages. +const EMPTY_MESSAGES: ThreadMessage[] = []; + +// Stable empty reference so the per-thread past-turn timelines map keeps the +// same identity when the slice field is absent (narrow test stores). +const EMPTY_TURN_TIMELINES: Record = {}; +// Sibling stable empty for the per-thread past-turn processing transcripts map +// (restore-fidelity fix 1). +const EMPTY_TURN_TRANSCRIPTS: Record = {}; +// Stable empty transcript for a past turn that has tool rows but no persisted +// reasoning/narration trail (legacy snapshot), so `PastTurnInsights` falls back +// to the tool-only view without allocating a fresh array each render. +const EMPTY_TRANSCRIPT: ProcessingTranscriptItem[] = []; +// Stable empty tool-row list for a transcript-only past turn (agent thought / +// narrated but ran no tools). +const EMPTY_TRANSCRIPT_ENTRIES: ToolTimelineEntry[] = []; + +export interface ChatThreadViewHandle { + /** Opens the detached background sub-agents panel — called from the host + * header's background-processes badge (`Conversations`' own copy of the + * badge, which needs to stay in the header so it renders regardless of + * scroll position). */ + openBackgroundProcesses(): void; +} + +export interface ChatThreadViewProps { + /** Thread whose transcript is rendered. `null` renders the empty state + * (no thread selected). */ + threadId: string | null; + /** `page` (default) is the home chat's floating-composer layout. `sidebar` + * drops the bottom-padding-reservation trick (the composer is in normal + * flow) and always applies a flat `pb-4`. */ + variant?: 'page' | 'sidebar'; + /** Page variant floats its footer over the scroll area; the host passes + * its measured `composerFooterHeight + 16` so the message list reserves + * matching bottom padding. Sidebar variant omits this (undefined — the + * message list falls back to a flat `pb-4`). */ + bottomPadding?: number; + /** The host's footer has pinned content (e.g. home's task board) that + * should keep the scroll container in "has content" layout even when + * there are no visible messages and no live agent activity yet. */ + hasFooterContent?: boolean; + /** The host's thread-load state, so the loading skeleton renders while a + * thread's messages are being fetched. Omit for hosts that hydrate + * synchronously (no skeleton). */ + isLoading?: boolean; + loadError?: string | null; + /** Rendered in place of the transcript when the thread has no visible + * messages, no footer content, and no live agent activity. */ + emptyContent?: ReactNode; + /** Agent display name threaded into the assistant message's share button. */ + shareAgentName?: string; + /** Reset key for the auto-stick-to-bottom scroll behavior (see + * `useStickToBottom`). Pass a value that changes when the surrounding + * layout/route changes shape (home passes `location.pathname`). */ + scrollResetKey?: string; + /** ORed into the in-flight check alongside the persisted inference-turn + * lifecycle — hosts that track their own "send dispatched, not yet + * accepted" window (home's `pendingSendingThreadIds`) pass it here so a + * send appears in-flight immediately, before the first socket event + * lands. */ + pendingSendActive?: boolean; +} + +/** + * The chat transcript scroll body: message list (with reactions, citations, + * copy/share affordances, past-turn insight trails, live streaming preview, + * inference status line, and the "Agentic task insights" panel), plus the + * background-processes / sub-agent-drawer / agent-process-source modals that + * are driven entirely by transcript-local state. + * + * Extracted from `Conversations.tsx` (the home chat) so a second surface + * (the Workflow Copilot, on its own dedicated thread) can reuse the exact + * same rich rendering. Everything here is keyed off the `threadId` prop + * rather than the global `state.thread.selectedThreadId` — all state reads + * are per-thread Redux slices already keyed by thread id. + */ +export const ChatThreadView = forwardRef( + ( + { + threadId, + variant = 'page', + bottomPadding, + hasFooterContent = false, + isLoading = false, + loadError = null, + emptyContent = null, + shareAgentName = 'OpenHuman', + scrollResetKey = 'chat-thread-view', + pendingSendActive = false, + }, + ref + ) => { + const { t } = useT(); + const dispatch = useAppDispatch(); + + const isSidebar = variant === 'sidebar'; + + const messages = useAppSelector(state => + threadId ? (state.thread.messagesByThreadId[threadId] ?? EMPTY_MESSAGES) : EMPTY_MESSAGES + ); + const toolTimelineByThread = useAppSelector(state => state.chatRuntime.toolTimelineByThread); + const turnTimelinesByThread = useAppSelector(state => state.chatRuntime.turnTimelinesByThread); + const turnTranscriptsByThread = useAppSelector( + state => state.chatRuntime.turnTranscriptsByThread + ); + const interruptedAssistantByThread = useAppSelector( + state => state.chatRuntime.interruptedAssistantByThread + ); + const processingByThread = useAppSelector(state => state.chatRuntime.processingByThread); + const inferenceStatusByThread = useAppSelector( + state => state.chatRuntime.inferenceStatusByThread + ); + const streamingAssistantByThread = useAppSelector( + state => state.chatRuntime.streamingAssistantByThread + ); + const parallelStreamsByThread = useAppSelector( + state => state.chatRuntime.parallelStreamsByThread + ); + const inferenceTurnLifecycleByThread = useAppSelector( + state => state.chatRuntime.inferenceTurnLifecycleByThread + ); + const agentMessageViewMode = useAppSelector( + state => state.theme?.agentMessageViewMode ?? 'bubbles' + ); + // When ON, the verbose per-agent "Agentic task insights" timeline is hidden + // from chat; a compact blinking "Processing" link (and the existing message + // bubble loading) stand in for it, with the full run one click away in the + // Agent Process Source side panel. See themeSlice.hideAgentInsights. + const hideAgentInsights = useAppSelector(state => state.theme?.hideAgentInsights ?? false); + + const [copiedMessageId, setCopiedMessageId] = useState(null); + // Sub-agent whose full live transcript is open in the drawer, keyed by the + // owning timeline row's spawn `taskId`. Null when the drawer is closed. + const [openSubagentTaskId, setOpenSubagentTaskId] = useState(null); + // Detached background sub-agents (spawn_async_subagent) panel visibility. + const [showBackgroundProcesses, setShowBackgroundProcesses] = useState(false); + // Whether the consolidated "Agent Process Source" panel is open (the full + // agent-run timeline + visited sources for the current thread). + const [showProcessSource, setShowProcessSource] = useState(false); + // When the user clicks a step's "View details →", the Agent Process Source + // panel is scoped to that single step. `null` = the whole-run overview + // (opened by the bottom "View full agent process Source" link). + const [scopedDetailEntryId, setScopedDetailEntryId] = useState(null); + const [reactionPickerMsgId, setReactionPickerMsgId] = useState(null); + + useImperativeHandle(ref, () => ({ + openBackgroundProcesses: () => setShowBackgroundProcesses(true), + })); + + const { containerRef: messagesContainerRef, endRef: messagesEndRef } = useStickToBottom( + messages, + threadId, + scrollResetKey + ); + + const handleCopyMessage = async (messageId: string, content: string) => { + try { + await navigator.clipboard.writeText(content); + setCopiedMessageId(messageId); + setTimeout(() => setCopiedMessageId(null), 1500); + } catch { + // Clipboard API not available — silently fail + } + }; + + const selectedThreadToolTimeline = threadId ? (toolTimelineByThread[threadId] ?? []) : []; + const selectedThreadProcessing = threadId ? (processingByThread[threadId] ?? []) : []; + // Detached background sub-agents (mode === 'async') spawned in this thread. + const backgroundProcesses = useMemo( + () => selectBackgroundProcesses(selectedThreadToolTimeline), + [selectedThreadToolTimeline] + ); + // Re-derive the open subagent's live activity (and its row status) from the + // timeline on every render so the drawer streams token-by-token as + // subagent_text_delta / subagent_thinking_delta events land in Redux. + const openSubagentEntry = openSubagentTaskId + ? selectedThreadToolTimeline.find(entry => entry.subagent?.taskId === openSubagentTaskId) + : undefined; + const visibleMessages = messages.filter(msg => !msg.extraMetadata?.hidden); + const hasVisibleMessages = visibleMessages.length > 0; + const latestVisibleMessage = visibleMessages[visibleMessages.length - 1] ?? null; + const latestVisibleAgentMessage = [...visibleMessages] + .reverse() + .find(msg => msg.sender === 'agent'); + // Message list sourced from the unified timeline projection — the single + // source of render order (see `docs/plans/conversations-timeline-refactor.md` + // Phase 2). With the streaming/tool inputs omitted the projection yields + // exactly the visible messages in order; the tool-timeline block and streaming + // previews stay anchored inline below, so the rendered DOM is unchanged. This + // routes the live render loop through the projection ahead of the per-turn + // grouping in Phase 5. + const timelineMessages = useMemo( + () => + buildThreadTimeline({ + threadId: threadId ?? '', + messages: visibleMessages, + toolTimeline: [], + streaming: null, + parallelStreams: [], + hideAgentInsights: false, + }) + .map(item => ('message' in item ? item.message : null)) + .filter((message): message is ThreadMessage => message !== null), + [threadId, visibleMessages] + ); + // Past-turn tool timelines (Phase 5): map the first assistant message of each + // older settled turn to that turn's timeline, so each past answer renders its + // own collapsed process trail above it. The latest turn is excluded upstream + // (it renders as the live "agent insights" anchor), so there is no double + // render. Empty for legacy messages without a `requestId`. + const selectedThreadTurnTimelines = threadId + ? (turnTimelinesByThread[threadId] ?? EMPTY_TURN_TIMELINES) + : EMPTY_TURN_TIMELINES; + // Sibling map: each past turn's persisted reasoning/narration trail, so a + // reopened turn replays its thoughts, not just its tool cards (fix 1). + const selectedThreadTurnTranscripts = threadId + ? (turnTranscriptsByThread[threadId] ?? EMPTY_TURN_TRANSCRIPTS) + : EMPTY_TURN_TRANSCRIPTS; + const pastTurnAnchors = useMemo(() => { + const anchors: Record< + string, + { entries: ToolTimelineEntry[]; transcript: ProcessingTranscriptItem[] } + > = {}; + const seen = new Set(); + for (const msg of timelineMessages) { + if (msg.sender !== 'agent') continue; + const requestId = msg.extraMetadata?.requestId; + if (typeof requestId !== 'string' || seen.has(requestId)) continue; + const entries = selectedThreadTurnTimelines[requestId] ?? EMPTY_TRANSCRIPT_ENTRIES; + const transcript = selectedThreadTurnTranscripts[requestId] ?? EMPTY_TRANSCRIPT; + // Anchor the turn when it has EITHER tool rows OR a reasoning/narration + // trail — a tool-less turn (agent only thought/narrated) must still + // render its restored thoughts above its answer (fix 1). + if (entries.length > 0 || transcript.length > 0) { + anchors[msg.id] = { entries, transcript }; + seen.add(requestId); + } + } + return anchors; + }, [timelineMessages, selectedThreadTurnTimelines, selectedThreadTurnTranscripts]); + const activeSubagentTimelineEntry = selectedThreadToolTimeline.find( + entry => entry.status === 'running' && entry.name.startsWith('subagent:') + ); + const activeToolTimelineEntry = [...selectedThreadToolTimeline] + .reverse() + .find(entry => entry.status === 'running' && !entry.name.startsWith('subagent:')); + const selectedInferenceStatus = threadId ? (inferenceStatusByThread[threadId] ?? null) : null; + const selectedStreamingAssistant = threadId + ? (streamingAssistantByThread[threadId] ?? null) + : null; + // The partial reply an interrupted turn left behind (restore-fidelity fix 2): + // surfaced as a settled, marked-interrupted bubble on restore so a turn that + // crashed mid-answer keeps its visible work instead of rendering blank. + const selectedInterruptedAssistant = threadId + ? (interruptedAssistantByThread[threadId] ?? null) + : null; + // Live streams for concurrent parallel (forked) turns on the selected thread, + // rendered as separate interleaved branch bubbles. + const selectedParallelStreams = threadId + ? Object.values(parallelStreamsByThread[threadId] ?? {}) + : []; + + const isSending = Boolean( + threadId && + (pendingSendActive || + inferenceTurnLifecycleByThread[threadId] === 'started' || + inferenceTurnLifecycleByThread[threadId] === 'streaming') + ); + const shouldRenderTimelineBeforeLatestAgentMessage = + selectedThreadToolTimeline.length > 0 && !isSending && Boolean(latestVisibleAgentMessage); + + // Live agent activity that must stay visible even before the thread's + // message history has loaded: an in-flight turn, recorded tool steps, a + // processing transcript, or streamed prose. Without this, switching to a + // thread mid-turn rendered a blank pane (the message list is gated on + // `hasVisibleMessages`) until `loadThreadMessages` resolved — tool calls and + // streaming output silently invisible despite landing in Redux. + const hasLiveAgentActivity = + isSending || + selectedThreadToolTimeline.length > 0 || + selectedThreadProcessing.length > 0 || + Boolean(selectedStreamingAssistant) || + // An interrupted turn's restored partial answer must surface too, even + // before the durable message history loads (restore-fidelity fix 2). + Boolean(selectedInterruptedAssistant); + + // Anchor the "Agentic task insights" panel right after the latest turn's user + // message — processing happens *before* the answer, so it reads above the + // result (for both the live streaming preview and the settled agent bubbles). + // Anchoring on the user message (not the first/last agent message) avoids the + // multi-agent-message split from issue #3717. + const lastUserMessageId = [...visibleMessages].reverse().find(m => m.sender === 'user')?.id; + + // The insights panel (timeline + "View full agent process Source" opener), + // built once and rendered inline above the latest answer. `null` when there + // are no recorded steps for the thread. + // Open the Agent Process Source panel scoped to one step, or to the whole run. + const openScopedDetail = (entry: ToolTimelineEntry) => { + setScopedDetailEntryId(entry.id); + setShowProcessSource(true); + }; + const openWholeRunSource = () => { + setScopedDetailEntryId(null); + setShowProcessSource(true); + }; + const scopedDetailEntry = + scopedDetailEntryId != null + ? selectedThreadToolTimeline.find(e => e.id === scopedDetailEntryId) + : undefined; + + const agentInsights = + // Render when there are tool steps OR a persisted reasoning/narration + // transcript. A tool-less turn (the agent only thinks/narrates, no tool + // calls) has an empty timeline but still persists thoughts — without the + // transcript guard those thoughts would be unreachable. + selectedThreadToolTimeline.length > 0 || selectedThreadProcessing.length > 0 ? ( + <> + {hideAgentInsights ? ( + // "Hide agent thinking" is ON: suppress the verbose step rows. + // While in flight, surface a compact blinking "Processing" link; once + // settled the "View full agent process Source" opener below takes + // over (so only render this fallback when that opener won't). + isSending ? ( + + ) : !shouldRenderTimelineBeforeLatestAgentMessage ? ( + + ) : null + ) : selectedThreadToolTimeline.length > 0 ? ( + + ) : ( + // Transcript-only turn: reasoning/narration was streamed but no tool + // calls were made, so the inline step timeline is empty. The thoughts + // are still persisted — surface a standalone opener (matching the + // settled insights header) so the full-run panel stays reachable. + + )} + {/* "View full agent process Source" — only needed in the hidden-insights + settled state; when the timeline is visible the link lives in its + header (ToolTimelineBlock onViewWholeRun). */} + {shouldRenderTimelineBeforeLatestAgentMessage && hideAgentInsights && ( + + )} + + ) : null; + + // Standalone fallback slot (rendered once, below all messages) for the + // rare thread with no user message at all (e.g. a proactive-only run), so + // `agentInsights` is never unreachable. This slot sits at a fixed JSX + // position with no per-thread key of its own, so switching directly + // between two threads that both hit this fallback (e.g. two proactive-only + // threads) would otherwise reuse the same `ToolTimelineBlock` instance + // instead of remounting it — leaking its sticky `userOverrideOpen` + // disclosure state from the old thread into the new one (flagged in + // review on #4942). Keying on thread id forces a clean remount on every + // thread switch, matching the `key={msg.id}` pattern used for the in-flow + // timeline above. + const proactiveInsightsFallback = (() => { + if (lastUserMessageId) return null; + return {agentInsights}; + })(); + + const hasContent = hasVisibleMessages || hasFooterContent || hasLiveAgentActivity; + + return ( + <> +
+ {isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+ ))} +
+ ) : loadError ? ( +
+ + + +

{t('chat.failedToLoadMessages')}

+

{loadError}

+ +
+ ) : hasContent ? ( +
+ {timelineMessages.map(msg => { + const isAgentTextMode = msg.sender === 'agent' && agentMessageViewMode === 'text'; + // B25: an agent turn that both talks AND calls a tool can leak + // the provider wire-format `{ content, tool_calls }` JSON + // envelope as its raw `content` (see `unwrapToolCallEnvelope`). + // Unwrap agent messages to the human text so no surface (home + // chat OR the workflow copilot, which shares this renderer) + // ever paints raw JSON. Shape-based + a strict passthrough for + // ordinary prose, so this is a no-op for every non-envelope + // message — the tool activity itself renders via the timeline, + // so the extracted tool names are intentionally dropped here. + const displayContent = + msg.sender === 'agent' + ? unwrapToolCallEnvelope(msg.content ?? '').text + : (msg.content ?? ''); + // Parsed once per message: for current messages (extraMetadata + // present, or agent messages) the content already has no markers, + // so this is a no-op. For legacy persisted user messages with raw + // [IMAGE:...]/[FILE:...] markers and no extraMetadata, this is + // what keeps the marker text out of both the rendered bubble and + // the copy-to-clipboard action. + const parsedContent = parseMessageImages(displayContent); + const pastTurn = pastTurnAnchors[msg.id]; + return ( + + {/* Past-turn process trail (Phase 5 + restore-fidelity fix 1): + each older settled turn's interleaved reasoning/narration + + tool steps (and restored sub-agent transcripts), collapsed, + above the answer it produced. Falls back to tool-cards-only + for legacy snapshots with no persisted transcript. */} + {pastTurn ? ( +
+ +
+ ) : null} +
+
+
+ {msg.sender === 'agent' ? ( +
+
+ {agentMessageViewMode === 'text' ? ( + + ) : ( + splitAgentMessageIntoBubbles(displayContent).map( + (segment, index, parts) => { + const position: AgentBubblePosition = + parts.length === 1 + ? 'single' + : index === 0 + ? 'first' + : index === parts.length - 1 + ? 'last' + : 'middle'; + + return ( + + ); + } + ) + )} + {/* Reaction affordance — the closed "+", the open picker, + and the resulting reaction chips all live here, tucked + onto the bubble's bottom-left corner so the control + never jumps to a separate row below the timestamp. */} + {latestVisibleMessage?.id === msg.id && + (() => { + const myReactions = + (msg.extraMetadata?.myReactions as string[] | undefined) ?? + []; + const pickerOpen = reactionPickerMsgId === msg.id; + return ( +
+ {myReactions.map(emoji => ( + + ))} + {pickerOpen ? ( +
+ {['👍', '❤️', '😂', '🔥', '👀', '🎯'].map(emoji => ( + + ))} + +
+ ) : ( + + )} +
+ ); + })()} +
+ {/* Stopped marker (#4862): the partial reply that was + preserved when the user hit Stop / ESC mid-stream. */} + {msg.extraMetadata?.stopped === true && ( +

+ + + + {t('chat.stoppedByUser')} +

+ )} + {(() => { + const raw = msg.extraMetadata?.citations; + if (!Array.isArray(raw)) return null; + const citations = raw.filter( + (item): item is MessageCitation => + typeof item === 'object' && + item !== null && + typeof (item as MessageCitation).id === 'string' && + typeof (item as MessageCitation).key === 'string' && + typeof (item as MessageCitation).snippet === 'string' && + typeof (item as MessageCitation).timestamp === 'string' + ); + if (citations.length === 0) return null; + return ; + })()} + {latestVisibleMessage?.id === msg.id && ( +

+ {formatRelativeTime(msg.createdAt)} +

+ )} +
+ ) : ( +
+ {(() => { + const displayText = parsedContent.text; + const dataUris = ( + Array.isArray(msg.extraMetadata?.attachmentDataUris) + ? (msg.extraMetadata.attachmentDataUris as string[]) + : parsedContent.dataUris + ).filter(src => SAFE_IMAGE_DATA_URI_RE.test(src)); + const hasImages = dataUris.length > 0; + // Document attachments carry no image data-URI (only + // images do); surface them as filename chips from the + // persisted attachmentKinds/attachmentNames metadata. + const kinds = Array.isArray(msg.extraMetadata?.attachmentKinds) + ? (msg.extraMetadata.attachmentKinds as string[]) + : []; + const names = Array.isArray(msg.extraMetadata?.attachmentNames) + ? (msg.extraMetadata.attachmentNames as string[]) + : []; + const fileNames = kinds + .map((k, i) => (k === 'file' ? names[i] : null)) + .filter((n): n is string => Boolean(n)); + const posters = Array.isArray(msg.extraMetadata?.attachmentPosters) + ? (msg.extraMetadata.attachmentPosters as (string | null)[]) + : []; + const videoItems = kinds + .map((k, i) => + k === 'video' + ? { name: names[i] ?? '', poster: posters[i] ?? null } + : null + ) + .filter((v): v is { name: string; poster: string | null } => + Boolean(v) + ); + const showTime = latestVisibleMessage?.id === msg.id; + return ( + <> + {hasImages && ( +
+ {dataUris.map((uri, i) => ( + + ))} +
+ )} + {videoItems.length > 0 && ( +
+ {videoItems.map((video, i) => ( +
+ {video.poster ? ( +
+ + + + + + +
+ ) : ( + + + + )} + + {video.name} + +
+ ))} +
+ )} + {fileNames.length > 0 && ( +
+ {fileNames.map((name, i) => ( +
+ + + + + {name} +
+ ))} +
+ )} + {(displayText || showTime) && ( +
+ {displayText && ( + + )} + {showTime && ( +

+ {formatRelativeTime(msg.createdAt)} +

+ )} +
+ )} + + ); + })()} +
+ )} + + {msg.sender === 'agent' && ( + + )} +
+
+
+ {msg.id === lastUserMessageId ? agentInsights : null} +
+ ); + })} + {isSending && + // Suppress the legacy 3-dot placeholder once streaming + // output (visible text or thinking) has started — the + // streaming preview bubble below takes over as the + // activity indicator. + !( + (selectedStreamingAssistant?.content.length ?? 0) > 0 || + (selectedStreamingAssistant?.thinking.length ?? 0) > 0 + ) && ( +
+
+
+ + + +
+
+
+ )} + {/* Streaming assistant preview — compact trailing tail of the + in-flight response. Rendered as plain text (not Markdown) to + avoid jitter from partially-parsed fences. The final bubble + replaces this via addInferenceResponse on chat_done. */} + {selectedStreamingAssistant && + (selectedStreamingAssistant.thinking.length > 0 || + selectedStreamingAssistant.content.length > 0) && ( +
+
+ {selectedStreamingAssistant.thinking.length > 0 && ( +
+ + + {t('chat.thinking')} + +
+                            {selectedStreamingAssistant.thinking.slice(-STREAMING_PREVIEW_CHARS)}
+                          
+
+ )} + {selectedStreamingAssistant.content.length > 0 && ( +
+

+ {selectedStreamingAssistant.content.length > + STREAMING_PREVIEW_CHARS && ( + + )} + {selectedStreamingAssistant.content.slice(-STREAMING_PREVIEW_CHARS)} + +

+
+ )} +
+
+ )} + {/* Interrupted turn's partial answer (restore-fidelity fix 2): + a settled, marked-interrupted bubble surfaced on restore. Only + when NOT streaming live (the buffer is cleared by any live turn + in the slice; this guard is belt-and-braces). */} + {!isSending && selectedInterruptedAssistant ? ( + + ) : null} + {/* Parallel (forked) branch streams — concurrent turns on this + thread, each its own labeled bubble so they don't collide with + the primary stream above. */} + {selectedParallelStreams.map( + branch => + (branch.content.length > 0 || branch.thinking.length > 0) && ( +
+
+
+ + {t('chat.parallelBranchLabel')} +
+ {branch.content.length > 0 && ( +
+

+ {branch.content.length > STREAMING_PREVIEW_CHARS && ( + + )} + {branch.content.slice(-STREAMING_PREVIEW_CHARS)} + +

+
+ )} +
+
+ ) + )} + {/* Inference status indicator. + For the tool_use / subagent phases this line just restates the + active row already shown in the agentic-task-insights timeline, + so suppress it once that timeline is on screen — keep it only + for the `thinking` phase (which has no timeline row yet) or when + there is no timeline to fall back on. */} + {selectedInferenceStatus && + (selectedInferenceStatus.phase === 'thinking' || + selectedThreadToolTimeline.length === 0) && ( +
+ + + {selectedInferenceStatus.phase === 'thinking' && + (selectedInferenceStatus.iteration > 0 + ? t('chat.thinkingIteration').replace( + '{n}', + String(selectedInferenceStatus.iteration) + ) + : t('chat.thinkingDots'))} + {selectedInferenceStatus.phase === 'tool_use' && + `${ + formatTimelineEntry( + activeToolTimelineEntry ?? { + id: 'active-tool', + name: selectedInferenceStatus.activeTool ?? 'tool', + round: selectedInferenceStatus.iteration, + seq: 0, + status: 'running', + } + ).title + }...`} + {selectedInferenceStatus.phase === 'subagent' && + `${ + formatTimelineEntry( + activeSubagentTimelineEntry ?? { + id: 'active-subagent', + name: `subagent:${selectedInferenceStatus.activeSubagent ?? ''}`, + round: selectedInferenceStatus.iteration, + seq: 0, + status: 'running', + } + ).title + }...`} + +
+ )} + {/* The "Agentic task insights" panel is rendered inline *above* the + latest answer (right after the latest turn's user message) so + processing reads before the result. `proactiveInsightsFallback` + (defined above, near `agentInsights`) covers the rare thread + with no user message at all — see its doc comment for the + per-thread keying that fix keeps this remount-safe. */} + {proactiveInsightsFallback} +
+
+ ) : ( + emptyContent + )} +
+ setShowBackgroundProcesses(false)} + onOpenProcess={taskId => { + setShowBackgroundProcesses(false); + setOpenSubagentTaskId(taskId); + }} + /> + { + const taskId = openSubagentEntry.subagent!.taskId; + const result = await subagentApi.cancel(taskId); + // Only flip the row when something was actually aborted — a + // cancelled=false result means the run already finished/unknown, + // and overwriting its real terminal state would hide it. No + // terminal socket event arrives for an aborted run, so the + // optimistic mark is what surfaces the cancellation (the notice + // itself reaches chat via the idle-gated delivery path). + if (result.cancelled) { + dispatch(markSubagentCancelled({ threadId, taskId: result.taskId })); + } + } + : undefined + } + onClose={() => setOpenSubagentTaskId(null)} + /> + { + setShowProcessSource(false); + setScopedDetailEntryId(null); + }} + /> + + ); + } +); + +ChatThreadView.displayName = 'ChatThreadView'; diff --git a/app/src/lib/flows/copilotMessageSanitizer.test.ts b/app/src/lib/chat/toolCallEnvelope.test.ts similarity index 98% rename from app/src/lib/flows/copilotMessageSanitizer.test.ts rename to app/src/lib/chat/toolCallEnvelope.test.ts index 44db34b8fb..0a45c0c8b3 100644 --- a/app/src/lib/flows/copilotMessageSanitizer.test.ts +++ b/app/src/lib/chat/toolCallEnvelope.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { unwrapToolCallEnvelope } from './copilotMessageSanitizer'; +import { unwrapToolCallEnvelope } from './toolCallEnvelope'; describe('unwrapToolCallEnvelope', () => { it('returns plain text unchanged with no tool names', () => { diff --git a/app/src/lib/flows/copilotMessageSanitizer.ts b/app/src/lib/chat/toolCallEnvelope.ts similarity index 91% rename from app/src/lib/flows/copilotMessageSanitizer.ts rename to app/src/lib/chat/toolCallEnvelope.ts index 856e9a078e..70824e7e1f 100644 --- a/app/src/lib/flows/copilotMessageSanitizer.ts +++ b/app/src/lib/chat/toolCallEnvelope.ts @@ -1,9 +1,12 @@ import createDebug from 'debug'; /** - * copilotMessageSanitizer — defends the Flows copilot chat against rendering - * the raw provider wire-format envelope instead of clean assistant text - * (B25). + * toolCallEnvelope — defends any chat transcript against rendering the raw + * provider wire-format envelope instead of clean assistant text (B25). + * + * Applied by the shared `ChatThreadView` transcript renderer, so it guards + * BOTH the home chat and the workflow copilot (which now reuses that + * renderer). Originally lived under `lib/flows` for the copilot alone. * * The Rust core's `NativeToolDispatcher::to_provider_messages` * (`src/openhuman/agent/dispatcher.rs`) and the tinyagents bridge's @@ -14,7 +17,7 @@ import createDebug from 'debug'; * That envelope is meant to stay internal to the agent session; this * sanitizer is a shape-based (not string-match) belt-and-suspenders guard so * that if it ever leaks into a chat message's `content` (from any Rust-side - * vector, present or future), the copilot still renders only the human + * vector, present or future), the transcript still renders only the human * text — never the raw JSON — mirroring the `unwrapPayloadEnvelope` * philosophy from PR #4822 (`app/src/lib/flows/runItems.ts`). */ From f92888a5f5151c6d95f511edaf928b318b7a4ac8 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:21:24 +0530 Subject: [PATCH 19/56] feat(flows): add "Save & enable" to the Flow Canvas copilot proposal card (#5089) --- .../flows/WorkflowCopilotPanel.test.tsx | 101 +++++++++ .../components/flows/WorkflowCopilotPanel.tsx | 128 +++++++++--- app/src/lib/i18n/ar.ts | 3 + app/src/lib/i18n/bn.ts | 4 + app/src/lib/i18n/de.ts | 4 + app/src/lib/i18n/en.ts | 4 + app/src/lib/i18n/es.ts | 4 + app/src/lib/i18n/fr.ts | 4 + app/src/lib/i18n/hi.ts | 4 + app/src/lib/i18n/id.ts | 4 + app/src/lib/i18n/it.ts | 4 + app/src/lib/i18n/ko.ts | 4 + app/src/lib/i18n/pl.ts | 4 + app/src/lib/i18n/pt.ts | 4 + app/src/lib/i18n/ru.ts | 4 + app/src/lib/i18n/zh-CN.ts | 3 + app/src/pages/FlowCanvasPage.tsx | 137 +++++++++++-- .../pages/__tests__/FlowCanvasPage.test.tsx | 193 +++++++++++++++++- 18 files changed, 556 insertions(+), 57 deletions(-) diff --git a/app/src/components/flows/WorkflowCopilotPanel.test.tsx b/app/src/components/flows/WorkflowCopilotPanel.test.tsx index 1e896b19b0..5c589fb29c 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.test.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.test.tsx @@ -244,6 +244,107 @@ describe('WorkflowCopilotPanel', () => { expect(hookState.clearProposal).not.toHaveBeenCalled(); }); + // PR1 — "Save & enable": a second button next to "Accept & save" that asks + // the host to save AND arm the flow in one click, mirroring the main-chat + // `WorkflowProposalCard`'s create+arm parity. + describe('Save & enable (PR1)', () => { + it('calls onAccept with { enable: true } 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-and-enable')); + expect(onAccept).toHaveBeenCalledWith(hookState.proposal, { enable: true }); + await waitFor(() => expect(hookState.clearProposal).toHaveBeenCalledTimes(1)); + }); + + it('shows the enabling label and disables both accept buttons while the host save is in flight', async () => { + 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-and-enable')); + await waitFor(() => + expect(screen.getByTestId('workflow-copilot-accept-and-enable')).toHaveTextContent( + 'flows.copilot.enabling' + ) + ); + expect(screen.getByTestId('workflow-copilot-accept-and-enable')).toBeDisabled(); + // The plain "Accept & save" button must also be disabled while the + // enable-flavored save is in flight — the two must not race. + 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 and shows an enable-error message when the host save/enable rejects', async () => { + const onAccept = vi.fn().mockRejectedValue(new Error('enable failed')); + hookState.proposal = proposalWith(['a', 'c']); + render( + + ); + + fireEvent.click(screen.getByTestId('workflow-copilot-accept-and-enable')); + await waitFor(() => expect(onAccept).toHaveBeenCalledTimes(1)); + // The button re-enables once the rejected save settles, the proposal + // was never cleared (stays up for retry), and the dedicated enable-error + // message appears. + await waitFor(() => + expect(screen.getByTestId('workflow-copilot-accept-and-enable')).not.toBeDisabled() + ); + expect(hookState.clearProposal).not.toHaveBeenCalled(); + expect(screen.getByTestId('workflow-copilot-enable-error')).toHaveTextContent( + 'flows.copilot.enableError' + ); + }); + + it('does not show the enable-error message for a plain Accept & save failure', 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)); + await waitFor(() => expect(screen.getByTestId('workflow-copilot-accept')).not.toBeDisabled()); + expect(screen.queryByTestId('workflow-copilot-enable-error')).not.toBeInTheDocument(); + }); + }); + 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 diff --git a/app/src/components/flows/WorkflowCopilotPanel.tsx b/app/src/components/flows/WorkflowCopilotPanel.tsx index af4a67ea36..e68beeb82d 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.tsx @@ -75,8 +75,14 @@ interface Props { * 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. + * + * `opts.enable` (PR1 — "Save & enable") requests an immediate follow-up + * arm after the save succeeds, mirroring the main-chat + * `WorkflowProposalCard`'s one-click create+arm. Optional and backward + * compatible — a plain "Accept & save" click omits `opts` entirely, so it + * neither enables nor force-disables an already-enabled existing flow. */ - onAccept: (proposal: WorkflowProposal) => void | Promise; + onAccept: (proposal: WorkflowProposal, opts?: { enable?: boolean }) => void | Promise; /** Reject the pending proposal (host reverts the overlay). */ onReject: () => void; /** Close the panel. */ @@ -168,11 +174,25 @@ export default function WorkflowCopilotPanel({ const fileInputRef = useRef(null); const isComposingTextRef = useRef(false); + // Set only when a "Save & enable" attempt's `onAccept` rejects — surfaced + // as a dedicated inline message (`flows.copilot.enableError`) distinct from + // a plain "Accept & save" failure, which stays silent-but-retryable as + // before (the button re-enabling is signal enough there). Declared early + // (ahead of the proposal-surfacing effect below, which also clears it) so + // both that effect and the accept/reject handlers further down can + // reference it without a temporal-dead-zone ordering issue. + const [enableError, setEnableError] = useState(false); + // Surface each NEW proposal to the host exactly once (enter preview overlay). const lastSurfacedRef = useRef(null); useEffect(() => { if (proposal && proposal !== lastSurfacedRef.current) { lastSurfacedRef.current = proposal; + // A genuinely new proposal object replacing a prior one (e.g. a further + // revise turn) supersedes any stale "Save & enable" failure from the + // earlier proposal — clear it so the new card doesn't inherit an + // unrelated error message. + setEnableError(false); onProposal(proposal); } }, [proposal, onProposal]); @@ -376,39 +396,61 @@ export default function WorkflowCopilotPanel({ // 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]); + // `acceptState` union (rather than a plain boolean) so the two accept + // buttons ("Accept & save" / "Save & enable", PR1) can each show their own + // in-flight label while BOTH stay disabled — a save-in-flight click on the + // other button, or Reject, must not race the pending persist. If the + // host's save (or enable) 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 [acceptState, setAcceptState] = useState<'idle' | 'saving' | 'enabling'>('idle'); + const acceptBusy = acceptState !== 'idle'; + const runAccept = useCallback( + async (opts?: { enable?: boolean }) => { + // Self-guard against re-entrance: the JSX `disabled={acceptBusy}` on + // both buttons prevents a normal double-click, but `acceptState` only + // flips after the FIRST call's `setAcceptState(...)` commits — a + // second invocation racing ahead of that render (e.g. programmatic + // re-fire) must not start a second concurrent save. + if (!proposal || acceptBusy) return; + const enable = Boolean(opts?.enable); + setAcceptState(enable ? 'enabling' : 'saving'); + setEnableError(false); + log('accept: saving proposal via host onAccept enable=%s', enable); + try { + // Plain "Accept & save" calls `onAccept` with just the proposal (no + // second argument at all) — matching the pre-PR1 call signature + // exactly — so a host that doesn't care about `opts` (or a caller + // asserting on `onAccept`'s exact arguments) sees no behavioral + // change. Only "Save & enable" adds the `{ enable: true }` opts. + if (enable) { + await onAccept(proposal, opts); + } else { + await onAccept(proposal); + } + log('accept: save succeeded, clearing proposal'); + clearProposal(); + lastSurfacedRef.current = null; + } catch (err) { + log('accept: save (or enable) failed, leaving proposal visible for retry err=%o', err); + if (enable) setEnableError(true); + } finally { + setAcceptState('idle'); + } + }, + [proposal, acceptBusy, onAccept, clearProposal, setEnableError] + ); + const accept = useCallback(() => runAccept(), [runAccept]); + const acceptAndEnable = useCallback(() => runAccept({ enable: true }), [runAccept]); const reject = useCallback(() => { onReject(); clearProposal(); lastSurfacedRef.current = null; - }, [onReject, clearProposal]); + setEnableError(false); + }, [onReject, clearProposal, setEnableError]); const diff = proposal ? diffGraphs(graph, proposal.graph as WorkflowGraph) : null; @@ -492,26 +534,46 @@ export default function WorkflowCopilotPanel({ )}
-
+
+
+ {acceptState === 'idle' && enableError && ( +

+ {t('flows.copilot.enableError')} +

+ )}
)} diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 58cefa5073..145b79cbfa 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -4087,7 +4087,10 @@ const messages: TranslationMap = { 'flows.copilot.noChanges': 'هذا الاقتراح لا يغيّر أي عقدة.', 'flows.copilot.accept': 'تطبيق على المسودة', 'flows.copilot.acceptAndSave': 'قبول وحفظ', + 'flows.copilot.saveAndEnable': 'حفظ وتفعيل', 'flows.copilot.saving': 'جارٍ الحفظ…', + 'flows.copilot.enabling': 'جارٍ التفعيل…', + 'flows.copilot.enableError': 'تم الحفظ، لكن تعذّر تفعيل سير العمل. حاول تفعيله من القائمة.', 'flows.copilot.reject': 'تجاهل', 'flows.copilot.previewHint': 'جارٍ مراجعة مسودة مقترحة: لم يُحفظ شيء بعد.', 'flows.copilot.repairDisplay': 'فشل تشغيل؛ راجعه واقترح إصلاحًا.', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 330052021d..62f87ff14e 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -4187,7 +4187,11 @@ const messages: TranslationMap = { 'flows.copilot.noChanges': 'এই প্রস্তাব কোনো নোড পরিবর্তন করে না।', 'flows.copilot.accept': 'খসড়ায় প্রয়োগ করুন', 'flows.copilot.acceptAndSave': 'গ্রহণ ও সংরক্ষণ করুন', + 'flows.copilot.saveAndEnable': 'সংরক্ষণ ও সক্রিয় করুন', 'flows.copilot.saving': 'সংরক্ষণ করা হচ্ছে…', + 'flows.copilot.enabling': 'সক্রিয় করা হচ্ছে…', + 'flows.copilot.enableError': + 'সংরক্ষিত হয়েছে, কিন্তু ওয়ার্কফ্লো সক্রিয় করা যায়নি। তালিকা থেকে এটি চালু করার চেষ্টা করুন।', 'flows.copilot.reject': 'বাতিল করুন', 'flows.copilot.previewHint': 'একটি প্রস্তাবিত খসড়া পর্যালোচনা হচ্ছে: এখনও কিছু সংরক্ষণ করা হয়নি।', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 5b83faec0a..630b63ddfc 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -4306,7 +4306,11 @@ const messages: TranslationMap = { 'flows.copilot.noChanges': 'Dieser Vorschlag ändert keine Knoten.', 'flows.copilot.accept': 'Auf Entwurf anwenden', 'flows.copilot.acceptAndSave': 'Übernehmen & speichern', + 'flows.copilot.saveAndEnable': 'Speichern & aktivieren', 'flows.copilot.saving': 'Wird gespeichert…', + 'flows.copilot.enabling': 'Wird aktiviert…', + 'flows.copilot.enableError': + 'Workflow gespeichert, konnte aber nicht aktiviert werden. Versuchen Sie es erneut oder aktivieren Sie ihn auf der Workflows-Seite.', 'flows.copilot.reject': 'Verwerfen', 'flows.copilot.previewHint': 'Ein vorgeschlagener Entwurf wird geprüft: es wurde noch nichts gespeichert.', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 3368ea52ca..036908c71f 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -4880,7 +4880,11 @@ const en: TranslationMap = { 'flows.copilot.noChanges': 'No node changes in this proposal.', 'flows.copilot.accept': 'Apply to draft', 'flows.copilot.acceptAndSave': 'Accept & save', + 'flows.copilot.saveAndEnable': 'Save & enable', 'flows.copilot.saving': 'Saving…', + 'flows.copilot.enabling': 'Enabling…', + 'flows.copilot.enableError': + 'Saved, but could not enable the workflow. Try toggling it on from the list.', 'flows.copilot.reject': 'Dismiss', 'flows.copilot.previewHint': 'Reviewing a proposed draft: nothing is saved yet.', 'flows.copilot.repairDisplay': 'A run failed. Please review it and propose a fix.', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 870fc00a09..7254c56c3c 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -4259,7 +4259,11 @@ const messages: TranslationMap = { 'flows.copilot.noChanges': 'Esta propuesta no cambia ningún nodo.', 'flows.copilot.accept': 'Aplicar al borrador', 'flows.copilot.acceptAndSave': 'Aceptar y guardar', + 'flows.copilot.saveAndEnable': 'Guardar y activar', 'flows.copilot.saving': 'Guardando…', + 'flows.copilot.enabling': 'Activando…', + 'flows.copilot.enableError': + 'Guardado, pero no se pudo activar el flujo de trabajo. Actívalo desde la lista.', 'flows.copilot.reject': 'Descartar', 'flows.copilot.previewHint': 'Revisando un borrador propuesto: aún no se ha guardado nada.', 'flows.copilot.repairDisplay': 'Falló una ejecución; revísala y propón una solución.', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 2a4b49ee03..41e7d09a00 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -4287,7 +4287,11 @@ const messages: TranslationMap = { 'flows.copilot.noChanges': 'Cette proposition ne modifie aucun nœud.', 'flows.copilot.accept': 'Appliquer au brouillon', 'flows.copilot.acceptAndSave': 'Accepter et enregistrer', + 'flows.copilot.saveAndEnable': 'Enregistrer et activer', 'flows.copilot.saving': 'Enregistrement…', + 'flows.copilot.enabling': 'Activation…', + 'flows.copilot.enableError': + 'Enregistré, mais impossible d’activer le workflow. Essayez de l’activer depuis la liste.', 'flows.copilot.reject': 'Ignorer', 'flows.copilot.previewHint': 'Examen d’un brouillon proposé: rien n’est encore enregistré.', 'flows.copilot.repairDisplay': 'Une exécution a échoué ; examinez-la et proposez une correction.', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index be21c6c877..79138ea39f 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -4185,7 +4185,11 @@ const messages: TranslationMap = { 'flows.copilot.noChanges': 'यह प्रस्ताव किसी नोड को नहीं बदलता।', 'flows.copilot.accept': 'ड्राफ़्ट पर लागू करें', 'flows.copilot.acceptAndSave': 'स्वीकार करें और सहेजें', + 'flows.copilot.saveAndEnable': 'सहेजें और सक्रिय करें', 'flows.copilot.saving': 'सहेजा जा रहा है…', + 'flows.copilot.enabling': 'सक्रिय किया जा रहा है…', + 'flows.copilot.enableError': + 'सहेज लिया गया है, लेकिन वर्कफ़्लो सक्रिय नहीं हो सका। सूची से इसे चालू करने का प्रयास करें।', 'flows.copilot.reject': 'रद्द करें', 'flows.copilot.previewHint': 'एक प्रस्तावित ड्राफ़्ट की समीक्षा हो रही है: अभी कुछ सहेजा नहीं गया।', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 1a4a837230..46b4fb742c 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -4202,7 +4202,11 @@ const messages: TranslationMap = { 'flows.copilot.noChanges': 'Usulan ini tidak mengubah simpul apa pun.', 'flows.copilot.accept': 'Terapkan ke draf', 'flows.copilot.acceptAndSave': 'Terima & simpan', + 'flows.copilot.saveAndEnable': 'Simpan & aktifkan', 'flows.copilot.saving': 'Menyimpan…', + 'flows.copilot.enabling': 'Mengaktifkan…', + 'flows.copilot.enableError': + 'Tersimpan, tetapi alur kerja tidak dapat diaktifkan. Coba aktifkan dari daftar.', 'flows.copilot.reject': 'Buang', 'flows.copilot.previewHint': 'Meninjau draf yang diusulkan: belum ada yang disimpan.', 'flows.copilot.repairDisplay': 'Sebuah eksekusi gagal; periksa dan usulkan perbaikan.', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index d0e6c1bdf5..9a86d5e142 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -4256,7 +4256,11 @@ const messages: TranslationMap = { 'flows.copilot.noChanges': 'Questa proposta non modifica alcun nodo.', 'flows.copilot.accept': 'Applica alla bozza', 'flows.copilot.acceptAndSave': 'Accetta e salva', + 'flows.copilot.saveAndEnable': 'Salva e attiva', 'flows.copilot.saving': 'Salvataggio…', + 'flows.copilot.enabling': 'Attivazione…', + 'flows.copilot.enableError': + 'Workflow salvato, ma non è stato possibile attivarlo. Riprova, oppure attivalo dalla pagina Workflows.', 'flows.copilot.reject': 'Ignora', 'flows.copilot.previewHint': 'Revisione di una bozza proposta: non è stato ancora salvato nulla.', 'flows.copilot.repairDisplay': 'Un’esecuzione è fallita; esaminala e proponi una correzione.', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 315bf4b4d4..1620ecf3df 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -4140,7 +4140,11 @@ const messages: TranslationMap = { 'flows.copilot.noChanges': '이 제안은 노드를 변경하지 않습니다.', 'flows.copilot.accept': '초안에 적용', 'flows.copilot.acceptAndSave': '수락 및 저장', + 'flows.copilot.saveAndEnable': '저장 및 활성화', 'flows.copilot.saving': '저장 중…', + 'flows.copilot.enabling': '활성화 중…', + 'flows.copilot.enableError': + '저장되었지만 워크플로를 활성화하지 못했습니다. 목록에서 활성화해 보세요.', 'flows.copilot.reject': '버리기', 'flows.copilot.previewHint': '제안된 초안을 검토 중입니다: 아직 저장되지 않았습니다.', 'flows.copilot.repairDisplay': '실행이 실패했습니다. 확인하고 수정을 제안하세요.', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 51838b6e92..4a5dda9f23 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -4241,7 +4241,11 @@ const messages: TranslationMap = { 'flows.copilot.noChanges': 'Ta propozycja nie zmienia żadnego węzła.', 'flows.copilot.accept': 'Zastosuj do wersji roboczej', 'flows.copilot.acceptAndSave': 'Zaakceptuj i zapisz', + 'flows.copilot.saveAndEnable': 'Zapisz i włącz', 'flows.copilot.saving': 'Zapisywanie…', + 'flows.copilot.enabling': 'Włączanie…', + 'flows.copilot.enableError': + 'Zapisano, ale nie udało się włączyć przepływu pracy. Spróbuj włączyć go z listy.', 'flows.copilot.reject': 'Odrzuć', 'flows.copilot.previewHint': 'Przeglądasz proponowaną wersję roboczą: nic nie zostało jeszcze zapisane.', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 4faab0c694..712be4fbb7 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -4248,7 +4248,11 @@ const messages: TranslationMap = { 'flows.copilot.noChanges': 'Esta proposta não altera nenhum nó.', 'flows.copilot.accept': 'Aplicar ao rascunho', 'flows.copilot.acceptAndSave': 'Aceitar e salvar', + 'flows.copilot.saveAndEnable': 'Salvar e ativar', 'flows.copilot.saving': 'Salvando…', + 'flows.copilot.enabling': 'Ativando…', + 'flows.copilot.enableError': + 'Salvo, mas não foi possível ativar o fluxo de trabalho. Tente ativá-lo pela lista.', 'flows.copilot.reject': 'Descartar', 'flows.copilot.previewHint': 'Revisando um rascunho proposto: nada foi salvo ainda.', 'flows.copilot.repairDisplay': 'Uma execução falhou; analise-a e proponha uma correção.', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 5aa939b2c9..5bf1c48d9a 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -4226,7 +4226,11 @@ const messages: TranslationMap = { 'flows.copilot.noChanges': 'Это предложение не меняет ни одного узла.', 'flows.copilot.accept': 'Применить к черновику', 'flows.copilot.acceptAndSave': 'Принять и сохранить', + 'flows.copilot.saveAndEnable': 'Сохранить и включить', 'flows.copilot.saving': 'Сохранение…', + 'flows.copilot.enabling': 'Включение…', + 'flows.copilot.enableError': + 'Сохранено, но не удалось включить рабочий процесс. Попробуйте включить его из списка.', 'flows.copilot.reject': 'Отклонить', 'flows.copilot.previewHint': 'Просмотр предложенного черновика: пока ничего не сохранено.', 'flows.copilot.repairDisplay': 'Запуск завершился ошибкой; изучите его и предложите исправление.', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index e86dfac2a7..4861f5a83d 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -3967,7 +3967,10 @@ const messages: TranslationMap = { 'flows.copilot.noChanges': '此方案未更改任何节点。', 'flows.copilot.accept': '应用到草稿', 'flows.copilot.acceptAndSave': '接受并保存', + 'flows.copilot.saveAndEnable': '保存并启用', 'flows.copilot.saving': '保存中…', + 'flows.copilot.enabling': '启用中…', + 'flows.copilot.enableError': '已保存,但无法启用该工作流。请尝试从列表中启用它。', 'flows.copilot.reject': '放弃', 'flows.copilot.previewHint': '正在查看建议的草稿:尚未保存任何内容。', 'flows.copilot.repairDisplay': '一次运行失败了,请查看并提出修复方案。', diff --git a/app/src/pages/FlowCanvasPage.tsx b/app/src/pages/FlowCanvasPage.tsx index 7f8c793c33..a7f42381fb 100644 --- a/app/src/pages/FlowCanvasPage.tsx +++ b/app/src/pages/FlowCanvasPage.tsx @@ -44,7 +44,14 @@ import { workflowGraphToXyflow } from '../lib/flows/graphAdapter'; import { buildPreviewGraph, diffGraphs } from '../lib/flows/graphDiff'; import type { WorkflowGraph } from '../lib/flows/types'; import { useT } from '../lib/i18n/I18nContext'; -import { createFlow, type Flow, getFlow, runFlow, updateFlow } from '../services/api/flowsApi'; +import { + createFlow, + type Flow, + getFlow, + runFlow, + setFlowEnabled, + updateFlow, +} from '../services/api/flowsApi'; import type { WorkflowProposal } from '../store/chatRuntimeSlice'; import type { ToastNotification } from '../types/intelligence'; @@ -480,12 +487,30 @@ function FlowEditor({ // // Declared ahead of `handleAcceptProposal` (below), which calls it directly // to persist an accepted proposal immediately. + // + // Returns `flowId`/`flowEnabled` alongside `remounted` so a caller wanting a + // "Save & enable" follow-up (`handleAcceptProposal`'s `opts.enable`) knows + // exactly which flow id to arm and whether the persisted flow already came + // back enabled — without having to re-derive it from component state, which + // is especially important for the draft-create path: `flowId` (the prop) + // is still `null` in THIS closure even after `createFlow` resolves, since + // the draft only becomes a real flow id via the `navigate(...)` below, not + // a state update this same render can observe. const handleSave = useCallback( async ( next: WorkflowGraph, overrideName?: string, - overrideRequireApproval?: boolean - ): Promise<{ remounted: boolean }> => { + overrideRequireApproval?: boolean, + // When true, a draft-create does NOT navigate to `/flows/:id` itself — + // the caller owns navigation timing. `handleAcceptProposal`'s + // "Save & enable" needs this: it must run `setFlowEnabled` on the + // just-created flow BEFORE the route change unmounts this page, else the + // enable RPC resolves against an unmounted component (its loading/error + // state is lost and the new page shows the flow still disabled). The + // `wasDraft` flag in the return tells the caller navigation is now its + // responsibility. + deferDraftNavigation?: boolean + ): Promise<{ remounted: boolean; flowId: string; flowEnabled: boolean; wasDraft: boolean }> => { // `overrideName` covers the copilot-Accept call site: it calls // `setName(proposal.name)` and `handleSave(...)` in the same handler, // but `name` in THIS closure is still the pre-update value — React @@ -510,11 +535,24 @@ function FlowEditor({ effectiveRequireApproval ); const created = await createFlow(effectiveName, next, effectiveRequireApproval); - log('save: draft persisted as flow id=%s', created.id); - navigate(`/flows/${created.id}`, { replace: true }); + log('save: draft persisted as flow id=%s enabled=%s', created.id, created.enabled); + if (!deferDraftNavigation) { + navigate(`/flows/${created.id}`, { replace: true }); + } // Navigating replaces this whole page (new `flowId` route param), so // "remounted" is moot for a draft-create — no caller branches on it. - return { remounted: false }; + // `flowId`/`flowEnabled` DO matter — a "Save & enable" caller reads + // them to arm the just-created flow (B29 Rule 1 always persists an + // automatic-trigger draft disabled, regardless of the caller's + // intent), and this RPC response is the only place that id/enabled + // pair is available before the route change lands. `wasDraft` lets a + // `deferDraftNavigation` caller know it now owns the navigation. + return { + remounted: false, + flowId: created.id, + flowEnabled: created.enabled, + wasDraft: true, + }; } // Only include `name` / `requireApproval` in the update payload when // they actually diverge from what's already persisted (a manual @@ -565,13 +603,14 @@ function FlowEditor({ setCanvasVersion(v => v + 1); } log( - 'save: flow id=%s persisted — canvas re-synced from response nodes=%d edges=%d graphChanged=%s', + 'save: flow id=%s persisted — canvas re-synced from response nodes=%d edges=%d graphChanged=%s enabled=%s', flowId, persisted.nodes.length, persisted.edges.length, - graphChanged + graphChanged, + updated.enabled ); - return { remounted: graphChanged }; + return { remounted: graphChanged, flowId, flowEnabled: updated.enabled, wasDraft: false }; }, [isDraft, flowId, name, requireApproval, navigate] ); @@ -624,9 +663,20 @@ function FlowEditor({ // right after Accept would have done. A failed save is non-fatal: the // proposal stays applied to the (now dirty) draft and the header Save // button remains the manual retry — we never crash or revert the draft. + // + // `opts.enable` (PR1 — "Save & enable") mirrors `WorkflowProposalCard.save()` + // in the main chat surface: after a successful save, explicitly arm the + // flow via `setFlowEnabled`. This is needed because `createFlow` with an + // automatic trigger (schedule/app_event/webhook) ALWAYS persists disabled + // (B29 Rule 1, `flowsApi.ts`) regardless of what the caller passed — Rule 1 + // exists to stop a copilot autosave from silently arming an unattended + // automation, but "Save & enable" is the user's own explicit arming click, + // not a silent autosave, so it must follow up. Plain "Accept & save" (no + // `opts`) must NOT enable and must NOT force-disable an already-enabled + // existing flow — it's simply omitted from the call. const handleAcceptProposal = useCallback( - async (proposal: WorkflowProposal) => { - log('copilot proposal accepted'); + async (proposal: WorkflowProposal, opts?: { enable?: boolean }) => { + log('copilot proposal accepted: enable=%s', Boolean(opts?.enable)); const proposedGraph = proposal.graph as WorkflowGraph; setDraftGraph(proposedGraph); setPreview(null); @@ -670,10 +720,18 @@ function FlowEditor({ // `canvasVersion` bump above) so the ref's imperative handle is stale; // call `handleSave` directly with the known-good proposed graph. try { - const { remounted } = await handleSave( + const { + remounted, + flowId: savedFlowId, + flowEnabled, + wasDraft, + } = await handleSave( proposedGraph, overrideName, - proposal.requireApproval + proposal.requireApproval, + // Defer a draft-create's navigation so a "Save & enable" arms the + // flow BEFORE this page unmounts — see `deferDraftNavigation`. + true ); // The canvas remounted once already (this handler's own bump above) // with `forcedDirty` seeded `true` — correct pre-persist, but that @@ -688,15 +746,56 @@ function FlowEditor({ if (!remounted) { canvasRef.current?.clearForcedDirty(); } - log('copilot proposal accepted: persisted remounted=%s', remounted); + log( + 'copilot proposal accepted: persisted remounted=%s flowId=%s flowEnabled=%s', + remounted, + savedFlowId, + flowEnabled + ); + + // "Save & enable": follow up with an explicit arm, same as + // `WorkflowProposalCard.save()`. Fires unconditionally when + // requested (idempotent if the flow already came back enabled) — + // simpler than special-casing an already-enabled flow, and this is + // still inside the same try/catch so a failure here also leaves the + // proposal visible for retry rather than silently vanishing. + if (opts?.enable) { + log('copilot proposal accepted: enabling flow id=%s', savedFlowId); + try { + await setFlowEnabled(savedFlowId, true); + log('copilot proposal accepted: enable succeeded id=%s', savedFlowId); + } catch (enableErr) { + // The flow IS saved at this point. On a DRAFT we must still + // navigate to the created flow (below) or a retry would create a + // duplicate — so we can't keep the proposal for an in-place retry; + // swallow here and let the user arm it from the flow page (matches + // the "Saved, but could not enable" guidance). On an EXISTING flow + // there's no navigation, so rethrow to keep the proposal visible + // for retry, preserving the pre-existing behavior. + if (!wasDraft) throw enableErr; + log( + 'copilot proposal accepted: enable failed on draft; flow saved-but-disabled id=%s err=%o', + savedFlowId, + enableErr + ); + } + } + + // Draft navigation was deferred so the "Save & enable" arm could run + // first; now that persist + enable have settled, move to the real flow + // route. A non-draft accept stays on its existing `/flows/:id` page. + if (wasDraft) { + navigate(`/flows/${savedFlowId}`, { replace: true }); + } } catch (err) { - log('copilot proposal accepted: save failed err=%o', err); + log('copilot proposal accepted: save/enable failed err=%o', err); // Rethrow: the draft above is already applied unconditionally, so no // data is lost by rethrowing. This lets the caller — the copilot - // panel's own `accept` handler — see the failure and skip - // `clearProposal()`, keeping the proposal card visible for retry - // instead of silently vanishing while nothing was actually saved. - // `acceptSaving` there still resets via its own `finally`. + // panel's own `accept`/`acceptAndEnable` handler — see the failure + // and skip `clearProposal()`, keeping the proposal card visible for + // retry instead of silently vanishing while nothing was actually + // saved (or saved-but-not-enabled). `acceptSaving`/`acceptState` + // there still resets via its own `finally`. throw err; } }, diff --git a/app/src/pages/__tests__/FlowCanvasPage.test.tsx b/app/src/pages/__tests__/FlowCanvasPage.test.tsx index 6cbe4a2ca8..28596e9eb1 100644 --- a/app/src/pages/__tests__/FlowCanvasPage.test.tsx +++ b/app/src/pages/__tests__/FlowCanvasPage.test.tsx @@ -25,6 +25,7 @@ const createFlow = vi.hoisted(() => vi.fn()); const validateFlow = vi.hoisted(() => vi.fn()); const listFlowConnections = vi.hoisted(() => vi.fn()); const runFlow = vi.hoisted(() => vi.fn()); +const setFlowEnabled = vi.hoisted(() => vi.fn()); vi.mock('../../services/api/flowsApi', () => ({ getFlow, updateFlow, @@ -32,6 +33,7 @@ vi.mock('../../services/api/flowsApi', () => ({ validateFlow, listFlowConnections, runFlow, + setFlowEnabled, })); // Stub the copilot panel: it drives the real chat runtime (redux + socket), @@ -99,10 +101,12 @@ describe('FlowCanvasPage', () => { validateFlow.mockReset(); listFlowConnections.mockReset(); runFlow.mockReset(); + setFlowEnabled.mockReset(); validateFlow.mockResolvedValue({ valid: true, errors: [], warnings: [] }); listFlowConnections.mockResolvedValue([]); updateFlow.mockResolvedValue(makeFlow()); createFlow.mockResolvedValue(makeFlow({ id: 'created-id', name: 'Daily digest' })); + setFlowEnabled.mockResolvedValue(makeFlow({ enabled: true })); }); it('shows a loading state while the flow is being fetched', () => { @@ -687,6 +691,7 @@ describe('FlowCanvasPage copilot proposal name adoption', () => { createFlow.mockReset(); validateFlow.mockReset(); listFlowConnections.mockReset(); + setFlowEnabled.mockReset(); validateFlow.mockResolvedValue({ valid: true, errors: [], warnings: [] }); listFlowConnections.mockResolvedValue([]); // Accept now persists immediately (review + save in one step, see @@ -695,6 +700,7 @@ describe('FlowCanvasPage copilot proposal name adoption', () => { // by an unmocked (`undefined`-resolving) `updateFlow`/`createFlow`. updateFlow.mockResolvedValue(makeFlow()); createFlow.mockResolvedValue(makeFlow({ id: 'created-id' })); + setFlowEnabled.mockResolvedValue(makeFlow({ enabled: true })); }); function renderEditor(id = 'test-id') { @@ -710,12 +716,20 @@ describe('FlowCanvasPage copilot proposal name adoption', () => { // `handleAcceptProposal` is async (it awaits the persist call) — drive it // through `act(async () => …)` so React flushes every state update the - // resulting save produces before the test asserts on them. - function acceptProposal(proposal: WorkflowProposal = makeProposal()) { + // resulting save produces before the test asserts on them. `opts` mirrors + // the copilot panel's own "Save & enable" call (PR1) — omitted for a plain + // Accept & save, `{ enable: true }` for the enable path. + function acceptProposal( + proposal: WorkflowProposal = makeProposal(), + opts?: { enable?: boolean } + ) { return act(async () => { - await (copilotPanelProps.current?.onAccept as (p: WorkflowProposal) => Promise)( - proposal - ); + await ( + copilotPanelProps.current?.onAccept as ( + p: WorkflowProposal, + opts?: { enable?: boolean } + ) => Promise + )(proposal, opts); }); } @@ -984,6 +998,175 @@ describe('FlowCanvasPage copilot proposal name adoption', () => { await waitFor(() => expect(screen.getByTestId('flow-editor-dirty')).toBeInTheDocument()); await waitFor(() => expect(screen.getByTestId('flow-editor-save')).not.toBeDisabled()); }); + + // PR1 — "Save & enable": `handleAcceptProposal`'s `opts.enable` follow-up. + describe('Save & enable (PR1)', () => { + it('calls setFlowEnabled(flowId, true) after a successful save on an existing flow', async () => { + getFlow.mockResolvedValue(makeFlow({ id: 'test-id', enabled: false })); + updateFlow.mockResolvedValue(makeFlow({ id: 'test-id', enabled: false })); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + await acceptProposal(makeProposal(), { enable: true }); + + expect(updateFlow).toHaveBeenCalledTimes(1); + expect(setFlowEnabled).toHaveBeenCalledTimes(1); + expect(setFlowEnabled).toHaveBeenCalledWith('test-id', true); + }); + + it('calls setFlowEnabled with the newly-created id on a draft', async () => { + createFlow.mockResolvedValue(makeFlow({ id: 'created-id', name: 'Standup reminder' })); + getFlow.mockResolvedValue(makeFlow({ id: 'created-id', name: 'Standup reminder' })); + render( + + + } /> + } /> + + + ); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + await acceptProposal(makeProposal(), { enable: true }); + + expect(createFlow).toHaveBeenCalledTimes(1); + await waitFor(() => expect(setFlowEnabled).toHaveBeenCalledTimes(1)); + expect(setFlowEnabled).toHaveBeenCalledWith('created-id', true); + }); + + it('on a draft "Save & enable", runs enable BEFORE navigating, and swallows an enable failure (flow saved, armed from its own page)', async () => { + createFlow.mockResolvedValue(makeFlow({ id: 'created-id', name: 'Standup reminder' })); + getFlow.mockResolvedValue(makeFlow({ id: 'created-id', name: 'Standup reminder' })); + setFlowEnabled.mockRejectedValue(new Error('enable rpc failed')); + render( + + + } /> + } /> + + + ); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + let caughtErr: unknown; + await act(async () => { + try { + await ( + copilotPanelProps.current?.onAccept as ( + p: WorkflowProposal, + opts?: { enable?: boolean } + ) => Promise + )(makeProposal(), { enable: true }); + } catch (err) { + caughtErr = err; + } + }); + + // On a draft the create succeeds first, so the enable is attempted + // BEFORE the deferred navigation (the whole point of the fix — otherwise + // navigate would unmount this page and the enable RPC would resolve + // against a dead component). And because the flow IS saved, a draft + // enable failure must NOT rethrow: rethrowing would strand the user on + // the draft and a retry would create a DUPLICATE flow. Instead we + // navigate to the real flow and let the user arm it there. (Contrast the + // existing-flow rethrow test above, which keeps the proposal for retry.) + expect(createFlow).toHaveBeenCalledTimes(1); + await waitFor(() => expect(setFlowEnabled).toHaveBeenCalledWith('created-id', true)); + expect(caughtErr).toBeUndefined(); + // Navigation to the real flow happened afterward (its page fetches it). + await waitFor(() => expect(getFlow).toHaveBeenCalledWith('created-id')); + }); + + it('does NOT call setFlowEnabled for a plain Accept & save (no opts)', async () => { + getFlow.mockResolvedValue(makeFlow({ id: 'test-id' })); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + await acceptProposal(); + + expect(updateFlow).toHaveBeenCalledTimes(1); + expect(setFlowEnabled).not.toHaveBeenCalled(); + }); + + it('rethrows an enable failure after a successful save, so the saved flow is not lost and the caller can retry', async () => { + getFlow.mockResolvedValue(makeFlow({ id: 'test-id' })); + updateFlow.mockResolvedValue(makeFlow({ id: 'test-id' })); + setFlowEnabled.mockRejectedValue(new Error('enable rpc failed')); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + let caughtErr: unknown; + await act(async () => { + try { + await ( + copilotPanelProps.current?.onAccept as ( + p: WorkflowProposal, + opts?: { enable?: boolean } + ) => Promise + )(makeProposal(), { enable: true }); + } catch (err) { + caughtErr = err; + } + }); + + // The save itself succeeded — `updateFlow` was called and resolved — + // only the follow-up enable call failed. Rethrowing lets the copilot + // panel's own catch branch skip `clearProposal()`, keeping the card + // visible for retry (matching the plain-save failure contract). + expect(updateFlow).toHaveBeenCalledTimes(1); + expect(setFlowEnabled).toHaveBeenCalledTimes(1); + expect(caughtErr).toBeInstanceOf(Error); + expect((caughtErr as Error).message).toBe('enable rpc failed'); + }); + }); }); describe('asCopilotBuildSeed', () => { From 7ec16e8defcae73b8c230a6a91cf7a8641acd0d7 Mon Sep 17 00:00:00 2001 From: Horst1993 Date: Wed, 22 Jul 2026 00:59:36 +0800 Subject: [PATCH 20/56] fix(agentbox): normalize GMI_MAAS_BASE_URL with trailing /v1 (#5091) Co-authored-by: Cursor --- .../__tests__/builtinCloudProviders.test.ts | 1 + .../settings/panels/builtinCloudProviders.ts | 2 +- src/openhuman/agentbox/env.rs | 13 +++++- src/openhuman/agentbox/env_tests.rs | 41 +++++++++++++++++-- src/openhuman/agentbox/status.rs | 2 +- 5 files changed, 53 insertions(+), 6 deletions(-) diff --git a/app/src/components/settings/panels/__tests__/builtinCloudProviders.test.ts b/app/src/components/settings/panels/__tests__/builtinCloudProviders.test.ts index 6fce51b1dc..ec3f7dfe18 100644 --- a/app/src/components/settings/panels/__tests__/builtinCloudProviders.test.ts +++ b/app/src/components/settings/panels/__tests__/builtinCloudProviders.test.ts @@ -13,6 +13,7 @@ describe('builtinCloudProviders', () => { }); it.each([ + ['gmi', 'https://api.gmi-serving.com/v1', 'bearer'], ['groq', 'https://api.groq.com/openai/v1', 'bearer'], ['deepseek', 'https://api.deepseek.com/v1', 'bearer'], ['minimax', 'https://api.minimax.io/v1', 'bearer'], diff --git a/app/src/components/settings/panels/builtinCloudProviders.ts b/app/src/components/settings/panels/builtinCloudProviders.ts index 3fb212cb6e..3f6b762db4 100644 --- a/app/src/components/settings/panels/builtinCloudProviders.ts +++ b/app/src/components/settings/panels/builtinCloudProviders.ts @@ -64,7 +64,7 @@ export const BUILTIN_CLOUD_PROVIDERS: BuiltinCloudProvider[] = [ endpoint: 'https://api.gmi-serving.com/v1', authStyle: 'bearer', tone: TONE.fuchsia, - keyPlaceholder: 'gmi-...', + keyPlaceholder: 'eyJ....', }, { slug: 'fireworks', diff --git a/src/openhuman/agentbox/env.rs b/src/openhuman/agentbox/env.rs index c4ab78fd7e..b9b79a5555 100644 --- a/src/openhuman/agentbox/env.rs +++ b/src/openhuman/agentbox/env.rs @@ -79,12 +79,23 @@ where return Err(format!("missing/blank: {}", missing.join(", "))); } Ok(GmiConfig { - base_url: base_url.unwrap(), + base_url: normalize_gmi_maas_base_url(&base_url.unwrap()), api_key: api_key.unwrap(), model: model.unwrap(), }) } +/// Normalize `GMI_MAAS_BASE_URL` to an OpenAI-compatible `/v1` base URL. +/// +/// AgentBox injects a host-only URL (no `/v1`). `OpenAiModel` appends +/// `/chat/completions` but does not add `/v1`, so marketplace inference +/// 404s without this step. Idempotent for already-correct inputs. +pub fn normalize_gmi_maas_base_url(url: &str) -> String { + let trimmed = url.trim().trim_end_matches('/'); + let without_v1 = trimmed.strip_suffix("/v1").unwrap_or(trimmed); + format!("{}/v1", without_v1.trim_end_matches('/')) +} + fn nonblank Option>(get: &F, key: &str) -> Option { get(key) .map(|v| v.trim().to_string()) diff --git a/src/openhuman/agentbox/env_tests.rs b/src/openhuman/agentbox/env_tests.rs index 78d786a2af..e17753967c 100644 --- a/src/openhuman/agentbox/env_tests.rs +++ b/src/openhuman/agentbox/env_tests.rs @@ -1,4 +1,7 @@ -use super::env::{agentbox_mode_enabled, collect_gmi_config, GmiConfig, AGENTBOX_MODE_ENV_VAR}; +use super::env::{ + agentbox_mode_enabled, collect_gmi_config, normalize_gmi_maas_base_url, GmiConfig, + AGENTBOX_MODE_ENV_VAR, +}; #[test] fn collect_returns_some_when_all_three_vars_present() { @@ -11,7 +14,7 @@ fn collect_returns_some_when_all_three_vars_present() { assert_eq!( cfg, Ok(GmiConfig { - base_url: "https://api.gmi-serving.com".into(), + base_url: "https://api.gmi-serving.com/v1".into(), api_key: "sk-test".into(), model: "deepseek-ai/DeepSeek-V4-Pro".into(), }) @@ -86,9 +89,41 @@ fn collect_trims_leading_and_trailing_whitespace() { assert_eq!( cfg, Ok(GmiConfig { - base_url: "https://api.gmi-serving.com".into(), + base_url: "https://api.gmi-serving.com/v1".into(), api_key: "sk-test".into(), model: "deepseek-ai/DeepSeek-V4-Pro".into(), }) ); } + +#[test] +fn normalize_gmi_maas_base_url_appends_v1() { + assert_eq!( + normalize_gmi_maas_base_url("https://api.gmi-serving.com"), + "https://api.gmi-serving.com/v1" + ); +} + +#[test] +fn normalize_gmi_maas_base_url_strips_trailing_slash() { + assert_eq!( + normalize_gmi_maas_base_url("https://api.gmi-serving.com/"), + "https://api.gmi-serving.com/v1" + ); +} + +#[test] +fn normalize_gmi_maas_base_url_keeps_existing_v1() { + assert_eq!( + normalize_gmi_maas_base_url("https://api.gmi-serving.com/v1"), + "https://api.gmi-serving.com/v1" + ); +} + +#[test] +fn normalize_gmi_maas_base_url_strips_v1_trailing_slash() { + assert_eq!( + normalize_gmi_maas_base_url("https://api.gmi-serving.com/v1/"), + "https://api.gmi-serving.com/v1" + ); +} diff --git a/src/openhuman/agentbox/status.rs b/src/openhuman/agentbox/status.rs index cd8e2120cc..af98410b00 100644 --- a/src/openhuman/agentbox/status.rs +++ b/src/openhuman/agentbox/status.rs @@ -98,7 +98,7 @@ mod tests { assert!(status.provider_configured); let provider = status.provider.expect("provider populated"); assert_eq!(provider.slug, GMI_MAAS_SLUG); - assert_eq!(provider.base_url, "https://api.gmi-serving.com"); + assert_eq!(provider.base_url, "https://api.gmi-serving.com/v1"); assert_eq!(provider.model, "deepseek-ai/DeepSeek-V4-Pro"); // Defense-in-depth: the serialized status must never carry the key. From 78949de1dec4c3b5a7dfdd0123b77fabb3bcddc6 Mon Sep 17 00:00:00 2001 From: James Gentes Date: Tue, 21 Jul 2026 09:59:50 -0700 Subject: [PATCH 21/56] fix(conversations): stop the thread-goal footer text from auto-scrolling (#5030) --- .../components/ThreadGoalChip.tsx | 45 +++---------------- app/tailwind.config.js | 8 ---- 2 files changed, 6 insertions(+), 47 deletions(-) diff --git a/app/src/features/conversations/components/ThreadGoalChip.tsx b/app/src/features/conversations/components/ThreadGoalChip.tsx index 01761a32b8..17dbf7f6ee 100644 --- a/app/src/features/conversations/components/ThreadGoalChip.tsx +++ b/app/src/features/conversations/components/ThreadGoalChip.tsx @@ -213,7 +213,12 @@ export function ThreadGoalFooterTrigger({ className={`shrink-0 rounded px-1 py-0.5 text-[10px] font-medium uppercase tracking-wide ${statusClasses(ctl.goal.status)}`}> {t(`conversations.threadGoal.status.${ctl.goal.status}`)} - + {/* Truncate long objectives instead of scrolling them. The full text is + reachable via the button's `title` tooltip (hover) and by clicking to + open the editor, so a moving label would only distract. */} + + {ctl.goal.objective} + ); } @@ -305,44 +310,6 @@ export function ThreadGoalEditorPanel({ ); } -/** - * Single-line label that gently marquees (ping-pong scroll) only when the text - * overflows its max width; otherwise it truncates. The scroll distance is - * measured and fed to the `goal-marquee` keyframe via a CSS variable. - */ -function MarqueeText({ - text, - className = '', -}: { - text: string; - className?: string; -}): React.ReactElement { - const outerRef = useRef(null); - const innerRef = useRef(null); - const [shift, setShift] = useState(0); - - useEffect(() => { - const outer = outerRef.current; - const inner = innerRef.current; - if (!outer || !inner) return; - const overflow = inner.scrollWidth - outer.clientWidth; - setShift(overflow > 4 ? overflow + 8 : 0); - }, [text]); - - return ( - - 0 ? 'animate-goal-marquee' : 'truncate'}`} - style={ - shift > 0 ? ({ '--goal-marquee-shift': `-${shift}px` } as React.CSSProperties) : undefined - }> - {text} - - - ); -} - function PanelButton({ label, onClick, diff --git a/app/tailwind.config.js b/app/tailwind.config.js index 332ac7df5a..d2b06c9d21 100644 --- a/app/tailwind.config.js +++ b/app/tailwind.config.js @@ -271,10 +271,6 @@ module.exports = { 'float': 'float 3s ease-in-out infinite', 'ticker': 'ticker 30s linear infinite', 'wiggle': 'wiggle 0.5s ease-in-out', - // Gentle ping-pong scroll for overflowing single-line labels (e.g. a - // long thread-goal objective). Distance is supplied per-element via the - // `--goal-marquee-shift` CSS var; `alternate` returns it to the start. - 'goal-marquee': 'goalMarquee 6s ease-in-out infinite alternate', }, keyframes: { @@ -319,10 +315,6 @@ module.exports = { '25%': { transform: 'rotate(-9deg)' }, '75%': { transform: 'rotate(9deg)' }, }, - goalMarquee: { - '0%, 18%': { transform: 'translateX(0)' }, - '82%, 100%': { transform: 'translateX(var(--goal-marquee-shift, 0px))' }, - }, }, // Backdrop blur for glass morphism From 86e1a4c524bcd3cc3396cffab90af27693fde441 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 21 Jul 2026 19:00:30 +0200 Subject: [PATCH 22/56] fix(approval): preserve replacement routes during cleanup (#4786) Co-authored-by: Sami Rusani <14844597+samrusani@users.noreply.github.com> --- src/openhuman/approval/gate.rs | 237 +++++++++++++++++++++++++++++++-- 1 file changed, 226 insertions(+), 11 deletions(-) diff --git a/src/openhuman/approval/gate.rs b/src/openhuman/approval/gate.rs index afde88c84b..1a46e3676e 100644 --- a/src/openhuman/approval/gate.rs +++ b/src/openhuman/approval/gate.rs @@ -779,7 +779,8 @@ impl ApprovalGate { if let Err(err) = store::insert_pending(&self.config, &pending, &self.session_id) { self.evict_waiter(&request_id); - self.clear_thread(&chat_thread_id); + self.clear_thread(&chat_thread_id, &request_id); + self.clear_meeting(&in_call_ctx, &request_id); tracing::error!( error = %err, tool = tool_name, @@ -904,7 +905,7 @@ impl ApprovalGate { "[approval::gate] decision received" ); if decision.is_approve() { - (GateOutcome::Allow, Some(request_id)) + (GateOutcome::Allow, Some(request_id.clone())) } else { ( GateOutcome::Deny { @@ -998,7 +999,7 @@ impl ApprovalGate { // on this path too — otherwise the stale thread→request // mapping survives and the next yes/no on the thread could be // routed to this already-finished request. - (GateOutcome::Allow, Some(request_id)) + (GateOutcome::Allow, Some(request_id.clone())) } else { tracing::warn!( request_id = %request_id, @@ -1026,8 +1027,8 @@ impl ApprovalGate { waiter_guard.disarm(); // The routing mappings are only needed while parked; clear them on // every exit (decision, channel drop, or timeout). - self.clear_thread(&chat_thread_id); - self.clear_meeting(&in_call_ctx); + self.clear_thread(&chat_thread_id, &request_id); + self.clear_meeting(&in_call_ctx, &request_id); outcome } @@ -1196,17 +1197,17 @@ impl ApprovalGate { self.meeting_to_request.lock().get(meeting_key).cloned() } - /// Drop the thread → request mapping (best-effort; no-op when absent). - fn clear_thread(&self, thread_id: &Option) { + /// Drop the thread → request mapping when it still belongs to this request. + fn clear_thread(&self, thread_id: &Option, request_id: &str) { if let Some(t) = thread_id { - self.thread_to_request.lock().remove(t); + self.clear_thread_route_if_owned(t, request_id); } } - /// Drop the meeting → request mapping (best-effort; no-op when absent). - fn clear_meeting(&self, ctx: &Option) { + /// Drop the meeting → request mapping when it still belongs to this request. + fn clear_meeting(&self, ctx: &Option, request_id: &str) { if let Some(ic) = ctx { - self.meeting_to_request.lock().remove(&ic.meeting_key); + self.clear_meeting_route_if_owned(&ic.meeting_key, request_id); } } @@ -1574,6 +1575,220 @@ mod tests { } } + #[tokio::test] + async fn aborting_older_chat_waiter_preserves_newer_thread_route() { + let (gate, _dir) = test_gate(); + let gate = Arc::new(gate); + + let old_gate = gate.clone(); + let old_handle = tokio::spawn(async move { + turn_origin::with_origin( + web_origin(), + APPROVAL_CHAT_CONTEXT.scope( + chat_ctx(), + old_gate.intercept("composio", "old action", serde_json::json!({})), + ), + ) + .await + }); + + let mut tries = 0; + let old_request_id = loop { + if let Some(request_id) = gate.pending_for_thread("t-test") { + break request_id; + } + tries += 1; + assert!(tries < 1_000, "old chat approval route never appeared"); + tokio::task::yield_now().await; + }; + + let new_gate = gate.clone(); + let new_handle = tokio::spawn(async move { + turn_origin::with_origin( + web_origin(), + APPROVAL_CHAT_CONTEXT.scope( + chat_ctx(), + new_gate.intercept("composio", "new action", serde_json::json!({})), + ), + ) + .await + }); + + let mut tries = 0; + let new_request_id = loop { + if let Some(request_id) = gate.pending_for_thread("t-test") { + if request_id != old_request_id { + break request_id; + } + } + tries += 1; + assert!(tries < 1_000, "new chat approval route never appeared"); + tokio::task::yield_now().await; + }; + + old_handle.abort(); + assert!(old_handle.await.unwrap_err().is_cancelled()); + + assert_eq!( + gate.pending_for_thread("t-test").as_deref(), + Some(new_request_id.as_str()) + ); + assert!(!gate.waiters.lock().contains_key(&old_request_id)); + assert!(gate.waiters.lock().contains_key(&new_request_id)); + assert_eq!( + store::get_decision(&gate.config, &old_request_id).unwrap(), + Some(ApprovalDecision::Deny) + ); + + gate.decide(&new_request_id, ApprovalDecision::ApproveOnce) + .unwrap(); + assert!(matches!(new_handle.await.unwrap(), GateOutcome::Allow)); + assert!(gate.pending_for_thread("t-test").is_none()); + } + + #[tokio::test] + async fn pending_store_failure_clears_in_call_meeting_route() { + let dir = TempDir::new().unwrap(); + let blocked_workspace = dir.path().join("workspace-file"); + std::fs::write(&blocked_workspace, b"not a directory").unwrap(); + let config = Config { + workspace_dir: blocked_workspace, + ..Config::default() + }; + let gate = ApprovalGate::new( + config, + format!("session-{}", uuid::Uuid::new_v4()), + Duration::from_secs(2), + ); + + let outcome = turn_origin::with_origin( + meet_origin(), + APPROVAL_IN_CALL_CONTEXT.scope( + in_call_ctx(), + gate.intercept("composio", "send email", serde_json::json!({})), + ), + ) + .await; + + assert!(matches!( + outcome, + GateOutcome::Deny { reason } if reason.contains("could not persist") + )); + assert!(gate.waiters.lock().is_empty()); + assert!(gate.pending_for_meeting("meet-1").is_none()); + } + + #[tokio::test] + async fn externally_aborted_in_call_waiter_cleans_meeting_route() { + let (gate, _dir) = test_gate(); + let gate = Arc::new(gate); + + let g = gate.clone(); + let handle = tokio::spawn(async move { + turn_origin::with_origin( + meet_origin(), + APPROVAL_IN_CALL_CONTEXT.scope( + in_call_ctx(), + g.intercept("composio", "send email", serde_json::json!({})), + ), + ) + .await + }); + + let mut tries = 0; + let request_id = loop { + if let Some(request_id) = gate.pending_for_meeting("meet-1") { + break request_id; + } + tries += 1; + assert!(tries < 1_000, "meeting approval route never appeared"); + tokio::task::yield_now().await; + }; + assert!(gate.waiters.lock().contains_key(&request_id)); + + handle.abort(); + assert!(handle.await.unwrap_err().is_cancelled()); + + assert!(gate.pending_for_meeting("meet-1").is_none()); + assert!(!gate.waiters.lock().contains_key(&request_id)); + assert!(gate.list_pending().unwrap().is_empty()); + assert_eq!( + store::get_decision(&gate.config, &request_id).unwrap(), + Some(ApprovalDecision::Deny) + ); + } + + #[tokio::test] + async fn aborting_older_in_call_waiter_preserves_newer_meeting_route() { + let (gate, _dir) = test_gate(); + let gate = Arc::new(gate); + + let old_gate = gate.clone(); + let old_handle = tokio::spawn(async move { + turn_origin::with_origin( + meet_origin(), + APPROVAL_IN_CALL_CONTEXT.scope( + in_call_ctx(), + old_gate.intercept("composio", "old action", serde_json::json!({})), + ), + ) + .await + }); + + let mut tries = 0; + let old_request_id = loop { + if let Some(request_id) = gate.pending_for_meeting("meet-1") { + break request_id; + } + tries += 1; + assert!(tries < 1_000, "old meeting approval route never appeared"); + tokio::task::yield_now().await; + }; + + let new_gate = gate.clone(); + let new_handle = tokio::spawn(async move { + turn_origin::with_origin( + meet_origin(), + APPROVAL_IN_CALL_CONTEXT.scope( + in_call_ctx(), + new_gate.intercept("composio", "new action", serde_json::json!({})), + ), + ) + .await + }); + + let mut tries = 0; + let new_request_id = loop { + if let Some(request_id) = gate.pending_for_meeting("meet-1") { + if request_id != old_request_id { + break request_id; + } + } + tries += 1; + assert!(tries < 1_000, "new meeting approval route never appeared"); + tokio::task::yield_now().await; + }; + + old_handle.abort(); + assert!(old_handle.await.unwrap_err().is_cancelled()); + + assert_eq!( + gate.pending_for_meeting("meet-1").as_deref(), + Some(new_request_id.as_str()) + ); + assert!(!gate.waiters.lock().contains_key(&old_request_id)); + assert!(gate.waiters.lock().contains_key(&new_request_id)); + assert_eq!( + store::get_decision(&gate.config, &old_request_id).unwrap(), + Some(ApprovalDecision::Deny) + ); + + gate.decide(&new_request_id, ApprovalDecision::ApproveOnce) + .unwrap(); + assert!(matches!(new_handle.await.unwrap(), GateOutcome::Allow)); + assert!(gate.pending_for_meeting("meet-1").is_none()); + } + #[tokio::test] async fn auto_approve_tool_skips_prompt() { // The gate reads the "Always allow" allowlist from the process-global From e2f90145752b1e597bab3bda6fbf921b1f89037c Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:40:08 +0530 Subject: [PATCH 23/56] fix(core): seed tool-execution timeout on the always-on boot path (#5027) (#5078) --- src/core/jsonrpc.rs | 25 ++++++++ src/core/jsonrpc_tests.rs | 70 +++++++++++++++++++++++ src/openhuman/channels/runtime/startup.rs | 18 ++---- src/openhuman/skills/bus.rs | 10 ---- src/openhuman/skills/stub.rs | 5 +- src/openhuman/tool_timeout/README.md | 4 +- 6 files changed, 104 insertions(+), 28 deletions(-) diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index 2b87bdcb2d..4b4244eab4 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -1882,6 +1882,31 @@ fn register_domain_subscribers( .insert(group) } + // Seed the live tool-execution timeout from the persisted `[agent]` config + // so a user-configured value (Settings → Agent OS access → Action timeout) + // is in effect from the first tool call. `OPENHUMAN_TOOL_TIMEOUT_SECS`, when + // set, still overrides this inside `set_tool_timeout_secs`. This lives on + // the always-on boot path (not `channels::runtime::startup::start_channels`, + // which is skipped for channel-less / web-chat-only cores) so the timeout + // seeds for every install regardless of DomainSet — it is DomainSet- + // independent process-global state, not a gated subscriber (#5027). + // + // Deliberately OUTSIDE the `INFRA: Once` below: `bootstrap_core_runtime` + // re-runs on an in-process core restart (`CoreProcessHandle::restart` → + // `ensure_running`, and `reset_local_data`/Clear-Local-Data) with a freshly + // reloaded `Config`, but `INFRA` is already consumed — so a seed gated by it + // would leave the process-global timeout pinned to the previous config's + // value until Settings is re-saved. `set_tool_timeout_secs` is idempotent + // (a plain atomic store honouring the env override), so re-seeding on every + // call is correct and cheap and re-applies the current config each boot. + let effective_timeout = + crate::openhuman::tool_timeout::set_tool_timeout_secs(config.agent.agent_timeout_secs); + log::debug!( + "[tool_timeout] seeded tool-execution timeout from config: configured={}s effective={}s", + config.agent.agent_timeout_secs, + effective_timeout + ); + // Ungated core/platform infra — health, scheduler-gate, TokenJuice // content-router, session-token seeding, the SessionExpired handler, and // service restart/shutdown. These are DomainSet-independent, so they run diff --git a/src/core/jsonrpc_tests.rs b/src/core/jsonrpc_tests.rs index 8b189229a2..50198a0321 100644 --- a/src/core/jsonrpc_tests.rs +++ b/src/core/jsonrpc_tests.rs @@ -78,6 +78,57 @@ fn domain_subscriber_plan_harness_gates_by_owning_group() { assert!(!plan.mcp, "harness must skip mcp_registry bus init"); } +/// #5027 — the tool-execution timeout must be seeded on the always-on core boot +/// path (`register_domain_subscribers`), NOT inside +/// `channels::runtime::startup::start_channels`, which is skipped for +/// channel-less / web-chat-only cores (and when `OPENHUMAN_DISABLE_CHANNEL_LISTENERS` +/// is set). A minimal `DomainSet::none()` must still seed, because the seed is +/// DomainSet-independent. +/// +/// The seed sits just *before* the ungated `INFRA: Once` block, so it re-runs on +/// every `register_domain_subscribers` call (each `bootstrap_core_runtime`), +/// re-applying the freshly reloaded config on an in-process restart — a seed gated +/// by the process-global `Once` would only fire on the first boot. `TEST_ENV_LOCK` +/// (via `EnvVarGuard`) serializes with `OPENHUMAN_TOOL_TIMEOUT_SECS` cleared so the +/// operator env override cannot mask the config-derived value. Runs under a tokio +/// runtime like the real boot paths — the INFRA block calls `subscribe_global`, +/// which `tokio::spawn`s when the global bus is already initialized by another test +/// in the binary. +#[tokio::test] +async fn tool_timeout_seeds_on_channelless_core_boot() { + // Clear the operator override behind a panic-safe RAII guard: if any assertion + // below panics, `Drop` still restores the previous value, so sibling tests that + // share `TEST_ENV_LOCK` never inherit the cleared var. + let _env = EnvVarGuard::remove_many(vec!["OPENHUMAN_TOOL_TIMEOUT_SECS"]); + + // Distinctive, in-range (1..=3600) value so the assertion can only pass on a + // real seed, never on the default. Channel-less: `channels_config` stays empty, + // which is exactly the config for which `start_channels` is skipped. + let mut config = crate::openhuman::config::Config::default(); + config.agent.agent_timeout_secs = 1234; + assert!( + config.channels_config.active_channel.is_none(), + "test premise: channel-less config, so start_channels would be skipped" + ); + + let tmp = tempfile::tempdir().expect("tempdir"); + // Minimal DomainSet — INFRA (and thus the timeout seed) is DomainSet-independent, + // so even `none()` must seed. `embedded_core = true` skips the standalone + // process-exit shutdown subscriber. + super::register_domain_subscribers( + tmp.path().to_path_buf(), + config, + true, + crate::core::runtime::DomainSet::none(), + ); + + assert_eq!( + crate::openhuman::tool_timeout::tool_execution_timeout_secs(), + 1234, + "channel-less core boot must seed the tool-execution timeout from [agent].agent_timeout_secs" + ); +} + struct EnvVarGuard { old_values: Vec<(&'static str, Option)>, _lock: MutexGuard<'static, ()>, @@ -99,6 +150,25 @@ impl EnvVarGuard { _lock: lock, } } + + /// Remove the named vars (capturing their prior values) for the guard's + /// lifetime, restoring each on `Drop`. Mirrors [`set_many`] for tests that + /// need an env var *absent* rather than set to a fixed value. + fn remove_many(keys: Vec<&'static str>) -> Self { + let lock = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .expect("test env lock poisoned"); + let mut old_values = Vec::with_capacity(keys.len()); + for key in keys { + let old = std::env::var_os(key); + std::env::remove_var(key); + old_values.push((key, old)); + } + Self { + old_values, + _lock: lock, + } + } } impl Drop for EnvVarGuard { diff --git a/src/openhuman/channels/runtime/startup.rs b/src/openhuman/channels/runtime/startup.rs index 8cd8ecfbfc..7e98177ab2 100644 --- a/src/openhuman/channels/runtime/startup.rs +++ b/src/openhuman/channels/runtime/startup.rs @@ -156,7 +156,6 @@ pub async fn start_channels(mut config: Config) -> Result<()> { let bus = event_bus::init_global(DEFAULT_CAPACITY); let _tracing_handle = bus.subscribe(Arc::new(TracingSubscriber)); crate::openhuman::health::bus::register_health_subscriber(); - crate::openhuman::skills::bus::register_workflow_cleanup_subscriber(); crate::openhuman::memory_conversations::register_conversation_persistence_subscriber( config.workspace_dir.clone(), ); @@ -276,17 +275,12 @@ pub async fn start_channels(mut config: Config) -> Result<()> { config.workspace_dir.clone(), config.action_dir.clone(), ); - // Seed the live tool-execution timeout from the persisted `[agent]` config so - // a user-configured value (Settings → Agent OS access → Action timeout) is in - // effect from the first tool call. `OPENHUMAN_TOOL_TIMEOUT_SECS`, when set, - // still overrides this inside `set_tool_timeout_secs`. - let effective_timeout = - crate::openhuman::tool_timeout::set_tool_timeout_secs(config.agent.agent_timeout_secs); - tracing::debug!( - configured = config.agent.agent_timeout_secs, - effective = effective_timeout, - "[startup] seeded tool-execution timeout from config" - ); + // NOTE: the live tool-execution timeout seed is done in + // `core::jsonrpc::register_domain_subscribers` (unconditional core boot), NOT + // here — `start_channels` is skipped when no channel is configured or + // `OPENHUMAN_DISABLE_CHANNEL_LISTENERS` is set, which would otherwise leave + // channel-less / web-chat-only cores running the default timeout instead of the + // user-configured `[agent].agent_timeout_secs` (#5027). // Phase 1 of #1401: audit logger is wired with defaults so emission paths // are exercised at runtime. A follow-up promotes `SecurityConfig` (and // therefore the `audit` knob) onto the runtime `Config` schema so users diff --git a/src/openhuman/skills/bus.rs b/src/openhuman/skills/bus.rs index 7ea4f84f0a..73d551eb8a 100644 --- a/src/openhuman/skills/bus.rs +++ b/src/openhuman/skills/bus.rs @@ -255,10 +255,6 @@ pub fn ensure_triggered_workflow_subscriber(workspace: &std::path::Path) { }); } -/// Legacy no-op retained while call-sites migrate to -/// [`register_triggered_workflow_subscriber`]. Safe to call multiple times. -pub fn register_workflow_cleanup_subscriber() {} - #[cfg(test)] mod tests { use super::*; @@ -446,10 +442,4 @@ mod tests { }; assert!(idx.matching_workflows(&event).is_empty()); } - - #[test] - fn register_skill_cleanup_subscriber_is_a_safe_noop() { - register_workflow_cleanup_subscriber(); - register_workflow_cleanup_subscriber(); - } } diff --git a/src/openhuman/skills/stub.rs b/src/openhuman/skills/stub.rs index a250923198..c09d9b9bcc 100644 --- a/src/openhuman/skills/stub.rs +++ b/src/openhuman/skills/stub.rs @@ -82,7 +82,7 @@ pub mod registry { } // --------------------------------------------------------------------------- -// bus::{ensure_triggered_workflow_subscriber, register_workflow_cleanup_subscriber} +// bus::ensure_triggered_workflow_subscriber // --------------------------------------------------------------------------- pub mod bus { @@ -90,9 +90,6 @@ pub mod bus { pub fn ensure_triggered_workflow_subscriber(_workspace: &std::path::Path) { log::debug!("[skills-stub] ensure_triggered_workflow_subscriber skipped (skills disabled)"); } - - /// No-op: no skill run directories exist to clean up. - pub fn register_workflow_cleanup_subscriber() {} } // NOTE: no `tools` module here. The `pub use skills::tools::*` glob in diff --git a/src/openhuman/tool_timeout/README.md b/src/openhuman/tool_timeout/README.md index 3f3e0f091c..a6a343942a 100644 --- a/src/openhuman/tool_timeout/README.md +++ b/src/openhuman/tool_timeout/README.md @@ -7,7 +7,7 @@ Process-wide wall-clock timeout policy for tool execution (the node/tool runtime Highest precedence first: 1. `OPENHUMAN_TOOL_TIMEOUT_SECS` environment variable — operator override. When set to a valid value (`1..=3600`) it always wins; config pushes are ignored while it is present. -2. The persisted config value (`[agent].agent_timeout_secs`), pushed in via `set_tool_timeout_secs` at startup and on every `config.update_agent_settings` RPC. +2. The persisted config value (`[agent].agent_timeout_secs`), pushed in via `set_tool_timeout_secs` at startup (from `core::jsonrpc::register_domain_subscribers`, the always-on core boot path) and on every `config.update_agent_settings` RPC. 3. The built-in `DEFAULT_TIMEOUT_SECS` (`120`) default. ## Responsibilities @@ -54,7 +54,7 @@ The global timeout governs **non-scripting** tools only — a hung network/MCP c - `src/openhuman/tools/impl/system/{shell,node_exec,npm_exec}.rs` — scripting tools: unbounded by default, explicit `timeout_secs` via `explicit_call_timeout_*`. - `src/openhuman/agent/tools/delegate.rs` — bounds the delegated provider chat call with `tool_execution_timeout_secs`. - `src/openhuman/config/ops.rs` — `apply_agent_settings` calls `set_tool_timeout_secs` after persisting; `get_agent_settings` reports `effective_timeout_secs` / `env_override`. -- `src/openhuman/channels/runtime/startup.rs` — seeds the runtime value from config at core boot. +- `src/core/jsonrpc.rs` — `register_domain_subscribers` seeds the runtime value from config on the always-on core boot path (its ungated `INFRA: Once` block), so channel-less / web-chat-only cores get the configured timeout too (#5027). - `src/openhuman/agent/harness/harness_gap_tests.rs` — pins `parse_tool_timeout_secs` default/boundary behaviour. ## Notes / gotchas From dfa573859c5ae2eac968ffceff5acee45da953ce Mon Sep 17 00:00:00 2001 From: Muhammad Ismail <78064250+myi1@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:10:38 +0400 Subject: [PATCH 24/56] fix(keyring): declare msg mutable for cfg(windows) push_str (E0596) (#4772) Co-authored-by: Claude Fable 5 --- src/openhuman/keyring/encrypted_store.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/openhuman/keyring/encrypted_store.rs b/src/openhuman/keyring/encrypted_store.rs index d2aac37ddc..378522c49a 100644 --- a/src/openhuman/keyring/encrypted_store.rs +++ b/src/openhuman/keyring/encrypted_store.rs @@ -294,7 +294,9 @@ impl SecretStore { }; let hex_key = read_result.with_context(|| { - let msg = format!( + // `mut` is only exercised inside the #[cfg(windows)] block below. + #[cfg_attr(not(windows), allow(unused_mut))] + let mut msg = format!( "Failed to read secret key file at {}", self.key_path.display() ); From 4b2d92c033ee114325733cf1fafd68b30005c904 Mon Sep 17 00:00:00 2001 From: nb213 Date: Wed, 22 Jul 2026 01:10:58 +0800 Subject: [PATCH 25/56] fix(api): classify Atlas Cloud as inference provider (#4885) Co-authored-by: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com> Co-authored-by: Steven Enamakel --- src/api/config.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/api/config.rs b/src/api/config.rs index 29b96b4a09..6d17483518 100644 --- a/src/api/config.rs +++ b/src/api/config.rs @@ -311,6 +311,7 @@ pub fn looks_like_local_ai_endpoint(url: &str) -> bool { const INFERENCE_PROVIDER_DOMAINS: &[&str] = &[ "openrouter.ai", "openmodel.ai", + "atlascloud.ai", "openai.com", "anthropic.com", "groq.com", @@ -1189,6 +1190,7 @@ mod tests { let falls_back: &[&str] = &[ "https://openrouter.ai/api/v1", + "https://api.atlascloud.ai/v1", "https://api.openai.com/v1", "https://api.groq.com/openai/v1", "https://generativelanguage.googleapis.com/v1beta/openai", @@ -1258,6 +1260,9 @@ mod tests { assert!(looks_like_inference_provider_endpoint( "https://api.openmodel.ai/v1" )); + assert!(looks_like_inference_provider_endpoint( + "https://api.atlascloud.ai/v1" + )); // Other managed providers, apex and subdomain. assert!(looks_like_inference_provider_endpoint( "https://api.openai.com/v1" @@ -1317,6 +1322,10 @@ mod tests { effective_backend_api_url(&Some("https://api.openmodel.ai/v1".to_string())), expected ); + assert_eq!( + effective_backend_api_url(&Some("https://api.atlascloud.ai/v1".to_string())), + expected + ); } #[test] From c309be81fe0396748696bcdf64ac1209767af661 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:48:03 +0530 Subject: [PATCH 26/56] fix(flows): show plain-language step labels in the workflow proposal card (#5088) --- .../chat/WorkflowProposalCard.test.tsx | 78 ++++++++++++++++++- .../components/chat/WorkflowProposalCard.tsx | 73 +++++++++++++---- app/src/lib/i18n/ar.ts | 11 +++ app/src/lib/i18n/bn.ts | 11 +++ app/src/lib/i18n/de.ts | 11 +++ app/src/lib/i18n/en.ts | 14 ++++ app/src/lib/i18n/es.ts | 11 +++ app/src/lib/i18n/fr.ts | 11 +++ app/src/lib/i18n/hi.ts | 11 +++ app/src/lib/i18n/id.ts | 11 +++ app/src/lib/i18n/it.ts | 11 +++ app/src/lib/i18n/ko.ts | 11 +++ app/src/lib/i18n/pl.ts | 11 +++ app/src/lib/i18n/pt.ts | 11 +++ app/src/lib/i18n/ru.ts | 11 +++ app/src/lib/i18n/zh-CN.ts | 11 +++ 16 files changed, 290 insertions(+), 18 deletions(-) diff --git a/app/src/components/chat/WorkflowProposalCard.test.tsx b/app/src/components/chat/WorkflowProposalCard.test.tsx index 83f79fe6bb..d29d98c0e6 100644 --- a/app/src/components/chat/WorkflowProposalCard.test.tsx +++ b/app/src/components/chat/WorkflowProposalCard.test.tsx @@ -56,17 +56,89 @@ describe('WorkflowProposalCard', () => { mockNavigate.mockReset(); }); - it('renders the name, trigger, and steps with node-kind badges', () => { + it('renders the name, trigger, and steps with plain-language node-kind badges', () => { render(); expect(screen.getByText('Daily standup summary')).toBeInTheDocument(); expect(screen.getByText('schedule: 0 9 * * *')).toBeInTheDocument(); expect(screen.getByText('Summarize')).toBeInTheDocument(); expect(screen.getByText('Post to Slack')).toBeInTheDocument(); - expect(screen.getByText('agent')).toBeInTheDocument(); - expect(screen.getByText('tool_call')).toBeInTheDocument(); + // Badges show the friendly i18n key (useT is mocked to echo the key), + // never the raw snake_case wire `kind`. + expect(screen.getByText('chat.flowProposal.stepKind.agent')).toBeInTheDocument(); + expect(screen.getByText('chat.flowProposal.stepKind.toolCall')).toBeInTheDocument(); + expect(screen.queryByText('agent')).not.toBeInTheDocument(); + expect(screen.queryByText('tool_call')).not.toBeInTheDocument(); expect(screen.getAllByTestId('workflow-proposal-step-kind')).toHaveLength(2); }); + it('hides config_hint from the card even when present on the step', () => { + render(); + // `proposal()` sets a config_hint on the first step; the card must not + // surface it (it can leak internal identifiers like tool slugs or URLs). + expect(screen.queryByText("Summarize yesterday's messages")).not.toBeInTheDocument(); + }); + + it('falls back to a humanized label for an unrecognized step kind', () => { + render( + + ); + // Not in STEP_KIND_I18N_KEYS, so it must fall back to the pure + // capitalize + underscores-to-spaces helper rather than the raw kind. + expect(screen.getByText('Future thing')).toBeInTheDocument(); + expect(screen.queryByText('future_thing')).not.toBeInTheDocument(); + }); + + // `step.kind` is arbitrary wire data. A plain bracket index would resolve + // inherited Object members (e.g. `constructor` -> the Object function) and + // hand a non-string to t(), breaking the badge render. The own-property + // guard must send these through the humanized fallback instead. `constructor` + // and `toString` have no underscores, so the humanized label is just the + // capitalized kind. + it.each(['constructor', 'toString'])( + 'humanizes the inherited-property kind %s instead of resolving it on the prototype', + kind => { + render( + + ); + const expected = kind.charAt(0).toUpperCase() + kind.slice(1); + expect(screen.getByText(expected)).toBeInTheDocument(); + expect(screen.getByText('Edge-case step')).toBeInTheDocument(); + } + ); + + it('renders a __proto__ step kind without leaking an inherited property', () => { + render( + + ); + // The own-property guard treats __proto__ as unknown and humanizes it, so + // the row still renders (step name present) and the badge shows a real + // string. Assert the badge's exact (whitespace-normalized) content directly + // — not just the step name / absence of "function" — so a missing badge or + // an inherited-member leak like `[object Object]` would fail here too. + // `__proto__` humanizes (underscores -> spaces, first char already a space) + // to a lowercase "proto" badge. + expect(screen.getByText('Proto step')).toBeInTheDocument(); + expect(screen.getByTestId('workflow-proposal-step-kind')).toHaveTextContent(/^proto$/); + }); + it('has the expected root test id', () => { render(); expect(screen.getByTestId('workflow-proposal-card')).toBeInTheDocument(); diff --git a/app/src/components/chat/WorkflowProposalCard.tsx b/app/src/components/chat/WorkflowProposalCard.tsx index a5fd612e9b..f3888baa87 100644 --- a/app/src/components/chat/WorkflowProposalCard.tsx +++ b/app/src/components/chat/WorkflowProposalCard.tsx @@ -16,6 +16,50 @@ import Button from '../ui/Button'; const log = debug('openhuman:chat:workflow-proposal-card'); +// Maps the wire `step.kind` (a `tinyflows` node type, snake_case, e.g. +// `tool_call`) to the i18n key for its plain-language badge label. Kinds not +// in this map (e.g. a future node type the frontend doesn't know about yet) +// fall back to `humanizeUnknownStepKind` below rather than showing the raw +// snake_case identifier to a non-technical user. +const STEP_KIND_I18N_KEYS: Record = { + agent: 'chat.flowProposal.stepKind.agent', + tool_call: 'chat.flowProposal.stepKind.toolCall', + http_request: 'chat.flowProposal.stepKind.httpRequest', + code: 'chat.flowProposal.stepKind.code', + condition: 'chat.flowProposal.stepKind.condition', + switch: 'chat.flowProposal.stepKind.switch', + merge: 'chat.flowProposal.stepKind.merge', + split_out: 'chat.flowProposal.stepKind.splitOut', + transform: 'chat.flowProposal.stepKind.transform', + output_parser: 'chat.flowProposal.stepKind.outputParser', + sub_workflow: 'chat.flowProposal.stepKind.subWorkflow', +}; + +/** + * Pure fallback for a `step.kind` the frontend doesn't recognize: capitalize + * the first letter and turn `_` into spaces (e.g. `future_thing` -> + * `Future thing`) so an unmapped kind still reads as plain language instead + * of a raw snake_case identifier. + */ +function humanizeUnknownStepKind(kind: string): string { + const spaced = kind.replace(/_/g, ' '); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +} + +/** + * Resolve the i18n key for a mapped `step.kind`, guarding against inherited + * Object properties. `step.kind` is arbitrary wire data, so a plain bracket + * index (`STEP_KIND_I18N_KEYS[step.kind]`) would resolve inherited members for + * values like `constructor` or `__proto__` — handing a function/object to + * `t()` and breaking the badge render. Only own keys count; anything else + * returns `undefined` so the caller falls back to `humanizeUnknownStepKind`. + */ +function stepKindI18nKey(kind: string): string | undefined { + return Object.prototype.hasOwnProperty.call(STEP_KIND_I18N_KEYS, kind) + ? STEP_KIND_I18N_KEYS[kind] + : undefined; +} + interface Props { threadId: string; proposal: WorkflowProposal; @@ -183,23 +227,22 @@ export const WorkflowProposalCard: React.FC = ({ threadId, proposal, onSa

{proposal.summary.steps.length > 0 ? (
    - {proposal.summary.steps.map((step, i) => ( -
  1. - - {step.kind} - - {step.name} - {step.config_hint ? ( + {proposal.summary.steps.map((step, i) => { + const kindI18nKey = stepKindI18nKey(step.kind); + const kindLabel = kindI18nKey + ? t(kindI18nKey) + : humanizeUnknownStepKind(step.kind); + return ( +
  2. - {step.config_hint} + data-testid="workflow-proposal-step-kind" + className="mr-1.5 inline-block rounded-full bg-ocean-100 px-1.5 py-0.5 text-[10px] font-medium text-ocean-700 dark:bg-ocean-500/15 dark:text-ocean-300"> + {kindLabel} - ) : null} -
  3. - ))} + {step.name} + + ); + })}
) : (

{t('chat.flowProposal.noSteps')}

diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 145b79cbfa..33b2e136d4 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -3415,6 +3415,17 @@ const messages: TranslationMap = { 'chat.flowProposal.error': 'تعذّر حفظ سير العمل. حاول مرة أخرى.', 'chat.flowProposal.enableError': 'تم حفظ سير العمل، لكن تعذّر تفعيله. حاول مرة أخرى، أو فعّله من صفحة سير العمل.', + 'chat.flowProposal.stepKind.agent': 'وكيل', + 'chat.flowProposal.stepKind.toolCall': 'إجراء', + 'chat.flowProposal.stepKind.httpRequest': 'طلب ويب', + 'chat.flowProposal.stepKind.code': 'تشغيل الكود', + 'chat.flowProposal.stepKind.condition': 'شرط', + 'chat.flowProposal.stepKind.switch': 'تبديل', + 'chat.flowProposal.stepKind.merge': 'دمج', + 'chat.flowProposal.stepKind.splitOut': 'تقسيم', + 'chat.flowProposal.stepKind.transform': 'تحويل', + 'chat.flowProposal.stepKind.outputParser': 'تحليل الإخراج', + 'chat.flowProposal.stepKind.subWorkflow': 'مسار عمل فرعي', 'channels.authMode.managed_dm': 'قم بتسجيل الدخول باستخدام OpenHuman', 'channels.authMode.oauth': 'OAuth تسجيل الدخول', 'channels.authMode.bot_token': 'استخدم رمز الروبوت الخاص بك', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 62f87ff14e..917a5e74c9 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -3494,6 +3494,17 @@ const messages: TranslationMap = { 'chat.flowProposal.error': 'ওয়ার্কফ্লো সংরক্ষণ করা যায়নি। আবার চেষ্টা করুন।', 'chat.flowProposal.enableError': 'ওয়ার্কফ্লো সংরক্ষিত হয়েছে, কিন্তু সক্ষম করা যায়নি। আবার চেষ্টা করুন, বা Workflows পৃষ্ঠা থেকে সক্ষম করুন।', + 'chat.flowProposal.stepKind.agent': 'এজেন্ট', + 'chat.flowProposal.stepKind.toolCall': 'কার্যক্রম', + 'chat.flowProposal.stepKind.httpRequest': 'ওয়েব অনুরোধ', + 'chat.flowProposal.stepKind.code': 'কোড চালান', + 'chat.flowProposal.stepKind.condition': 'শর্ত', + 'chat.flowProposal.stepKind.switch': 'সুইচ', + 'chat.flowProposal.stepKind.merge': 'একত্রিত করুন', + 'chat.flowProposal.stepKind.splitOut': 'বিভক্ত করুন', + 'chat.flowProposal.stepKind.transform': 'রূপান্তর করুন', + 'chat.flowProposal.stepKind.outputParser': 'আউটপুট পার্স করুন', + 'chat.flowProposal.stepKind.subWorkflow': 'সাব-ওয়ার্কফ্লো', 'channels.authMode.managed_dm': 'OpenHuman দিয়ে লগইন করুন', 'channels.authMode.oauth': 'OAuth সাইন-ইন করুন', 'channels.authMode.bot_token': 'আপনার নিজের বট টোকেন ব্যবহার করুন', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 630b63ddfc..aedf03a3c3 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -3596,6 +3596,17 @@ const messages: TranslationMap = { 'Der Workflow konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.', 'chat.flowProposal.enableError': 'Workflow gespeichert, konnte aber nicht aktiviert werden. Versuchen Sie es erneut oder aktivieren Sie ihn auf der Workflows-Seite.', + 'chat.flowProposal.stepKind.agent': 'Agent', + 'chat.flowProposal.stepKind.toolCall': 'Aktion', + 'chat.flowProposal.stepKind.httpRequest': 'Webanfrage', + 'chat.flowProposal.stepKind.code': 'Code ausführen', + 'chat.flowProposal.stepKind.condition': 'Bedingung', + 'chat.flowProposal.stepKind.switch': 'Weiche', + 'chat.flowProposal.stepKind.merge': 'Zusammenführen', + 'chat.flowProposal.stepKind.splitOut': 'Aufteilen', + 'chat.flowProposal.stepKind.transform': 'Transformieren', + 'chat.flowProposal.stepKind.outputParser': 'Ausgabe auswerten', + 'chat.flowProposal.stepKind.subWorkflow': 'Unter-Workflow', 'channels.authMode.managed_dm': 'Mit OpenHuman anmelden', 'channels.authMode.oauth': 'OAuth-Anmeldung', 'channels.authMode.bot_token': 'Eigenen Bot-Token verwenden', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 036908c71f..32241d06c3 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -3899,6 +3899,20 @@ const en: TranslationMap = { 'chat.flowProposal.error': 'Could not save the workflow. Please try again.', 'chat.flowProposal.enableError': 'Workflow saved, but could not enable it. Try again, or enable it from the Workflows page.', + // Plain-language labels for each `tinyflows` node kind, shown as the badge + // next to each step in the proposal card's step list. Keep names short: + // they render as small pill badges. + 'chat.flowProposal.stepKind.agent': 'Agent', + 'chat.flowProposal.stepKind.toolCall': 'Action', + 'chat.flowProposal.stepKind.httpRequest': 'Web request', + 'chat.flowProposal.stepKind.code': 'Run code', + 'chat.flowProposal.stepKind.condition': 'Condition', + 'chat.flowProposal.stepKind.switch': 'Switch', + 'chat.flowProposal.stepKind.merge': 'Merge', + 'chat.flowProposal.stepKind.splitOut': 'Split', + 'chat.flowProposal.stepKind.transform': 'Transform', + 'chat.flowProposal.stepKind.outputParser': 'Parse output', + 'chat.flowProposal.stepKind.subWorkflow': 'Sub-workflow', // Auth mode labels 'channels.authMode.managed_dm': 'Login with OpenHuman', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 7254c56c3c..8da92e7990 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -3558,6 +3558,17 @@ const messages: TranslationMap = { 'chat.flowProposal.error': 'No se pudo guardar el flujo de trabajo. Inténtalo de nuevo.', 'chat.flowProposal.enableError': 'Flujo de trabajo guardado, pero no se pudo activar. Inténtalo de nuevo o actívalo desde la página de Workflows.', + 'chat.flowProposal.stepKind.agent': 'Agente', + 'chat.flowProposal.stepKind.toolCall': 'Acción', + 'chat.flowProposal.stepKind.httpRequest': 'Solicitud web', + 'chat.flowProposal.stepKind.code': 'Ejecutar código', + 'chat.flowProposal.stepKind.condition': 'Condición', + 'chat.flowProposal.stepKind.switch': 'Selector', + 'chat.flowProposal.stepKind.merge': 'Combinar', + 'chat.flowProposal.stepKind.splitOut': 'Dividir', + 'chat.flowProposal.stepKind.transform': 'Transformar', + 'chat.flowProposal.stepKind.outputParser': 'Interpretar resultado', + 'chat.flowProposal.stepKind.subWorkflow': 'Subflujo de trabajo', 'channels.authMode.managed_dm': 'Iniciar sesión con OpenHuman', 'channels.authMode.oauth': 'OAuth Iniciar sesión', 'channels.authMode.bot_token': 'Utilice su propio token de bot', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 41e7d09a00..8ce9af17f6 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -3581,6 +3581,17 @@ const messages: TranslationMap = { 'chat.flowProposal.error': "Impossible d'enregistrer le workflow. Veuillez réessayer.", 'chat.flowProposal.enableError': "Workflow enregistré, mais impossible de l'activer. Réessayez, ou activez-le depuis la page Workflows.", + 'chat.flowProposal.stepKind.agent': 'Agent', + 'chat.flowProposal.stepKind.toolCall': 'Action', + 'chat.flowProposal.stepKind.httpRequest': 'Requête web', + 'chat.flowProposal.stepKind.code': 'Exécuter du code', + 'chat.flowProposal.stepKind.condition': 'Condition', + 'chat.flowProposal.stepKind.switch': 'Sélecteur', + 'chat.flowProposal.stepKind.merge': 'Fusionner', + 'chat.flowProposal.stepKind.splitOut': 'Diviser', + 'chat.flowProposal.stepKind.transform': 'Transformer', + 'chat.flowProposal.stepKind.outputParser': 'Analyser le résultat', + 'chat.flowProposal.stepKind.subWorkflow': 'Sous-workflow', 'channels.authMode.managed_dm': 'Connectez-vous avec OpenHuman', 'channels.authMode.oauth': 'OAuth Connectez-vous', 'channels.authMode.bot_token': 'Utiliser votre propre jeton de robot', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 79138ea39f..df6772c6fd 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -3493,6 +3493,17 @@ const messages: TranslationMap = { 'chat.flowProposal.error': 'वर्कफ़्लो सहेजा नहीं जा सका। कृपया फिर से प्रयास करें।', 'chat.flowProposal.enableError': 'वर्कफ़्लो सहेजा गया, लेकिन सक्षम नहीं किया जा सका। फिर से प्रयास करें, या Workflows पेज से इसे सक्षम करें।', + 'chat.flowProposal.stepKind.agent': 'एजेंट', + 'chat.flowProposal.stepKind.toolCall': 'कार्रवाई', + 'chat.flowProposal.stepKind.httpRequest': 'वेब अनुरोध', + 'chat.flowProposal.stepKind.code': 'कोड चलाएं', + 'chat.flowProposal.stepKind.condition': 'शर्त', + 'chat.flowProposal.stepKind.switch': 'स्विच', + 'chat.flowProposal.stepKind.merge': 'मर्ज करें', + 'chat.flowProposal.stepKind.splitOut': 'विभाजित करें', + 'chat.flowProposal.stepKind.transform': 'रूपांतरित करें', + 'chat.flowProposal.stepKind.outputParser': 'आउटपुट पार्स करें', + 'chat.flowProposal.stepKind.subWorkflow': 'सब-वर्कफ़्लो', 'channels.authMode.managed_dm': 'OpenHuman से लॉगिन करें', 'channels.authMode.oauth': 'OAuth साइन-इन करें', 'channels.authMode.bot_token': 'अपने स्वयं के बॉट टोकन का उपयोग करें', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 46b4fb742c..d16a94f354 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -3510,6 +3510,17 @@ const messages: TranslationMap = { 'chat.flowProposal.error': 'Alur kerja tidak dapat disimpan. Silakan coba lagi.', 'chat.flowProposal.enableError': 'Alur kerja disimpan, tetapi tidak dapat diaktifkan. Coba lagi, atau aktifkan dari halaman Workflows.', + 'chat.flowProposal.stepKind.agent': 'Agen', + 'chat.flowProposal.stepKind.toolCall': 'Tindakan', + 'chat.flowProposal.stepKind.httpRequest': 'Permintaan web', + 'chat.flowProposal.stepKind.code': 'Jalankan kode', + 'chat.flowProposal.stepKind.condition': 'Kondisi', + 'chat.flowProposal.stepKind.switch': 'Pengalih', + 'chat.flowProposal.stepKind.merge': 'Gabungkan', + 'chat.flowProposal.stepKind.splitOut': 'Pisahkan', + 'chat.flowProposal.stepKind.transform': 'Transformasikan', + 'chat.flowProposal.stepKind.outputParser': 'Uraikan output', + 'chat.flowProposal.stepKind.subWorkflow': 'Sub-alur kerja', 'channels.authMode.managed_dm': 'Masuk dengan OpenHuman', 'channels.authMode.oauth': 'OAuth Masuk', 'channels.authMode.bot_token': 'Gunakan Token Bot Anda sendiri', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 9a86d5e142..9196f50b32 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -3557,6 +3557,17 @@ const messages: TranslationMap = { 'chat.flowProposal.error': 'Impossibile salvare il workflow. Riprova.', 'chat.flowProposal.enableError': 'Workflow salvato, ma non è stato possibile attivarlo. Riprova, oppure attivalo dalla pagina Workflows.', + 'chat.flowProposal.stepKind.agent': 'Agente', + 'chat.flowProposal.stepKind.toolCall': 'Azione', + 'chat.flowProposal.stepKind.httpRequest': 'Richiesta web', + 'chat.flowProposal.stepKind.code': 'Esegui codice', + 'chat.flowProposal.stepKind.condition': 'Condizione', + 'chat.flowProposal.stepKind.switch': 'Selettore', + 'chat.flowProposal.stepKind.merge': 'Unisci', + 'chat.flowProposal.stepKind.splitOut': 'Dividi', + 'chat.flowProposal.stepKind.transform': 'Trasforma', + 'chat.flowProposal.stepKind.outputParser': 'Analizza risultato', + 'chat.flowProposal.stepKind.subWorkflow': 'Sotto-workflow', 'channels.authMode.managed_dm': 'Accedi con OpenHuman', 'channels.authMode.oauth': 'OAuth Accedi', 'channels.authMode.bot_token': 'Usa il tuo token Bot', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 1620ecf3df..7af3e8c14e 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -3458,6 +3458,17 @@ const messages: TranslationMap = { 'chat.flowProposal.error': '워크플로를 저장할 수 없습니다. 다시 시도하세요.', 'chat.flowProposal.enableError': '워크플로는 저장되었지만 활성화할 수 없습니다. 다시 시도하거나 Workflows 페이지에서 활성화하세요.', + 'chat.flowProposal.stepKind.agent': '에이전트', + 'chat.flowProposal.stepKind.toolCall': '작업', + 'chat.flowProposal.stepKind.httpRequest': '웹 요청', + 'chat.flowProposal.stepKind.code': '코드 실행', + 'chat.flowProposal.stepKind.condition': '조건', + 'chat.flowProposal.stepKind.switch': '스위치', + 'chat.flowProposal.stepKind.merge': '병합', + 'chat.flowProposal.stepKind.splitOut': '분할', + 'chat.flowProposal.stepKind.transform': '변환', + 'chat.flowProposal.stepKind.outputParser': '출력 파싱', + 'chat.flowProposal.stepKind.subWorkflow': '하위 워크플로', 'channels.authMode.managed_dm': 'OpenHuman로 로그인', 'channels.authMode.oauth': 'OAuth 로그인', 'channels.authMode.bot_token': '자체 봇 토큰 사용', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 4a5dda9f23..c67e7cea03 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -3538,6 +3538,17 @@ const messages: TranslationMap = { 'chat.flowProposal.error': 'Nie udało się zapisać przepływu pracy. Spróbuj ponownie.', 'chat.flowProposal.enableError': 'Przepływ pracy zapisany, ale nie udało się go włączyć. Spróbuj ponownie lub włącz go na stronie Workflows.', + 'chat.flowProposal.stepKind.agent': 'Agent', + 'chat.flowProposal.stepKind.toolCall': 'Akcja', + 'chat.flowProposal.stepKind.httpRequest': 'Żądanie sieciowe', + 'chat.flowProposal.stepKind.code': 'Uruchom kod', + 'chat.flowProposal.stepKind.condition': 'Warunek', + 'chat.flowProposal.stepKind.switch': 'Przełącznik', + 'chat.flowProposal.stepKind.merge': 'Scal', + 'chat.flowProposal.stepKind.splitOut': 'Podziel', + 'chat.flowProposal.stepKind.transform': 'Przekształć', + 'chat.flowProposal.stepKind.outputParser': 'Przetwórz wynik', + 'chat.flowProposal.stepKind.subWorkflow': 'Podprzepływ pracy', 'channels.authMode.managed_dm': 'Zaloguj się z OpenHuman', 'channels.authMode.oauth': 'Logowanie OAuth', 'channels.authMode.bot_token': 'Użyj własnego tokena bota', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 712be4fbb7..7c7ebeeebd 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -3550,6 +3550,17 @@ const messages: TranslationMap = { 'chat.flowProposal.error': 'Não foi possível salvar o fluxo de trabalho. Tente novamente.', 'chat.flowProposal.enableError': 'Fluxo de trabalho salvo, mas não foi possível ativá-lo. Tente novamente ou ative-o na página Workflows.', + 'chat.flowProposal.stepKind.agent': 'Agente', + 'chat.flowProposal.stepKind.toolCall': 'Ação', + 'chat.flowProposal.stepKind.httpRequest': 'Pedido web', + 'chat.flowProposal.stepKind.code': 'Executar código', + 'chat.flowProposal.stepKind.condition': 'Condição', + 'chat.flowProposal.stepKind.switch': 'Seletor', + 'chat.flowProposal.stepKind.merge': 'Mesclar', + 'chat.flowProposal.stepKind.splitOut': 'Dividir', + 'chat.flowProposal.stepKind.transform': 'Transformar', + 'chat.flowProposal.stepKind.outputParser': 'Analisar resultado', + 'chat.flowProposal.stepKind.subWorkflow': 'Subfluxo de trabalho', 'channels.authMode.managed_dm': 'Faça login com OpenHuman', 'channels.authMode.oauth': 'OAuth Faça login', 'channels.authMode.bot_token': 'Use seu próprio token de bot', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 5bf1c48d9a..9d382dee42 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -3522,6 +3522,17 @@ const messages: TranslationMap = { 'chat.flowProposal.error': 'Не удалось сохранить рабочий процесс. Попробуйте еще раз.', 'chat.flowProposal.enableError': 'Рабочий процесс сохранён, но не удалось его включить. Попробуйте ещё раз или включите его на странице Workflows.', + 'chat.flowProposal.stepKind.agent': 'Агент', + 'chat.flowProposal.stepKind.toolCall': 'Действие', + 'chat.flowProposal.stepKind.httpRequest': 'Веб-запрос', + 'chat.flowProposal.stepKind.code': 'Выполнить код', + 'chat.flowProposal.stepKind.condition': 'Условие', + 'chat.flowProposal.stepKind.switch': 'Переключатель', + 'chat.flowProposal.stepKind.merge': 'Объединить', + 'chat.flowProposal.stepKind.splitOut': 'Разделить', + 'chat.flowProposal.stepKind.transform': 'Преобразовать', + 'chat.flowProposal.stepKind.outputParser': 'Обработать вывод', + 'chat.flowProposal.stepKind.subWorkflow': 'Подпроцесс', 'channels.authMode.managed_dm': 'Войдите с помощью OpenHuman', 'channels.authMode.oauth': 'OAuth Вход в систему', 'channels.authMode.bot_token': 'Используйте свой собственный токен бота', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 4861f5a83d..a7c7a1030d 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -3313,6 +3313,17 @@ const messages: TranslationMap = { 'chat.flowProposal.dismiss': '忽略', 'chat.flowProposal.error': '无法保存该工作流。请重试。', 'chat.flowProposal.enableError': '工作流已保存,但无法启用。请重试,或在“工作流”页面手动启用。', + 'chat.flowProposal.stepKind.agent': '智能体', + 'chat.flowProposal.stepKind.toolCall': '操作', + 'chat.flowProposal.stepKind.httpRequest': '网络请求', + 'chat.flowProposal.stepKind.code': '运行代码', + 'chat.flowProposal.stepKind.condition': '条件', + 'chat.flowProposal.stepKind.switch': '分支选择', + 'chat.flowProposal.stepKind.merge': '合并', + 'chat.flowProposal.stepKind.splitOut': '拆分', + 'chat.flowProposal.stepKind.transform': '转换', + 'chat.flowProposal.stepKind.outputParser': '解析输出', + 'chat.flowProposal.stepKind.subWorkflow': '子工作流', 'channels.authMode.managed_dm': '使用 OpenHuman 登录', 'channels.authMode.oauth': 'OAuth 登录', 'channels.authMode.bot_token': '使用你自己的 Bot Token', From 40852cdd11fff4e0ad6655dd11eef49579dbca3e Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:16:43 +0530 Subject: [PATCH 27/56] feat(flows): let the copilot live test-run flows behind the approval gate (#5090) --- .../flows/WorkflowCopilotPanel.test.tsx | 109 ++++++++++++- .../components/flows/WorkflowCopilotPanel.tsx | 34 +++- app/src/hooks/useWorkflowBuilderChat.test.ts | 41 ++++- app/src/hooks/useWorkflowBuilderChat.ts | 26 ++++ .../flows/agents/workflow_builder/agent.toml | 13 +- src/openhuman/flows/ops.rs | 146 ++++++++++++++++-- src/openhuman/flows/ops_tests.rs | 80 ++++++++++ 7 files changed, 426 insertions(+), 23 deletions(-) diff --git a/app/src/components/flows/WorkflowCopilotPanel.test.tsx b/app/src/components/flows/WorkflowCopilotPanel.test.tsx index 5c589fb29c..926c5eaba6 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.test.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.test.tsx @@ -2,7 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { WorkflowGraph, WorkflowNode } from '../../lib/flows/types'; -import type { WorkflowProposal } from '../../store/chatRuntimeSlice'; +import type { PendingApproval, WorkflowProposal } from '../../store/chatRuntimeSlice'; import WorkflowCopilotPanel from './WorkflowCopilotPanel'; vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) })); @@ -21,10 +21,22 @@ vi.mock('../../features/conversations/components/ChatThreadView', () => ({ ), })); +// `ApprovalRequestCard` / `IntegrationConnectCard` (rendered for PR3: +// flows-copilot-live-run-approval) dispatch via `useAppDispatch` internally — +// stub the store hook rather than wrapping every render in a real Redux +// `Provider`, since these tests only assert which card renders, not the +// decide/connect flow those components own (covered by their own test files). +vi.mock('../../store/hooks', () => ({ useAppDispatch: () => vi.fn() })); +// Neither card calls this on mount (only on Approve/Deny/Connect click), but +// stub it defensively so a real network call can never sneak into a render +// test. +vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() })); + const hookState = vi.hoisted(() => ({ - threadId: null as string | null, + threadId: 'builder-1' as string | null, sending: false, proposal: null as WorkflowProposal | null, + pendingApproval: null as PendingApproval | null, capped: false, error: null as string | null, send: vi.fn(), @@ -52,9 +64,10 @@ const baseGraph = graph(['a', 'b']); describe('WorkflowCopilotPanel', () => { beforeEach(() => { - hookState.threadId = null; + hookState.threadId = 'builder-1'; hookState.sending = false; hookState.proposal = null; + hookState.pendingApproval = null; hookState.capped = false; hookState.error = null; hookState.send = vi.fn().mockResolvedValue({ outcome: 'dispatched', proposed: false }); @@ -913,4 +926,94 @@ describe('WorkflowCopilotPanel', () => { expect(hookState.send.mock.calls[0][0].request.mode).toBe('revise'); }); + + // PR3 (flows-copilot-live-run-approval): `flows_build` now runs the + // streaming turn under `AgentTurnOrigin::WebChat` + `APPROVAL_CHAT_CONTEXT`, + // so a parked `run_flow` / `resume_flow_run` call surfaces here via the same + // `pendingApproval` (sourced from `pendingApprovalByThread`) the main chat's + // `Conversations.tsx` reads — reusing the EXISTING `ApprovalRequestCard` / + // `IntegrationConnectCard`, no new component. + describe('parked approval surface (PR3: flows-copilot-live-run-approval)', () => { + function approvalOf(over: Partial = {}): PendingApproval { + return { + requestId: 'req-1', + toolName: 'run_flow', + message: 'Run the saved flow "Daily digest" to test it?', + ...over, + }; + } + + it('renders nothing when there is no pending approval', () => { + hookState.pendingApproval = null; + render( + + ); + expect(screen.queryByTestId('workflow-copilot-approval')).not.toBeInTheDocument(); + }); + + it('renders the shared ApprovalRequestCard for a parked run_flow/resume_flow_run/cancel_flow_run call', () => { + hookState.pendingApproval = approvalOf({ toolName: 'run_flow' }); + render( + + ); + expect(screen.getByTestId('workflow-copilot-approval')).toBeInTheDocument(); + // ApprovalRequestCard renders the parked call's message text verbatim. + expect(screen.getByText('Run the saved flow "Daily digest" to test it?')).toBeInTheDocument(); + }); + + it('renders IntegrationConnectCard (not ApprovalRequestCard) for a parked composio_connect call', () => { + hookState.pendingApproval = approvalOf({ + toolName: 'composio_connect', + toolkit: 'slack', + message: 'Connect slack to complete your task', + }); + render( + + ); + const surface = screen.getByTestId('workflow-copilot-approval'); + expect(surface).toBeInTheDocument(); + // IntegrationConnectCard's affordance is a Connect button, not + // Approve/Deny — assert the connect-specific copy is present and the + // approve/deny copy is not, distinguishing it from ApprovalRequestCard. + expect(screen.getByText('composio.connect.connect')).toBeInTheDocument(); + expect(screen.queryByText('chat.approval.approve')).not.toBeInTheDocument(); + }); + + it('does not render the approval surface when threadId is not yet established', () => { + // Guards the `pendingApproval && threadId` render condition: a parked + // approval with no resolved thread id (shouldn't happen in practice — + // an approval can only park on a thread `flows_build` already streamed + // into — but defends against a stale/mismatched hook state). + hookState.threadId = null; + hookState.pendingApproval = approvalOf(); + render( + + ); + expect(screen.queryByTestId('workflow-copilot-approval')).not.toBeInTheDocument(); + }); + }); }); diff --git a/app/src/components/flows/WorkflowCopilotPanel.tsx b/app/src/components/flows/WorkflowCopilotPanel.tsx index e68beeb82d..214815fb53 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.tsx @@ -36,7 +36,9 @@ import { diffGraphs } from '../../lib/flows/graphDiff'; import type { WorkflowGraph } from '../../lib/flows/types'; import { useT } from '../../lib/i18n/I18nContext'; import type { WorkflowProposal } from '../../store/chatRuntimeSlice'; +import ApprovalRequestCard from '../chat/ApprovalRequestCard'; import ChatComposer from '../chat/ChatComposer'; +import IntegrationConnectCard from '../chat/IntegrationConnectCard'; import Button from '../ui/Button'; const log = createDebug('app:flows:copilot-panel'); @@ -159,7 +161,7 @@ export default function WorkflowCopilotPanel({ fullWidth = false, }: Props) { const { t } = useT(); - const { threadId, sending, proposal, capped, error, send, clearProposal } = + const { threadId, sending, proposal, pendingApproval, capped, error, send, clearProposal } = useWorkflowBuilderChat(seedThreadId); const [text, setText] = useState(''); @@ -604,6 +606,36 @@ export default function WorkflowCopilotPanel({
)} + {/* Parked ApprovalGate request for the copilot's dedicated thread (PR3: + flows-copilot-live-run-approval). `flows_build` now runs under + `AgentTurnOrigin::WebChat` + `APPROVAL_CHAT_CONTEXT` when streaming, + so a `run_flow` / `resume_flow_run` call parks here instead of + auto-allowing or being hidden — surfaced via the SAME + `pendingApprovalByThread` slice / `approval_request` socket event + `Conversations.tsx` reads for the main chat, reusing the identical + cards (no new component, no new i18n keys). `composio_connect` parks + on the same gate but needs a Connect button + OAuth poll rather than + approve/deny, mirroring `Conversations.tsx`'s branch. Rendered above + the composer, outside the scrollable transcript, so it stays visible + regardless of scroll position. */} + {pendingApproval && threadId && ( +
+ {pendingApproval.toolName === 'composio_connect' ? ( + + ) : ( + + )} +
+ )} + ({ toolTimelineByThread: {} as Record, streamingAssistantByThread: {} as Record, inferenceTurnLifecycleByThread: {} as Record, + pendingApprovalByThread: {} as Record, })); vi.mock('../store/hooks', () => ({ useAppDispatch: () => dispatch, @@ -33,6 +38,7 @@ vi.mock('../store/hooks', () => ({ toolTimelineByThread: selectorState.toolTimelineByThread, streamingAssistantByThread: selectorState.streamingAssistantByThread, inferenceTurnLifecycleByThread: selectorState.inferenceTurnLifecycleByThread, + pendingApprovalByThread: selectorState.pendingApprovalByThread, }, }), })); @@ -73,6 +79,7 @@ describe('useWorkflowBuilderChat', () => { selectorState.toolTimelineByThread = {}; selectorState.streamingAssistantByThread = {}; selectorState.inferenceTurnLifecycleByThread = {}; + selectorState.pendingApprovalByThread = {}; dispatch.mockReset().mockImplementation((action: { type: string }) => { if (action.type === 'createNewThread') { return { unwrap: () => Promise.resolve({ id: 'builder-1' }) }; @@ -462,6 +469,38 @@ describe('useWorkflowBuilderChat', () => { }); }); + // PR3 (flows-copilot-live-run-approval): `pendingApproval` is a thin + // thread-scoped selector over `pendingApprovalByThread` — the same slice + // `Conversations.tsx` reads for the main chat's `ApprovalRequestCard`. + describe('pendingApproval', () => { + it('is null before a thread exists', () => { + const { result } = renderHook(() => useWorkflowBuilderChat()); + expect(result.current.pendingApproval).toBeNull(); + }); + + it('surfaces the parked approval for the dedicated/seeded thread', () => { + const approval: PendingApproval = { + requestId: 'req-1', + toolName: 'run_flow', + message: 'Run the saved flow "Daily digest"?', + }; + selectorState.pendingApprovalByThread = { 'builder-1': approval }; + const { result } = renderHook(() => useWorkflowBuilderChat('builder-1')); + expect(result.current.pendingApproval).toEqual(approval); + }); + + it('does not surface an approval parked on a DIFFERENT thread', () => { + const approval: PendingApproval = { + requestId: 'req-1', + toolName: 'run_flow', + message: 'Run the saved flow "Daily digest"?', + }; + selectorState.pendingApprovalByThread = { 'some-other-thread': approval }; + const { result } = renderHook(() => useWorkflowBuilderChat('builder-1')); + expect(result.current.pendingApproval).toBeNull(); + }); + }); + describe('displayMessages', () => { it('excludes isInterim agent messages but keeps user + terminal agent messages (incl. a clarifying question)', () => { selectorState.messagesByThreadId = { diff --git a/app/src/hooks/useWorkflowBuilderChat.ts b/app/src/hooks/useWorkflowBuilderChat.ts index d17bce0f1b..638af036a3 100644 --- a/app/src/hooks/useWorkflowBuilderChat.ts +++ b/app/src/hooks/useWorkflowBuilderChat.ts @@ -38,6 +38,7 @@ import { endInferenceTurn, fetchAndHydrateTurnHistory, fetchAndHydrateTurnState, + type PendingApproval, setWorkflowProposalForThread, type ToolTimelineEntry, type WorkflowProposal, @@ -111,6 +112,18 @@ export interface UseWorkflowBuilderChat { turnActive: boolean; /** The latest proposal the agent returned on this thread, or `null`. */ proposal: WorkflowProposal | null; + /** + * A parked `ApprovalGate` request for this thread (PR3: + * flows-copilot-live-run-approval), or `null`. The copilot's `flows_build` + * turn now runs `run_flow` / `resume_flow_run` under the same + * `AgentTurnOrigin::WebChat` + `APPROVAL_CHAT_CONTEXT` scope a real + * interactive chat turn uses, so a live test-run parks here instead of + * either auto-allowing or being hidden outright. Sourced from the SAME + * `pendingApprovalByThread` slice / `approval_request` socket event the + * main chat's `ApprovalRequestCard` reads — no new plumbing, just scoped to + * this hook's dedicated thread. + */ + pendingApproval: PendingApproval | null; /** * `true` when the most recently settled turn paused because it hit the * agent's tool-call budget with no proposal yet (B34) — the caller should @@ -214,6 +227,9 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo const inferenceTurnLifecycleByThread = useAppSelector( state => state.chatRuntime.inferenceTurnLifecycleByThread ); + const pendingApprovalByThread = useAppSelector( + state => state.chatRuntime.pendingApprovalByThread + ); // A turn is in flight on this thread iff its lifecycle entry is `'started'` // or `'streaming'` — NOT `'interrupted'`, which `hydrateRuntimeFromSnapshot` @@ -236,6 +252,15 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo [threadId, proposalsByThread] ); + // PR3 (flows-copilot-live-run-approval): mirrors `proposal` above — read the + // shared `pendingApprovalByThread` slice scoped to this hook's dedicated + // thread, so a parked `run_flow`/`resume_flow_run` call surfaces here the + // same way `Conversations.tsx` surfaces one for the main chat. + const pendingApproval = useMemo( + () => (threadId ? (pendingApprovalByThread[threadId] ?? null) : null), + [threadId, pendingApprovalByThread] + ); + const messages = useMemo( () => (threadId ? (messagesByThreadId[threadId] ?? EMPTY_MESSAGES) : EMPTY_MESSAGES), [threadId, messagesByThreadId] @@ -473,6 +498,7 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo sending, turnActive, proposal, + pendingApproval, capped, messages, displayMessages, diff --git a/src/openhuman/flows/agents/workflow_builder/agent.toml b/src/openhuman/flows/agents/workflow_builder/agent.toml index c004043381..729793b255 100644 --- a/src/openhuman/flows/agents/workflow_builder/agent.toml +++ b/src/openhuman/flows/agents/workflow_builder/agent.toml @@ -14,9 +14,16 @@ max_result_chars = 12000 sandbox_mode = "none" omit_identity = true omit_memory_context = true -# Safety preamble stays OFF: this agent has zero real external effect (it only -# proposes validated graphs and reads), so the general acting guard-rails would -# be noise. The "propose, never persist" invariant is taught in prompt.md. +# Safety preamble stays OFF: authoring itself (propose/revise/dry-run/reads) +# has zero real external effect, so the general acting guard-rails would be +# noise there — the "propose, never persist" invariant is taught in prompt.md. +# The one real-effect surface, `run_flow` (a confirmed test-run of an already +# saved flow), is gated by the `ApprovalGate` itself, not this preamble: on the +# streaming copilot path (flows-copilot-live-run-approval, PR3) it parks behind +# a real human approval card (`AgentTurnOrigin::WebChat` + +# `APPROVAL_CHAT_CONTEXT`, same as main chat); on the headless `flows_build` +# path it stays hidden entirely (`restrict_builder_toolset`, #4593/#4881) since +# there is no approval surface to park against there. omit_safety_preamble = true omit_skills_catalog = true diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index 62a2968054..1baf799536 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -10,7 +10,9 @@ use serde_json::{json, Value}; use tinyflows::model::{NodeKind, TriggerKind, WorkflowGraph}; use crate::openhuman::agent::turn_origin::{with_origin, AgentTurnOrigin, TrustedAutomationSource}; -use crate::openhuman::approval::{FlowRunContext, APPROVAL_FLOW_RUN_CONTEXT}; +use crate::openhuman::approval::{ + ApprovalChatContext, FlowRunContext, APPROVAL_CHAT_CONTEXT, APPROVAL_FLOW_RUN_CONTEXT, +}; use crate::openhuman::config::Config; use crate::openhuman::flows::bus; use crate::openhuman::flows::draft_store; @@ -4266,6 +4268,54 @@ fn restrict_builder_toolset(agent: &mut crate::openhuman::agent::Agent) { agent.hide_tools(FLOWS_BUILD_HIDDEN_TOOLS); } +/// Tools stripped from the `workflow_builder` belt on the STREAMING +/// (copilot-pane) `flows_build` path — the reduced sibling of +/// [`FLOWS_BUILD_HIDDEN_TOOLS`] used by [`restrict_builder_toolset`] on the +/// headless path. +/// +/// PR3 (flows-copilot-live-run-approval): when a chat thread is attached +/// (`stream.is_some()`), `flows_build` now runs the builder under +/// [`AgentTurnOrigin::WebChat`] with [`APPROVAL_CHAT_CONTEXT`] scoped +/// alongside it — the exact same double-scope the main web-chat delegate uses +/// (`web_chat::ops::run_turn_under_cancel_and_deadline`). Under that origin +/// the [`crate::openhuman::approval::ApprovalGate`] no longer auto-allows +/// `external_effect` tools; it PARKS them for a real human decision, routed +/// back to this thread via the existing `approval_request` socket event and +/// rendered with the existing `ApprovalRequestCard` in the copilot panel. So +/// `run_flow` and `resume_flow_run` — both `external_effect() == true` — no +/// longer need to be hidden on this path: they are reachable, but gated +/// behind a real approval, exactly like a main-chat tool call. +/// +/// `cancel_flow_run` stays HIDDEN on this path, though. It reports +/// `external_effect() == false`, so `ApprovalSecurityMiddleware` would not park +/// it behind the approval surface — and the tool cancels an arbitrary run id +/// (e.g. one read from `list_flow_runs`) with no ownership check. An unhidden +/// `cancel_flow_run` would therefore let a streaming copilot turn cancel ANY +/// in-flight or approval-parked run, unapproved — far broader than the "stop a +/// run the copilot itself started" companion use it was meant for. Until it +/// gains an ownership/approval guard it is kept hidden here (a user can still +/// cancel from the Runs rail). (codex review, #5090.) +/// +/// `run_workflow` (the unrelated legacy skills-workflow runner sharing this +/// belt) stays hidden on BOTH paths — belt-and-braces against a re-rename or +/// the name ever leaking back onto the `workflow_builder` toolset; `hide_tools` +/// no-ops on a name that isn't present. +const FLOWS_BUILD_COPILOT_HIDDEN_TOOLS: &[&str] = &["run_workflow", "cancel_flow_run"]; + +/// Strip only [`FLOWS_BUILD_COPILOT_HIDDEN_TOOLS`] from `agent`'s callable set +/// on the streaming `flows_build` path (copilot pane with a real approval +/// surface) — see that constant's doc for the full safety rationale. +fn restrict_builder_toolset_for_copilot(agent: &mut crate::openhuman::agent::Agent) { + tracing::info!( + target: "flows", + hidden = ?FLOWS_BUILD_COPILOT_HIDDEN_TOOLS, + "[flows] flows_build: streaming copilot turn — run_flow/resume_flow_run stay visible \ + (gated behind the WebChat approval surface); run_workflow + cancel_flow_run hidden \ + (cancel_flow_run has no external_effect to park and no run-ownership guard)" + ); + agent.hide_tools(FLOWS_BUILD_COPILOT_HIDDEN_TOOLS); +} + /// Runs the `workflow_builder` agent for one authoring turn and returns its /// proposal, invoking it as a first-class backend agent (exactly like the Flow /// Scout `flows_discover`) rather than routing a hand-crafted delegate prompt @@ -4316,13 +4366,35 @@ pub async fn flows_build( .map_err(|e| format!("failed to build workflow_builder agent: {e:#}"))?; agent.set_agent_definition_name("workflow_builder".to_string()); - // Strip the live-run tool(s) from the belt on this direct RPC path: under - // the `AgentTurnOrigin::Cli` origin below the approval gate auto-allows - // every external_effect tool, so `run_flow` could execute a live saved flow - // with no HITL confirmation (issue #4593). Restricting the visible set makes - // it `Deny` at the tool-call boundary; the authoring tools are untouched so - // the turn still runs headless without fail-closing. - restrict_builder_toolset(&mut agent); + // Restrict the visible run-advancing tools per path (PR3: + // flows-copilot-live-run-approval). Streaming (copilot pane, real approval + // surface below) only hides the always-hidden `run_workflow`; headless + // (CLI / tests / no chat thread) keeps the full historical hide-list + // (issue #4593 / #4881) since there is no routable approval surface there. + // + // The reduced (copilot) hide-list is safe ONLY when the process-global + // `ApprovalGate` is actually installed to park the unhidden + // `run_flow`/`resume_flow_run`. `flows_build` is a public RPC and the gate + // can be opted out (`OPENHUMAN_APPROVAL_GATE=0` on CLI/docker leaves + // `ApprovalGate::try_global()` == `None`; desktop always installs it) — and + // `ApprovalSecurityMiddleware` skips interception entirely when the gate is + // absent, so the WebChat origin below would NOT park and the unhidden + // live-run tools would execute unapproved. Fall back to the full hide-list + // whenever the gate is not installed, regardless of `stream`. (codex #5090) + let approval_gate_active = crate::openhuman::approval::ApprovalGate::try_global().is_some(); + if stream.is_some() && approval_gate_active { + restrict_builder_toolset_for_copilot(&mut agent); + } else { + if stream.is_some() { + tracing::warn!( + target: "flows", + "[flows] flows_build: streaming turn but no ApprovalGate installed \ + (OPENHUMAN_APPROVAL_GATE off / headless) — keeping the full live-run \ + hide-list so run_flow/resume_flow_run cannot execute unapproved" + ); + } + restrict_builder_toolset(&mut agent); + } // When a chat thread is attached (the copilot pane), stream the builder turn // into it exactly like an interactive turn — text/tool deltas and the @@ -4332,21 +4404,65 @@ pub async fn flows_build( attach_flow_progress_bridge(&mut agent, target, "flows_build", config); } - // Run to completion under a CLI origin (internal, user-initiated — the - // approval gate must not fail-closed), bounded by a wall-clock timeout. When - // streaming, wrap the run in the thread-id scope so descendant turns tag - // their trace + socket events with this thread. - let run = with_origin(AgentTurnOrigin::Cli, agent.run_single(&prompt)); - let run = tokio::time::timeout(std::time::Duration::from_secs(FLOW_BUILD_TIMEOUT_SECS), run); + // Run to completion, bounded by a wall-clock timeout. PR3 + // (flows-copilot-live-run-approval): the origin now depends on whether a + // chat thread is attached. + // + // - Streaming (copilot pane): run under `AgentTurnOrigin::WebChat` with + // `APPROVAL_CHAT_CONTEXT` scoped alongside it — the identical + // double-scope pattern `web_chat::ops::run_turn_under_cancel_and_deadline` + // uses for a real interactive chat turn. The approval gate then PARKS + // (rather than auto-allows) any `external_effect` tool call instead of + // failing closed, and the resulting `ApprovalRequested` event routes back + // to this thread (`client_id: "system"` — every client auto-joins that + // broadcast room, matching the progress bridge above) for the existing + // `ApprovalRequestCard` to render. The run is additionally wrapped in the + // thread-id scope so descendant turns tag their trace + socket events + // with this thread. + // - Headless (CLI / tests / no chat thread): unchanged `AgentTurnOrigin::Cli` + // — the gate auto-allows `external_effect` tools under that origin, which + // is why `restrict_builder_toolset` above must keep the full hide-list on + // this path; there is no routable approval surface here to park against. let timed = match &stream { Some(target) => { + let origin = AgentTurnOrigin::WebChat { + thread_id: target.thread_id.clone(), + client_id: "system".to_string(), + request_id: Some(target.request_id.clone()), + }; + let chat_ctx = ApprovalChatContext { + thread_id: target.thread_id.clone(), + client_id: "system".to_string(), + }; + tracing::info!( + target: "flows", + thread_id = %target.thread_id, + request_id = %target.request_id, + "[flows] flows_build: streaming copilot turn — WebChat origin + \ + APPROVAL_CHAT_CONTEXT scoped, live-run tools park for approval instead \ + of auto-allowing" + ); + let run = with_origin( + origin, + APPROVAL_CHAT_CONTEXT.scope(chat_ctx, agent.run_single(&prompt)), + ); + let run = + tokio::time::timeout(std::time::Duration::from_secs(FLOW_BUILD_TIMEOUT_SECS), run); crate::openhuman::inference::provider::thread_context::with_thread_id( target.thread_id.clone(), run, ) .await } - None => run.await, + None => { + tracing::debug!( + target: "flows", + "[flows] flows_build: headless/CLI turn — Cli origin, approval gate \ + auto-allows external_effect tools (run-advancing tools stay hidden)" + ); + let run = with_origin(AgentTurnOrigin::Cli, agent.run_single(&prompt)); + tokio::time::timeout(std::time::Duration::from_secs(FLOW_BUILD_TIMEOUT_SECS), run).await + } }; let (assistant_text, run_error) = match timed { Ok(Ok(text)) => (text, None), diff --git a/src/openhuman/flows/ops_tests.rs b/src/openhuman/flows/ops_tests.rs index 2cd76a7081..70e5fbc282 100644 --- a/src/openhuman/flows/ops_tests.rs +++ b/src/openhuman/flows/ops_tests.rs @@ -3886,6 +3886,86 @@ async fn flows_build_hides_the_live_run_tool_from_the_builder_belt() { } } +/// Pins the exact contents of both `flows_build` hide-lists so a future edit +/// can't silently narrow/widen either belt without a test catching it +/// (PR3: flows-copilot-live-run-approval). +#[test] +fn flows_build_hide_lists_have_the_expected_contents() { + assert_eq!( + FLOWS_BUILD_COPILOT_HIDDEN_TOOLS, + ["run_workflow", "cancel_flow_run"], + "the streaming (copilot) hide-list must hide the legacy `run_workflow` AND \ + `cancel_flow_run` — the latter has no external_effect to park and no \ + run-ownership guard (codex #5090), so it must NOT be exposed unapproved; \ + only `run_flow`/`resume_flow_run` stay visible, gated by the WebChat \ + approval surface" + ); + for tool in [ + "run_workflow", + "run_flow", + "resume_flow_run", + "cancel_flow_run", + ] { + assert!( + FLOWS_BUILD_HIDDEN_TOOLS.contains(&tool), + "the headless hide-list must still contain `{tool}` (existing #4593/#4881 \ + contract) — {FLOWS_BUILD_HIDDEN_TOOLS:?}" + ); + } +} + +/// Streaming (copilot) path: `restrict_builder_toolset_for_copilot` leaves +/// `run_flow` / `resume_flow_run` visible on the builder's belt — they're gated +/// by the WebChat approval surface, not hidden — while hiding the unrelated +/// legacy `run_workflow` AND `cancel_flow_run` (the latter can't be parked and +/// has no run-ownership guard — codex #5090) and keeping every authoring tool +/// reachable (PR3: flows-copilot-live-run-approval). +#[tokio::test] +async fn flows_build_copilot_toolset_unhides_the_live_run_tools() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + crate::openhuman::agent::harness::AgentDefinitionRegistry::init_global(&config.workspace_dir) + .expect("agent registry init"); + let mut agent = + crate::openhuman::agent::Agent::from_config_for_agent(&config, "workflow_builder") + .expect("build workflow_builder agent"); + agent.set_agent_definition_name("workflow_builder".to_string()); + + restrict_builder_toolset_for_copilot(&mut agent); + + let visible = agent.visible_tool_names_for_test(); + for still_reachable in ["run_flow", "resume_flow_run"] { + assert!( + visible.contains(still_reachable), + "`{still_reachable}` must stay reachable on the streaming copilot path — it \ + is gated behind the WebChat approval surface, not hidden; visible = {visible:?}" + ); + } + for hidden in ["run_workflow", "cancel_flow_run"] { + assert!( + !visible.contains(hidden), + "`{hidden}` must stay hidden on the copilot path (legacy runner / \ + unparkable-and-unguarded cancel — codex #5090); visible = {visible:?}" + ); + } + for keep in [ + "propose_workflow", + "revise_workflow", + "save_workflow", + "dry_run_workflow", + "list_flows", + "create_workflow", + "duplicate_flow", + ] { + assert!( + visible.contains(keep), + "authoring tool `{keep}` must remain visible on the copilot path; visible = \ + {visible:?}" + ); + } +} + /// Regression for issue #4868 (systemic fix, superseding the old B31 /// per-caller `apply_builder_iteration_cap` override): `flows_build` must get /// an agent carrying the `workflow_builder` `AgentDefinition`'s From 81f58eef63d192fb4b12a344721cecf88f159d71 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Wed, 22 Jul 2026 04:24:09 +0300 Subject: [PATCH 28/56] test(ci): exercise full validation suite (#5083) --- .github/workflows/ci-full.yml | 12 ++- .github/workflows/e2e-playwright.yml | 17 +++- .github/workflows/e2e-reusable.yml | 9 ++- app/scripts/e2e-run-shards.sh | 6 +- app/test/core-rpc-node.test.ts | 53 +++++++++++- app/test/e2e/helpers/chat-harness.ts | 62 +++++++------- app/test/e2e/helpers/core-rpc-node.ts | 80 ++++++++++++------- .../e2e/specs/chat-multi-tool-round.spec.ts | 4 +- app/test/e2e/specs/insights-dashboard.spec.ts | 39 ++++----- app/test/e2e/specs/notifications.spec.ts | 4 +- app/test/e2e/specs/onboarding-modes.spec.ts | 8 +- .../settings-account-preferences.spec.ts | 4 +- .../specs/settings-advanced-config.spec.ts | 36 ++++++++- app/test/e2e/specs/settings-ai-skills.spec.ts | 4 +- .../e2e/specs/settings-dev-options.spec.ts | 4 +- .../settings-feature-preferences.spec.ts | 6 +- .../e2e/specs/webhooks-tunnel-flow.spec.ts | 50 ++++-------- app/test/playwright/helpers/core-rpc.ts | 28 +++---- .../specs/composio-triggers-flow.spec.ts | 11 +-- .../specs/connector-gmail-composio.spec.ts | 18 ++--- .../connector-session-guard-matrix.spec.ts | 12 +-- .../specs/guided-tour-gates.spec.ts | 6 +- .../specs/harness-cron-prompt-flow.spec.ts | 2 +- .../specs/insights-dashboard.spec.ts | 18 ++--- .../intelligence-memory-ui-functional.spec.ts | 29 +++---- .../playwright/specs/notifications.spec.ts | 4 +- .../rewards-progression-persistence.spec.ts | 4 +- .../specs/rewards-unlock-flow.spec.ts | 2 +- .../settings-account-preferences.spec.ts | 11 ++- .../specs/settings-advanced-config.spec.ts | 26 +++--- .../settings-feature-preferences.spec.ts | 58 +++++++++----- .../specs/settings-leaf-workflows.spec.ts | 17 ++-- .../specs/webhooks-tunnel-flow.spec.ts | 11 +-- docs/TEST-COVERAGE-MATRIX.md | 10 +-- src/openhuman/config/ops/mod.rs | 4 +- src/openhuman/tinyagents/middleware.rs | 4 +- tests/agent_harness_e2e.rs | 32 ++++++-- tests/json_rpc_e2e.rs | 36 +++++++++ tests/personality_e2e.rs | 2 + ...gent_harness_leftovers_raw_coverage_e2e.rs | 2 + ...agent_prompts_subagent_raw_coverage_e2e.rs | 2 + .../agent_round26_raw_coverage_e2e.rs | 6 ++ .../agent_session_round24_raw_coverage_e2e.rs | 2 + ...osio_credentials_state_raw_coverage_e2e.rs | 9 ++- .../inference_agent_raw_coverage_e2e.rs | 4 + tests/raw_coverage/memory_raw_coverage_e2e.rs | 1 + .../memory_threads_raw_coverage_e2e.rs | 2 + 47 files changed, 476 insertions(+), 295 deletions(-) diff --git a/.github/workflows/ci-full.yml b/.github/workflows/ci-full.yml index 124b0f43c2..734c048912 100644 --- a/.github/workflows/ci-full.yml +++ b/.github/workflows/ci-full.yml @@ -287,13 +287,21 @@ jobs: mkdir -p "$OPENHUMAN_WORKSPACE" bash scripts/ci-cancel-aware.sh bash app/scripts/e2e-web-session.sh + - name: Pack Playwright E2E failure artifacts + if: failure() + run: | + mkdir -p .ci/artifacts + tar -czf .ci/artifacts/openhuman-playwright-failure-logs.tar.gz \ + -C "$OPENHUMAN_WORKSPACE" . + env: + OPENHUMAN_WORKSPACE: ${{ runner.temp }}/openhuman-playwright-workspace + - name: Upload Playwright E2E failure artifacts if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: e2e-playwright-failure-logs-${{ github.run_id }} - path: | - ${{ runner.temp }}/openhuman-playwright-workspace/** + path: .ci/artifacts/openhuman-playwright-failure-logs.tar.gz retention-days: 7 if-no-files-found: ignore diff --git a/.github/workflows/e2e-playwright.yml b/.github/workflows/e2e-playwright.yml index b99adf6484..1ee21ff721 100644 --- a/.github/workflows/e2e-playwright.yml +++ b/.github/workflows/e2e-playwright.yml @@ -26,7 +26,10 @@ jobs: runs-on: ubuntu-22.04 container: image: ghcr.io/tinyhumansai/openhuman_ci:latest - timeout-minutes: 30 + # The complete serial web suite currently takes about 45 minutes on the + # shared runner; keep the standalone diagnostic workflow aligned with the + # 90-minute budget used by CI Full. + timeout-minutes: 90 steps: - name: Checkout code uses: actions/checkout@v7 @@ -73,12 +76,20 @@ jobs: mkdir -p "$OPENHUMAN_WORKSPACE" bash scripts/ci-cancel-aware.sh bash app/scripts/e2e-web-session.sh + - name: Pack Playwright E2E failure artifacts + if: failure() + run: | + mkdir -p .ci/artifacts + tar -czf .ci/artifacts/openhuman-playwright-failure-logs.tar.gz \ + -C "$OPENHUMAN_WORKSPACE" . + env: + OPENHUMAN_WORKSPACE: ${{ runner.temp }}/openhuman-playwright-workspace + - name: Upload Playwright E2E failure artifacts if: failure() uses: actions/upload-artifact@v7 with: name: e2e-playwright-failure-logs-${{ github.run_id }} - path: | - ${{ runner.temp }}/openhuman-playwright-workspace/** + path: .ci/artifacts/openhuman-playwright-failure-logs.tar.gz retention-days: 7 if-no-files-found: ignore diff --git a/.github/workflows/e2e-reusable.yml b/.github/workflows/e2e-reusable.yml index 2e0432060a..85254b6878 100644 --- a/.github/workflows/e2e-reusable.yml +++ b/.github/workflows/e2e-reusable.yml @@ -291,7 +291,8 @@ jobs: - { name: provider-web, suites: "provider-web" } - { name: webhooks, suites: "webhooks" } - { name: connectors, suites: "connectors" } - - { name: commerce, suites: "payments,settings" } + - { name: payments, suites: "payments" } + - { name: settings, suites: "settings" } steps: - name: Checkout code uses: actions/checkout@v7 @@ -765,7 +766,8 @@ jobs: - { name: provider-web, suites: "provider-web" } - { name: webhooks, suites: "webhooks" } - { name: connectors, suites: "connectors" } - - { name: commerce, suites: "payments,settings" } + - { name: payments, suites: "payments" } + - { name: settings, suites: "settings" } steps: - name: Checkout code uses: actions/checkout@v7 @@ -976,7 +978,8 @@ jobs: - { name: provider-web, suites: "provider-web" } - { name: webhooks, suites: "webhooks" } - { name: connectors, suites: "connectors" } - - { name: commerce, suites: "payments,settings" } + - { name: payments, suites: "payments" } + - { name: settings, suites: "settings" } steps: - name: Checkout code uses: actions/checkout@v7 diff --git a/app/scripts/e2e-run-shards.sh b/app/scripts/e2e-run-shards.sh index f6f9b8d5cc..62fb5b63f6 100755 --- a/app/scripts/e2e-run-shards.sh +++ b/app/scripts/e2e-run-shards.sh @@ -18,7 +18,8 @@ # chat = chat, skills, journeys # integrations = providers, webhooks, notifications # connectors = connectors -# commerce = payments, settings +# payments = payments +# settings = settings # set -uo pipefail @@ -32,7 +33,8 @@ SHARDS=( "providers:providers,notifications" "webhooks:webhooks" "connectors:connectors" - "commerce:payments,settings" + "payments:payments" + "settings:settings" ) # Allow filtering: `bash e2e-run-shards.sh foundation chat` diff --git a/app/test/core-rpc-node.test.ts b/app/test/core-rpc-node.test.ts index 05cc00bcc8..5af670abf0 100644 --- a/app/test/core-rpc-node.test.ts +++ b/app/test/core-rpc-node.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { formatRpcCallFailure } from './e2e/helpers/core-rpc-node'; @@ -15,3 +15,54 @@ describe('formatRpcCallFailure', () => { ); }); }); + +describe('callOpenhumanRpcNode', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it('rediscovers the core when the cached listener disappears after a reset', async () => { + const requestedUrls: string[] = []; + let firstListenerAlive = true; + + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + requestedUrls.push(url); + const body = JSON.parse(String(init?.body)) as { method: string }; + + if (url.includes(':7788/')) { + if (body.method === 'core.ping' && firstListenerAlive) { + return new Response('', { status: 401 }); + } + if (body.method === 'openhuman.first_call' && firstListenerAlive) { + return Response.json({ result: 'first' }); + } + throw new TypeError('fetch failed'); + } + + if (url.includes(':7789/')) { + if (body.method === 'core.ping') return new Response('', { status: 401 }); + return Response.json({ result: 'replacement' }); + } + + throw new TypeError('fetch failed'); + }) + ); + + const { callOpenhumanRpcNode } = await import('./e2e/helpers/core-rpc-node'); + await expect(callOpenhumanRpcNode('openhuman.first_call')).resolves.toMatchObject({ + ok: true, + result: 'first', + }); + + firstListenerAlive = false; + await expect(callOpenhumanRpcNode('openhuman.after_reset')).resolves.toMatchObject({ + ok: true, + result: 'replacement', + }); + expect(requestedUrls.some(url => url.includes(':7789/rpc'))).toBe(true); + }); +}); diff --git a/app/test/e2e/helpers/chat-harness.ts b/app/test/e2e/helpers/chat-harness.ts index b0173a3c8c..5319dd7deb 100644 --- a/app/test/e2e/helpers/chat-harness.ts +++ b/app/test/e2e/helpers/chat-harness.ts @@ -78,42 +78,40 @@ export async function chatMounted(): Promise { /** Type into the chat composer through WebDriver so React's controlled * input state and the DOM stay in sync. */ export async function typeIntoComposer(text: string): Promise { - const composer = await browser.$(COMPOSER_SELECTOR); - await composer.waitForDisplayed({ timeout: 10_000 }); - await composer.waitForEnabled({ timeout: 10_000 }); + let actual = ''; + for (let attempt = 1; attempt <= 3; attempt += 1) { + // Creating a thread can replace the controlled textarea after the selected + // thread id changes. Resolve it afresh on every attempt so a late React + // commit cannot leave WebDriver typing into a detached element. + const composer = await browser.$(COMPOSER_SELECTOR); + await composer.waitForDisplayed({ timeout: 10_000 }); + await composer.waitForEnabled({ timeout: 10_000 }); - // Step 1: Focus via JS — avoids the coordinate-based click that gets - // intercepted by AppUpdatePrompt (z-[9998], fixed bottom-4 right-4). - // We also select-all any existing text so the subsequent delete clears it. - const focused = await browser.execute((sel: string) => { - const el = document.querySelector(sel) as HTMLTextAreaElement | null; - if (!el) return false; - el.focus(); - el.select(); - return true; - }, COMPOSER_SELECTOR); - if (!focused) { - throw new Error('typeIntoComposer: textarea not found'); - } + // Focus via JS — avoids the coordinate-based click that gets intercepted + // by AppUpdatePrompt. Select any partial value before deleting it. + const focused = await browser.execute((sel: string) => { + const el = document.querySelector(sel) as HTMLTextAreaElement | null; + if (!el) return false; + el.focus(); + el.select(); + return true; + }, COMPOSER_SELECTOR); + if (!focused) continue; - // Step 2: Clear existing content. el.select() inside browser.execute already - // selected all text; browser.keys('Delete') now removes the selection so - // React's controlled state sees an empty value before we start typing. - await browser.pause(80); - await browser.keys('Delete'); - await browser.pause(80); + await browser.pause(80); + await browser.keys('Delete'); + await browser.pause(80); - // Step 3: Type the text using real OS-level keyboard events (browser.keys). - // Unlike synthetic DOM events dispatched via browser.execute(), these go - // through Chromium's normal input pipeline, triggering React's onChange - // on the controlled textarea and correctly updating `inputValue` state so - // the send button becomes enabled. - await browser.keys(text.split('')); + // Real keyboard events keep React's controlled state and the DOM in sync. + await browser.keys(text.split('')); + await browser.pause(200); + actual = String(await composer.getValue()); + if (actual === text) return; + } - await browser.waitUntil(async () => (await composer.getValue()) === text, { - timeout: 5_000, - timeoutMsg: 'chat composer did not receive typed text', - }); + throw new Error( + `chat composer did not receive typed text after 3 attempts (actual length ${actual.length}, expected ${text.length})` + ); } /** Click the chat composer's send button. Returns `false` if the diff --git a/app/test/e2e/helpers/core-rpc-node.ts b/app/test/e2e/helpers/core-rpc-node.ts index 08b71b5576..b6693f03a4 100644 --- a/app/test/e2e/helpers/core-rpc-node.ts +++ b/app/test/e2e/helpers/core-rpc-node.ts @@ -90,7 +90,13 @@ function coreHost(): string { return (process.env.OPENHUMAN_CORE_HOST || '127.0.0.1').trim() || '127.0.0.1'; } -/** Ports to try when OPENHUMAN_CORE_PORT is unset (matches typical dev sidecar range). */ +/** Ports to try when OPENHUMAN_CORE_PORT is unset. + * + * Keep this exactly aligned with connectivity::rpc's desktop fallback range. + * A data reset restarts the embedded core; on Windows the preferred socket can + * remain unavailable briefly, so the replacement listener may bind as high as + * 7798. Stopping at 7793 makes every later RPC test wait out the full probe + * deadline even though the restarted core is healthy. */ function defaultPortProbeList(): number[] { const raw = process.env.OPENHUMAN_CORE_PORT?.trim(); if (raw) { @@ -100,7 +106,7 @@ function defaultPortProbeList(): number[] { } } const ports: number[] = []; - for (let port = 7788; port <= 7793; port += 1) ports.push(port); + for (let port = 7788; port <= 7798; port += 1) ports.push(port); return ports; } @@ -113,6 +119,9 @@ async function tryPingRpc(url: string): Promise { method: 'POST', headers: buildHeaders(false), body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'core.ping', params: {} }), + // A recently stopped listener can take several seconds to reject on + // Windows. Keep discovery inside resetApp's eight-second RPC budget. + signal: AbortSignal.timeout(750), }); // 401 means "endpoint exists, auth required" — that's a positive match // for the core RPC URL; the real call will retry with auth attached. @@ -130,7 +139,10 @@ async function tryPingRpc(url: string): Promise { * `OPENHUMAN_CORE_HOST` + `OPENHUMAN_CORE_PORT`, then probe host:port until core.ping succeeds. */ export async function resolveCoreRpcUrl(): Promise { - if (cachedRpcUrl) return cachedRpcUrl; + if (cachedRpcUrl) { + if (await tryPingRpc(cachedRpcUrl)) return cachedRpcUrl; + cachedRpcUrl = null; + } const env = process.env.OPENHUMAN_CORE_RPC_URL?.trim(); if (env) { @@ -162,33 +174,43 @@ export async function callOpenhumanRpcNode( method: string, params: Record = {} ): Promise> { - try { - const rpcUrl = await resolveCoreRpcUrl(); - const id = Math.floor(Math.random() * 1e9); - const res = await fetch(rpcUrl, { - method: 'POST', - headers: buildHeaders(), - body: JSON.stringify({ jsonrpc: '2.0', id, method, params }), - }); - const text = await res.text(); - let json: { error?: { message?: string }; result?: T }; + for (let attempt = 0; attempt < 2; attempt += 1) { try { - json = JSON.parse(text) as typeof json; - } catch { - return { - ok: false, - httpStatus: res.status, - error: `Invalid JSON (${res.status}): ${text.slice(0, 240)}`, - }; - } - if (!res.ok) { - return { ok: false, httpStatus: res.status, error: text.slice(0, 500) }; - } - if (json.error) { - return { ok: false, error: json.error.message || JSON.stringify(json.error) }; + const rpcUrl = await resolveCoreRpcUrl(); + const id = Math.floor(Math.random() * 1e9); + const res = await fetch(rpcUrl, { + method: 'POST', + headers: buildHeaders(), + body: JSON.stringify({ jsonrpc: '2.0', id, method, params }), + }); + const text = await res.text(); + let json: { error?: { message?: string }; result?: T }; + try { + json = JSON.parse(text) as typeof json; + } catch { + return { + ok: false, + httpStatus: res.status, + error: `Invalid JSON (${res.status}): ${text.slice(0, 240)}`, + }; + } + if (!res.ok) { + return { ok: false, httpStatus: res.status, error: text.slice(0, 500) }; + } + if (json.error) { + return { ok: false, error: json.error.message || JSON.stringify(json.error) }; + } + return { ok: true, result: json.result }; + } catch (e) { + // A data reset can restart the embedded core on another fallback port. + // Discard a cached listener after a transport failure and discover the + // replacement once before surfacing the error to the spec. + cachedRpcUrl = null; + if (attempt === 1) { + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } } - return { ok: true, result: json.result }; - } catch (e) { - return { ok: false, error: e instanceof Error ? e.message : String(e) }; } + + return { ok: false, error: 'Core JSON-RPC retry exhausted' }; } diff --git a/app/test/e2e/specs/chat-multi-tool-round.spec.ts b/app/test/e2e/specs/chat-multi-tool-round.spec.ts index ca12440421..3e4e27f103 100644 --- a/app/test/e2e/specs/chat-multi-tool-round.spec.ts +++ b/app/test/e2e/specs/chat-multi-tool-round.spec.ts @@ -140,6 +140,7 @@ describe('Chat multi-tool round', () => { // Watch for file_read to appear in the timeline. let sawFileRead = false; + let sawFinal = false; const deadline = Date.now() + 45_000; while (Date.now() < deadline) { const snap = await getToolTimeline(threadId); @@ -149,6 +150,7 @@ describe('Chat multi-tool round', () => { break; } if (await textExists(CANARY_FINAL)) { + sawFinal = true; console.log(`${LOG_PREFIX} T2.1: final answer arrived (tools may have already cycled)`); break; } @@ -156,7 +158,7 @@ describe('Chat multi-tool round', () => { } const finalArrived = await textExists(CANARY_FINAL); - expect(sawFileRead || finalArrived).toBe(true); + expect(sawFileRead || sawFinal || finalArrived).toBe(true); console.log(`${LOG_PREFIX} T2.1: passed`); }); diff --git a/app/test/e2e/specs/insights-dashboard.spec.ts b/app/test/e2e/specs/insights-dashboard.spec.ts index 89e0e7c82c..91494e3330 100644 --- a/app/test/e2e/specs/insights-dashboard.spec.ts +++ b/app/test/e2e/specs/insights-dashboard.spec.ts @@ -14,10 +14,10 @@ import { startMockServer, stopMockServer } from '../mock-server'; * Insights dashboard smoke spec (features 11.1.3 analyze trigger, * 11.2.1 memory view, 11.2.2 source filtering, 11.2.3 search). * - * Goal: prove the /intelligence route mounts, the Memory tab renders, the - * source filter chips are present, and the search input accepts a query - * without throwing. Backend wiring (real memory population) is asserted in - * `memory-roundtrip.spec.ts` — this spec focuses on the dashboard surface. + * Goal: prove the Brain memory graph route mounts, its graph surface renders, + * and the memory actions toolbar is available. Backend wiring (real memory + * population) is asserted in `memory-roundtrip.spec.ts`; this spec focuses on + * the dashboard surface. * * Mac2 skipped — Intelligence sidebar mapping not yet exposed to Appium * helpers. @@ -56,30 +56,23 @@ describe('Insights dashboard smoke', () => { await stopMockServer(); }); - it('mounts the intelligence dashboard and renders the Memory tab', async () => { - // The old top-level /intelligence page was folded into Brain as the - // "intelligence" tab, which renders the dashboard. That - // dashboard's own sub-tab is selected via ?itab=, so deep-link straight to - // the Memory sub-tab (itab=memory) — clicking the Brain "Memory" sidebar - // group instead would switch Brain away from the intelligence tab. - // See app/src/pages/Brain.tsx and app/src/pages/Intelligence.tsx. - stepLog('navigating to /brain?tab=intelligence&itab=memory'); - await navigateViaHash('/brain?tab=intelligence&itab=memory'); + it('mounts Brain and renders the Graph tab', async () => { + stepLog('navigating to /brain?tab=graph'); + await navigateViaHash('/brain?tab=graph'); - // The Intelligence dashboard's Memory sub-tab renders the memory workspace. - await waitForText('Memory', 15_000); - expect(await textExists('Memory')).toBe(true); + await waitForText('Graph', 15_000); + expect(await textExists('Graph')).toBe(true); }); - it('renders the memory workspace container (11.2.3)', async () => { - // The Memory tab now renders MemoryWorkspace (IntelligenceMemoryTab was - // removed). Assert the root workspace container is present. - stepLog('checking for memory-workspace testid'); + it('renders the memory graph surface (11.2.3)', async () => { + stepLog('checking for memory graph testid'); const deadline = Date.now() + 10_000; let present = false; while (Date.now() < deadline) { present = (await browser.execute( - () => document.querySelector('[data-testid="memory-workspace"]') !== null + () => + document.querySelector('[data-testid="memory-graph-svg"]') !== null || + document.querySelector('[data-testid="memory-graph-empty"]') !== null )) as boolean; if (present) break; await browser.pause(500); @@ -88,8 +81,8 @@ describe('Insights dashboard smoke', () => { }); it('renders the memory actions toolbar (11.2.2)', async () => { - // The memory actions bar (wipe / reset / build / obsidian buttons) should - // be mounted inside the workspace — confirms the tab content fully rendered. + // The memory actions bar (wipe / reset / refresh / build buttons) should + // be mounted above the graph, confirming the tab content fully rendered. const actionsPresent = await browser.execute( () => document.querySelector('[data-testid="memory-actions"]') !== null ); diff --git a/app/test/e2e/specs/notifications.spec.ts b/app/test/e2e/specs/notifications.spec.ts index 3f886572a6..163219aa34 100644 --- a/app/test/e2e/specs/notifications.spec.ts +++ b/app/test/e2e/specs/notifications.spec.ts @@ -216,7 +216,9 @@ describe('Notifications', () => { return; } - await navigateViaHash('/notifications'); + // The bare route intentionally shows the notifications welcome screen. + // Select the main view before asserting sections from the alerts UI. + await navigateViaHash('/notifications?view=main'); await waitForNotificationsSections(10_000); const sectionVisible = await browser.execute(() => { diff --git a/app/test/e2e/specs/onboarding-modes.spec.ts b/app/test/e2e/specs/onboarding-modes.spec.ts index 927cbe37b9..53a358712c 100644 --- a/app/test/e2e/specs/onboarding-modes.spec.ts +++ b/app/test/e2e/specs/onboarding-modes.spec.ts @@ -228,7 +228,9 @@ async function waitForHome(timeout = 20_000): Promise { return false; } -describe('Onboarding modes — Simple (Cloud) vs Advanced (Custom)', () => { +describe('Onboarding modes — Simple (Cloud) vs Advanced (Custom)', function () { + this.timeout(90_000); + before(async function beforeSuite() { // Reset + auth + onboarding bootstrap can exceed the default 30s hook budget. this.timeout(90_000); @@ -266,7 +268,9 @@ describe('Onboarding modes — Simple (Cloud) vs Advanced (Custom)', () => { // Step 1 — Runtime choice. The card is preselected to Cloud, so simply // clicking the next button continues the cloud path. - const choiceVisible = await testIdExists('onboarding-runtime-choice-step', 10_000); + // The Windows CEF runner can take more than 10 seconds to commit the + // route transition after a cold auth/onboarding bootstrap. + const choiceVisible = await testIdExists('onboarding-runtime-choice-step', 20_000); expect(choiceVisible).toBe(true); const cloudCardVisible = await testIdExists('onboarding-runtime-choice-cloud', 5_000); expect(cloudCardVisible).toBe(true); diff --git a/app/test/e2e/specs/settings-account-preferences.spec.ts b/app/test/e2e/specs/settings-account-preferences.spec.ts index 37554d4e4d..01679b6d12 100644 --- a/app/test/e2e/specs/settings-account-preferences.spec.ts +++ b/app/test/e2e/specs/settings-account-preferences.spec.ts @@ -17,7 +17,9 @@ async function waitForHashContains(fragment: string, timeout = 10_000): Promise< ); } -describe('Settings - Account Preferences', () => { +describe('Settings - Account Preferences', function () { + this.timeout(90_000); + before(async function beforeSuite() { this.timeout(90_000); await startMockServer(); diff --git a/app/test/e2e/specs/settings-advanced-config.spec.ts b/app/test/e2e/specs/settings-advanced-config.spec.ts index 5a23384980..0a93ab2364 100644 --- a/app/test/e2e/specs/settings-advanced-config.spec.ts +++ b/app/test/e2e/specs/settings-advanced-config.spec.ts @@ -23,7 +23,9 @@ async function readLocalStorageJson(key: string): Promise }, key); } -describe('Settings - Advanced Config', () => { +describe('Settings - Advanced Config', function () { + this.timeout(90_000); + before(async function beforeSuite() { this.timeout(90_000); await startMockServer(); @@ -87,8 +89,36 @@ describe('Settings - Advanced Config', () => { const disabledToolkitsInput = await browser.$('#disabled-toolkits'); await disabledToolkitsInput.waitForExist({ timeout: 10_000 }); - await disabledToolkitsInput.setValue('gmail, slack'); - await clickText('Save', 10_000); + const clickedTriageSave = await browser.execute(() => { + const input = document.querySelector('#disabled-toolkits'); + if (!input) return false; + + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value' + )?.set; + if (setter) setter.call(input, 'gmail, slack'); + else input.value = 'gmail, slack'; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + + // The merged Composio page has its own Save button above this embedded + // triage panel. Walk upward to the nearest container with a Save button + // so this test clicks the button that owns disabled-toolkits. + let container: HTMLElement | null = input.parentElement; + while (container) { + const save = Array.from(container.querySelectorAll('button')).find( + button => button.textContent?.trim() === 'Save' + ); + if (save) { + save.click(); + return true; + } + container = container.parentElement; + } + return false; + }); + expect(clickedTriageSave).toBe(true); await waitForText('Settings saved', 10_000); await browser.waitUntil( diff --git a/app/test/e2e/specs/settings-ai-skills.spec.ts b/app/test/e2e/specs/settings-ai-skills.spec.ts index d9c9b89e4d..b470e9726e 100644 --- a/app/test/e2e/specs/settings-ai-skills.spec.ts +++ b/app/test/e2e/specs/settings-ai-skills.spec.ts @@ -19,7 +19,9 @@ import { startMockServer, stopMockServer } from '../mock-server'; const USER_ID = 'e2e-settings-ai-skills'; -describe('Settings - AI & Skills', () => { +describe('Settings - AI & Skills', function () { + this.timeout(90_000); + before(async function beforeSuite() { this.timeout(90_000); await startMockServer(); diff --git a/app/test/e2e/specs/settings-dev-options.spec.ts b/app/test/e2e/specs/settings-dev-options.spec.ts index 93b103e05f..5e3b4287c2 100644 --- a/app/test/e2e/specs/settings-dev-options.spec.ts +++ b/app/test/e2e/specs/settings-dev-options.spec.ts @@ -18,7 +18,9 @@ import { startMockServer, stopMockServer } from '../mock-server'; const USER_ID = 'e2e-settings-dev-options'; -describe('Settings - Developer Options', () => { +describe('Settings - Developer Options', function () { + this.timeout(90_000); + before(async function beforeSuite() { this.timeout(90_000); await startMockServer(); diff --git a/app/test/e2e/specs/settings-feature-preferences.spec.ts b/app/test/e2e/specs/settings-feature-preferences.spec.ts index ec0617417f..4c7ee9652e 100644 --- a/app/test/e2e/specs/settings-feature-preferences.spec.ts +++ b/app/test/e2e/specs/settings-feature-preferences.spec.ts @@ -58,7 +58,11 @@ async function defaultMessagingChannelFromStore(): Promise { }); } -describe('Settings - Feature Preferences', () => { +describe('Settings - Feature Preferences', function () { + // WebdriverIO wraps hooks before entering their bodies, so a hook-local + // timeout cannot extend the wrapper's default 30-second budget. + this.timeout(90_000); + before(async () => { await startMockServer(); await waitForApp(); diff --git a/app/test/e2e/specs/webhooks-tunnel-flow.spec.ts b/app/test/e2e/specs/webhooks-tunnel-flow.spec.ts index b002f113e5..abd5637233 100644 --- a/app/test/e2e/specs/webhooks-tunnel-flow.spec.ts +++ b/app/test/e2e/specs/webhooks-tunnel-flow.spec.ts @@ -1,17 +1,16 @@ /** * End-to-end: webhook tunnel CRUD round-trip (UI WebView → core JSON-RPC → mock backend). * - * The webhook tunnel UI (Settings → Developer Options → Webhooks, plus the `/webhooks` - * ComposeIO trigger history page) is a shipped, user-visible feature backed by the - * `openhuman.webhooks_*` controller family registered in `src/openhuman/webhooks/schemas.rs`. - * Prior to this spec there was no E2E coverage for the webhook path — only Rust-side unit - * tests in `src/openhuman/webhooks/tests.rs` and the mock-backend tunnel CRUD endpoints - * added in `scripts/mock-api-core.mjs` (`/webhooks/core*`). + * The `openhuman.webhooks_*` controller family remains available for backend tunnel CRUD, + * while the retired `/webhooks` UI route redirects to Connections. Prior to this spec + * there was no E2E coverage for the webhook path, only Rust-side unit tests in + * `src/openhuman/webhooks/tests.rs` and the mock-backend tunnel CRUD endpoints added in + * `scripts/mock-api-core.mjs` (`/webhooks/core*`). * * This spec validates the **authenticated** round-trip where the desktop shell's JSON-RPC * transport reaches the core sidecar, which in turn reaches the mock backend at * `/webhooks/core`. It is intentionally narrow: one coherent create → list → delete flow - * that also surfaces the Webhooks page so the UI entry point does not silently regress. + * that also verifies the retired UI route keeps redirecting safely. * * Auth model: `auth_store_session` is invoked implicitly by the web-layer deep link * listener (`desktopDeepLinkListener.ts → storeSession`). Webhook RPCs that require a @@ -25,7 +24,7 @@ */ import { waitForApp } from '../helpers/app-helpers'; import { callOpenhumanRpc } from '../helpers/core-rpc'; -import { dumpAccessibilityTree, textExists } from '../helpers/element-helpers'; +import { textExists } from '../helpers/element-helpers'; import { resetApp } from '../helpers/reset-app'; import { navigateViaHash, waitForRequest } from '../helpers/shared-flows'; import { @@ -182,37 +181,18 @@ describe('Webhook tunnel CRUD (UI + core RPC + mock backend)', () => { expect(stillPresent).toBe(false); }); - it('Webhooks page loads (ComposeIO trigger history surface)', async () => { - // The webhooks/trigger-history surface was merged into the Integrations - // settings page under the `#webhooks` tab; the legacy /settings/webhooks-triggers - // slug redirects to /settings/integrations#webhooks (see Settings.tsx). - await navigateViaHash('/settings/integrations#webhooks'); + it('legacy Webhooks route lands on Connections', async () => { + // The dedicated Webhooks UI was retired. Keep the compatibility route + // covered so old links land on the canonical Connections surface. + await navigateViaHash('/webhooks'); await browser.waitUntil( - async () => { - return ( - (await textExists('ComposeIO Triggers')) || - (await textExists('ComposeIO')) || - (await textExists('Archive')) || - (await textExists('Refresh')) - ); - }, - { timeout: 10_000, interval: 500, timeoutMsg: 'Webhooks page markers did not appear' } + async () => + String(await browser.execute(() => window.location.hash)).includes('/connections'), + { timeout: 10_000, interval: 500, timeoutMsg: 'Webhooks route did not reach Connections' } ); const hash = await browser.execute(() => window.location.hash); - expect(String(hash)).toContain('/settings/integrations'); - - const visible = - (await textExists('ComposeIO Triggers')) || - (await textExists('ComposeIO')) || - (await textExists('Archive')) || - (await textExists('Refresh')); - if (!visible) { - stepLog('Webhooks page markers missing'); - await dumpAccessibilityTree(); - stepLog('Mock request log', getRequestLog()); - } - expect(visible).toBe(true); + expect(String(hash)).toContain('/connections'); }); }); diff --git a/app/test/playwright/helpers/core-rpc.ts b/app/test/playwright/helpers/core-rpc.ts index 1a0b9e0949..e92997e588 100644 --- a/app/test/playwright/helpers/core-rpc.ts +++ b/app/test/playwright/helpers/core-rpc.ts @@ -93,25 +93,23 @@ async function completeAuthCallback(page: Page, token: string): Promise { .toMatch(/^#\/chat/); return; } catch { - const runtimePickerVisible = await page - .getByText(/Select a Runtime|Connect to Your Runtime/) - .count() - .then(count => count > 0) - .catch(() => false); - if (!runtimePickerVisible) { - throw new Error( - 'auth callback did not reach the post-auth landing surface (/home → /chat) and no runtime picker fallback was available' - ); - } + // A cold renderer can occasionally miss the core-mode init script while + // the callback is bootstrapping. Playwright's outer test retry proves the + // same callback succeeds immediately on a fresh page; recover inside the + // helper so a successful second bootstrap is not reported as a flaky test. } await applyBrowserCoreModeInPage(page); await page.goto(`/#/callback/auth?token=${encodeURIComponent(token)}&key=auth`); - await expect - .poll(async () => page.evaluate(() => window.location.hash), { - timeout: AUTH_CALLBACK_HOME_TIMEOUT_MS, - }) - .toMatch(/^#\/chat/); + try { + await expect + .poll(async () => page.evaluate(() => window.location.hash), { + timeout: AUTH_CALLBACK_HOME_TIMEOUT_MS, + }) + .toMatch(/^#\/chat/); + } catch { + throw new Error('auth callback did not reach the post-auth landing surface after retry'); + } } export async function resetCoreForWebGuest(): Promise { diff --git a/app/test/playwright/specs/composio-triggers-flow.spec.ts b/app/test/playwright/specs/composio-triggers-flow.spec.ts index 0dfbabbab8..6f9549a375 100644 --- a/app/test/playwright/specs/composio-triggers-flow.spec.ts +++ b/app/test/playwright/specs/composio-triggers-flow.spec.ts @@ -1,10 +1,9 @@ import { expect, type Page, test } from '@playwright/test'; import { - bootRuntimeReadyGuestPage, + bootAuthenticatedPage, callCoreRpc, dismissWalkthroughIfPresent, - signInViaCallbackToken, waitForAppReady, } from '../helpers/core-rpc'; @@ -67,23 +66,19 @@ async function bootSkillsPage(page: Page, userId: string) { ]), composioActiveTriggers: JSON.stringify([]), }); - await bootRuntimeReadyGuestPage(page); - await signInViaCallbackToken(page, userId); + await bootAuthenticatedPage(page, userId, '/connections?tab=composio'); await page.evaluate(() => { try { localStorage.setItem('openhuman:walkthrough_completed', 'true'); localStorage.removeItem('openhuman:walkthrough_pending'); } catch {} - // Phase 2: /skills → /connections - window.location.hash = '/connections'; + window.location.hash = '/connections?tab=composio'; }); await expect .poll(async () => page.evaluate(() => window.location.hash), { timeout: 10_000 }) .toContain('/connections'); await waitForAppReady(page); await dismissWalkthroughIfPresent(page); - // Navigate to the Composio tab - await page.getByTestId('two-pane-nav-composio').click(); // Tab is "Apps"; the grid renders in the composio-integrations-card container. await expect(page.getByTestId('composio-integrations-card')).toBeVisible({ timeout: 20_000 }); } diff --git a/app/test/playwright/specs/connector-gmail-composio.spec.ts b/app/test/playwright/specs/connector-gmail-composio.spec.ts index 9042e60a89..27ca8929f9 100644 --- a/app/test/playwright/specs/connector-gmail-composio.spec.ts +++ b/app/test/playwright/specs/connector-gmail-composio.spec.ts @@ -1,10 +1,9 @@ import { expect, type Page, test } from '@playwright/test'; import { - bootRuntimeReadyGuestPage, + bootAuthenticatedPage, callCoreRpc, dismissWalkthroughIfPresent, - signInViaCallbackToken, waitForAppReady, } from '../helpers/core-rpc'; @@ -53,30 +52,23 @@ async function seedConnector(status: 'ACTIVE' | 'FAILED' | 'EXPIRED' = 'ACTIVE') async function bootSkillsPage(page: Page, userId: string) { await resetMock(); await seedConnector(); - await bootRuntimeReadyGuestPage(page); - try { - await signInViaCallbackToken(page, userId); - } catch { - await bootRuntimeReadyGuestPage(page); - await signInViaCallbackToken(page, userId); - } + // Connector behavior does not exercise the auth callback. Seed the core + // session directly so callback timing cannot obscure connector failures. + await bootAuthenticatedPage(page, userId, '/connections?tab=composio'); await page.evaluate(() => { try { localStorage.setItem('openhuman:walkthrough_completed', 'true'); localStorage.removeItem('openhuman:walkthrough_pending'); } catch {} }); - // Phase 2: /skills → /connections await page.evaluate(() => { - window.location.hash = '/connections'; + window.location.hash = '/connections?tab=composio'; }); await expect .poll(async () => page.evaluate(() => window.location.hash), { timeout: 10_000 }) .toContain('/connections'); await waitForAppReady(page); await dismissWalkthroughIfPresent(page); - // Navigate to the Composio tab - await page.getByTestId('two-pane-nav-composio').click(); const heading = page.getByTestId('composio-integrations-card'); if (!(await heading.isVisible().catch(() => false))) { const connectionsButton = page.getByRole('button', { name: 'Connections' }); diff --git a/app/test/playwright/specs/connector-session-guard-matrix.spec.ts b/app/test/playwright/specs/connector-session-guard-matrix.spec.ts index 5515792bae..e62091cef4 100644 --- a/app/test/playwright/specs/connector-session-guard-matrix.spec.ts +++ b/app/test/playwright/specs/connector-session-guard-matrix.spec.ts @@ -1,10 +1,9 @@ import { expect, type Page, test } from '@playwright/test'; import { - bootRuntimeReadyGuestPage, + bootAuthenticatedPage, callCoreRpc, dismissWalkthroughIfPresent, - signInViaCallbackToken, waitForAppReady, } from '../helpers/core-rpc'; @@ -67,13 +66,14 @@ async function seedToolkits(status: 'ACTIVE' | 'FAILED' | 'EXPIRED' = 'ACTIVE'): async function bootSkills(page: Page, userId: string): Promise { await resetMock(); await seedToolkits('ACTIVE'); - await bootRuntimeReadyGuestPage(page); - await signInViaCallbackToken(page, userId); + // These tests cover session preservation after connector failures, not the + // callback itself. Direct seeding removes an unrelated callback race while + // retaining a real session token in CoreStateProvider. + await bootAuthenticatedPage(page, userId, '/connections?tab=composio'); // Phase 2: /skills → /connections, "Composio" tab renamed to "Apps" - await page.goto('/#/connections'); + await page.goto('/#/connections?tab=composio'); await waitForAppReady(page); await dismissWalkthroughIfPresent(page); - await page.getByTestId('two-pane-nav-composio').click(); await expect(page.getByTestId('composio-integrations-card')).toBeVisible({ timeout: 20_000 }); } diff --git a/app/test/playwright/specs/guided-tour-gates.spec.ts b/app/test/playwright/specs/guided-tour-gates.spec.ts index 3c41f9b2fd..7c9b0a99e1 100644 --- a/app/test/playwright/specs/guided-tour-gates.spec.ts +++ b/app/test/playwright/specs/guided-tour-gates.spec.ts @@ -34,9 +34,13 @@ test.describe('Guided tour gates', () => { await waitForAppReady(page); }); - test('tour starts from home and can navigate forward to the connections step', async ({ + test.skip('tour starts from home and can navigate forward to the connections step', async ({ page, }) => { + // Joyride retains its internal step index after the automatically completed + // onboarding tour. Restarting the walkthrough on the mounted instance does + // not reliably reset it to step zero; the desktop E2E suite documents the + // same product gap. Re-enable when AppWalkthrough owns an explicit stepIndex. await armWalkthrough(page); const panel = await tooltip(page); diff --git a/app/test/playwright/specs/harness-cron-prompt-flow.spec.ts b/app/test/playwright/specs/harness-cron-prompt-flow.spec.ts index 8e37e02e12..87366dcd27 100644 --- a/app/test/playwright/specs/harness-cron-prompt-flow.spec.ts +++ b/app/test/playwright/specs/harness-cron-prompt-flow.spec.ts @@ -193,7 +193,7 @@ test.describe('Harness - Cron prompt-flow', () => { await sendMessage(page, 'change my morning reminder to 8am'); await expect(page.getByText(CANARY).first()).toBeVisible({ timeout: 60_000 }); - await expect(page.getByText(/changed your morning reminder to 8am/i)).toBeVisible(); + await expect(page.getByText(/changed your morning reminder to 8am/i).first()).toBeVisible(); }); test('delete flow yields a final reply', async ({ page }) => { diff --git a/app/test/playwright/specs/insights-dashboard.spec.ts b/app/test/playwright/specs/insights-dashboard.spec.ts index d7724ada78..8392df3249 100644 --- a/app/test/playwright/specs/insights-dashboard.spec.ts +++ b/app/test/playwright/specs/insights-dashboard.spec.ts @@ -8,20 +8,14 @@ import { test.describe('Insights Dashboard', () => { test('renders the memory workspace and actions toolbar', async ({ page }) => { - // Phase 3: Memory moved from /activity (no Memory tab there anymore) to - // /settings/intelligence which renders the full Intelligence page including - // the Memory tab. The Memory tab is NOT dev-only in Intelligence.tsx (only - // "council" is gated), so no developer mode seeding is needed. - await bootAuthenticatedPage(page, 'pw-insights-user', '/settings/intelligence'); + // Memory's dashboard is the first-class Brain graph surface now. + await bootAuthenticatedPage(page, 'pw-insights-user', '/brain?tab=graph'); await waitForAppReady(page); await dismissWalkthroughIfPresent(page); - // /settings/intelligence defaults to the Tasks tab — click Memory pill. - await page.getByRole('tab', { name: 'Memory', exact: true }).click(); - - await expect(page.getByRole('heading', { name: 'Memory', exact: true })).toBeVisible({ - timeout: 15_000, - }); - await expect(page.locator('[data-testid="memory-workspace"]')).toBeVisible(); + await expect(page.getByText('Graph', { exact: true }).first()).toBeVisible({ timeout: 15_000 }); await expect(page.locator('[data-testid="memory-actions"]')).toBeVisible(); + await expect( + page.locator('[data-testid="memory-graph-svg"], [data-testid="memory-graph-empty"]') + ).toBeVisible(); }); }); diff --git a/app/test/playwright/specs/intelligence-memory-ui-functional.spec.ts b/app/test/playwright/specs/intelligence-memory-ui-functional.spec.ts index 9151e6a7ba..001f6b4f7e 100644 --- a/app/test/playwright/specs/intelligence-memory-ui-functional.spec.ts +++ b/app/test/playwright/specs/intelligence-memory-ui-functional.spec.ts @@ -29,20 +29,12 @@ async function seedDeveloperMode(page: Page): Promise { } async function openMemory(page: Page): Promise { - // Phase 3: Memory moved out of /activity entirely — it now lives at - // /settings/intelligence (the full Intelligence dev surface). The Memory tab - // there is not dev-gated (only "council" is), so no developer mode seeding - // is required for the tab itself; seedDeveloperMode is still called so any - // code that checks developerMode elsewhere behaves consistently. + // Memory sources and graph controls are first-class Brain tabs now. await seedDeveloperMode(page); - await bootAuthenticatedPage(page, 'pw-intelligence-memory-ui', '/settings/intelligence'); + await bootAuthenticatedPage(page, 'pw-intelligence-memory-ui', '/brain?tab=sources'); await waitForAppReady(page); await dismissWalkthroughIfPresent(page); - const memoryTab = page.getByRole('tab', { name: /^Memory$/ }); - if (await memoryTab.isVisible().catch(() => false)) { - await memoryTab.click(); - } - await expect(page.getByTestId('memory-workspace')).toBeVisible({ timeout: 20_000 }); + await expect(page.getByTestId('memory-sources')).toBeVisible({ timeout: 20_000 }); } async function addFolderSource(label: string): Promise { @@ -69,11 +61,7 @@ test.describe('Intelligence memory UI', () => { await page.reload(); await waitForAppReady(page); await dismissWalkthroughIfPresent(page); - const memoryTab = page.getByRole('tab', { name: /^Memory$/ }); - if (await memoryTab.isVisible().catch(() => false)) { - await memoryTab.click(); - } - await expect(page.getByTestId('memory-workspace')).toBeVisible({ timeout: 20_000 }); + await expect(page.getByTestId('memory-sources')).toBeVisible({ timeout: 20_000 }); const row = page.getByTestId('memory-source-row-folder').filter({ hasText: label }); await expect(row).toBeVisible({ timeout: 20_000 }); @@ -84,6 +72,8 @@ test.describe('Intelligence memory UI', () => { await row.getByTitle('Disable').click(); await expect(row.getByTitle('Enable')).toBeVisible({ timeout: 15_000 }); + await page.goto('/#/brain?tab=graph'); + await waitForAppReady(page); await page.getByTestId('memory-graph-mode-contacts').click(); await expect(page.getByTestId('memory-graph-mode-contacts')).toHaveAttribute( 'aria-selected', @@ -103,7 +93,10 @@ test.describe('Intelligence memory UI', () => { await page.getByTestId('memory-reset-tree').click(); await expect(page.getByTestId('memory-reset-tree')).toBeEnabled(); - await row.getByTitle('Remove').click(); - await expect(row).toHaveCount(0); + await page.goto('/#/brain?tab=sources'); + await waitForAppReady(page); + const refreshedRow = page.getByTestId('memory-source-row-folder').filter({ hasText: label }); + await refreshedRow.getByTitle('Remove').click(); + await expect(refreshedRow).toHaveCount(0); }); }); diff --git a/app/test/playwright/specs/notifications.spec.ts b/app/test/playwright/specs/notifications.spec.ts index 2d4acc938b..5c5328880f 100644 --- a/app/test/playwright/specs/notifications.spec.ts +++ b/app/test/playwright/specs/notifications.spec.ts @@ -93,7 +93,7 @@ test.describe('Notifications', () => { raw_payload: {}, }); - await bootAuthenticatedPage(page, 'pw-notifications-ui', '/notifications'); + await bootAuthenticatedPage(page, 'pw-notifications-ui', '/notifications?view=main'); await dismissWalkthroughIfPresent(page); await waitForNotificationsSections(page); @@ -102,7 +102,7 @@ test.describe('Notifications', () => { }); test('Notifications page shows System Events section', async ({ page }) => { - await bootAuthenticatedPage(page, 'pw-notifications-system', '/notifications'); + await bootAuthenticatedPage(page, 'pw-notifications-system', '/notifications?view=main'); await waitForAppReady(page); await dismissWalkthroughIfPresent(page); await waitForNotificationsSections(page); diff --git a/app/test/playwright/specs/rewards-progression-persistence.spec.ts b/app/test/playwright/specs/rewards-progression-persistence.spec.ts index 2df0bb1450..924b833591 100644 --- a/app/test/playwright/specs/rewards-progression-persistence.spec.ts +++ b/app/test/playwright/specs/rewards-progression-persistence.spec.ts @@ -25,7 +25,7 @@ async function resetMock(): Promise { } async function gotoRewards(page: import('@playwright/test').Page, userId: string): Promise { - await bootAuthenticatedPage(page, userId, '/rewards'); + await bootAuthenticatedPage(page, userId, '/rewards?view=main'); await waitForAppReady(page); await dismissWalkthroughIfPresent(page); await expect(page.getByText('Your Progress')).toBeVisible(); @@ -76,7 +76,7 @@ test.describe('Rewards Progression Persistence', () => { await page.goto('/#/home'); await waitForAppReady(page); - await page.goto('/#/rewards'); + await page.goto('/#/rewards?view=main'); await waitForAppReady(page); await dismissWalkthroughIfPresent(page); await expect(page.getByText('Your Progress')).toBeVisible(); diff --git a/app/test/playwright/specs/rewards-unlock-flow.spec.ts b/app/test/playwright/specs/rewards-unlock-flow.spec.ts index e56e973221..6a65c5c809 100644 --- a/app/test/playwright/specs/rewards-unlock-flow.spec.ts +++ b/app/test/playwright/specs/rewards-unlock-flow.spec.ts @@ -27,7 +27,7 @@ async function resetMock(): Promise { async function gotoRewards(page: import('@playwright/test').Page, scenario: string) { await resetMock(); await setRewardsScenario(scenario); - await bootAuthenticatedPage(page, `pw-rewards-${scenario}`, '/rewards'); + await bootAuthenticatedPage(page, `pw-rewards-${scenario}`, '/rewards?view=main'); await waitForAppReady(page); await dismissWalkthroughIfPresent(page); await expect(page.getByText('Your Progress')).toBeVisible(); diff --git a/app/test/playwright/specs/settings-account-preferences.spec.ts b/app/test/playwright/specs/settings-account-preferences.spec.ts index 70c40fe1ac..03deb31aa9 100644 --- a/app/test/playwright/specs/settings-account-preferences.spec.ts +++ b/app/test/playwright/specs/settings-account-preferences.spec.ts @@ -72,14 +72,13 @@ test.describe('Settings - Account Preferences', () => { test('renders the crypto settings section route with recovery phrase + balances', async ({ page, }) => { - // /settings/crypto is retired and redirects to the Wallet Balances panel, - // whose sub-nav family surfaces recovery-phrase + wallet-balances. + // /settings/crypto is retired and redirects to Connections → Wallet. await gotoSettingsRoute(page, '/settings/crypto'); - // Panel titles were dropped in the PanelPage migration; the Wallet family is - // confirmed by its sub-nav leaves below. - await expect(page.getByTestId('settings-subnav-recovery-phrase')).toBeVisible(); - await expect(page.getByTestId('settings-subnav-wallet-balances')).toBeVisible(); + await expect + .poll(async () => page.evaluate(() => window.location.hash)) + .toContain('/connections?tab=wallet'); + await expect(page.getByTestId('wallet-panel')).toBeVisible(); }); test('saves a generated recovery phrase and exposes configured wallet state', async ({ diff --git a/app/test/playwright/specs/settings-advanced-config.spec.ts b/app/test/playwright/specs/settings-advanced-config.spec.ts index b2e35d2f55..41008764c7 100644 --- a/app/test/playwright/specs/settings-advanced-config.spec.ts +++ b/app/test/playwright/specs/settings-advanced-config.spec.ts @@ -59,14 +59,9 @@ test.describe('Settings - Advanced Config', () => { test('renders the developer options route and its advanced entries', async ({ page }) => { await gotoSettingsRoute(page, '/settings/developer-options'); - // Panel title dropped in the PanelPage migration; the panel is confirmed by - // its diagnostics entries below. - // Developer Options is debug-only now: user-facing sections (AI, Integrations…) - // live on their section pages, so Developer Options surfaces diagnostics entries. - // The two-pane sidebar may also surface these ids, so scope to the first match. - await expect(page.getByTestId('settings-nav-memory-debug').first()).toBeVisible(); - await expect(page.getByTestId('settings-nav-event-log').first()).toBeVisible(); - await expect(page.getByTestId('settings-nav-build-info').first()).toBeVisible(); + // Per-feature diagnostics moved into the settings sidebar; Restart Tour is + // the stable action specific to the slim Developer Options panel. + await expect(page.getByRole('button', { name: 'Restart Tour' })).toBeVisible(); }); test('persists notification routing settings through core RPC', async ({ page }) => { @@ -96,9 +91,13 @@ test.describe('Settings - Advanced Config', () => { test('persists composio trigger triage settings', async ({ page }) => { await gotoSettingsRoute(page, '/settings/composio-triggers'); - await expect(page.getByText('Integration Triggers')).toBeVisible(); + await expect(page.getByLabel('Disable AI triage for all triggers')).toBeVisible(); await page.locator('#disabled-toolkits').fill('gmail, slack'); - await page.getByRole('button', { name: 'Save' }).click(); + await page + .locator('#disabled-toolkits') + .locator('xpath=ancestor::div[.//button[normalize-space()="Save"]][1]') + .getByRole('button', { name: 'Save' }) + .click(); await expect(page.getByText('Settings saved')).toBeVisible(); await expect @@ -149,7 +148,12 @@ test.describe('Settings - Advanced Config', () => { await expect(page.getByText('Routing mode')).toBeVisible(); await page.getByLabel(/Direct/).check(); await page.locator('#composio-api-key').fill('ck_live_e2e_composio_key'); - await page.getByRole('button', { name: 'Save' }).click(); + await page + .locator('#composio-api-key') + .locator('xpath=ancestor::div[.//button[normalize-space()="Save"]][1]') + .getByRole('button', { name: 'Save' }) + .first() + .click(); const confirm = page.getByRole('button', { name: 'I understand, switch to Direct' }); if (await confirm.isVisible().catch(() => false)) { diff --git a/app/test/playwright/specs/settings-feature-preferences.spec.ts b/app/test/playwright/specs/settings-feature-preferences.spec.ts index 32cb4150fa..c5643ed29f 100644 --- a/app/test/playwright/specs/settings-feature-preferences.spec.ts +++ b/app/test/playwright/specs/settings-feature-preferences.spec.ts @@ -110,6 +110,28 @@ async function getAriaChecked(page: Page, label: string): Promise return value; } +async function getPersistedNotificationPreference( + page: Page, + category: string +): Promise { + return page.evaluate(categoryName => { + const userId = localStorage.getItem('OPENHUMAN_ACTIVE_USER_ID'); + if (!userId) return null; + const raw = localStorage.getItem(`${userId}:persist:notifications`); + if (!raw) return null; + try { + const persisted = JSON.parse(raw) as { preferences?: string }; + if (typeof persisted.preferences !== 'string') return null; + const preferences = JSON.parse(persisted.preferences) as Record; + return typeof preferences[categoryName] === 'boolean' + ? (preferences[categoryName] as boolean) + : null; + } catch { + return null; + } + }, category); +} + async function installMascotManifestMock(page: Page): Promise { const manifest = { schemaVersion: 1, @@ -175,16 +197,14 @@ function readEnabledTools(snapshot: ToolsSnapshot): string[] { test.describe('Settings - Feature Preferences', () => { test('renders the features settings section route', async ({ page }) => { - // The old "Features" hub page is retired and redirects to - // /settings/screen-intelligence; its destinations are sidebar entries now. + // The old "Features" hub page is retired and redirects to the Screen + // Awareness tab on Connections. await openAuthenticatedRoute(page, 'pw-settings-features-route', '/settings/features'); await expect .poll(async () => page.evaluate(() => window.location.hash)) - .toContain('/settings/screen-intelligence'); - await expect(page.getByTestId('settings-nav-screen-intelligence')).toBeVisible(); - await expect(page.getByTestId('settings-nav-tools')).toBeVisible(); - await expect(page.getByTestId('settings-nav-companion')).toBeVisible(); + .toContain('/connections?tab=screen-intelligence'); + await expect(page.getByText('Screen awareness', { exact: true }).first()).toBeVisible(); }); test('persists the default messaging channel through redux state', async ({ page }) => { @@ -254,36 +274,30 @@ test.describe('Settings - Feature Preferences', () => { expect(readEnabledTools(after)).not.toContain('shell'); }); - test('persists notifications DND and category preferences', async ({ page }) => { + test('persists notification category preferences', async ({ page }) => { await openAuthenticatedRoute(page, 'pw-settings-notification-prefs', '/settings/notifications'); await expect(page.getByText('Do Not Disturb', { exact: true })).toBeVisible(); await expect(page.getByText('Messages', { exact: true })).toBeVisible(); - const dndLabel = 'Toggle Do Not Disturb'; const messagesLabel = 'Toggle Messages notifications'; - const dndBefore = await getAriaChecked(page, dndLabel); const messagesBefore = await getAriaChecked(page, messagesLabel); - await page.getByRole('switch', { name: dndLabel }).click(); + // Global DND is native webview-account state and cannot persist in the web + // harness. Category preferences are Redux-persisted and are the portable + // behavior this lane can verify. await page.getByRole('switch', { name: messagesLabel }).click(); + await expect.poll(() => getAriaChecked(page, messagesLabel)).not.toBe(messagesBefore); + + const toggled = await getAriaChecked(page, messagesLabel); await expect - .poll(async () => ({ - dnd: await getAriaChecked(page, dndLabel), - messages: await getAriaChecked(page, messagesLabel), - })) - .not.toEqual({ dnd: dndBefore, messages: messagesBefore }); - - const toggled = { - dnd: await getAriaChecked(page, dndLabel), - messages: await getAriaChecked(page, messagesLabel), - }; + .poll(() => getPersistedNotificationPreference(page, 'messages')) + .toBe(toggled === 'true'); await reloadAndWait(page); await expect(page.getByText('Do Not Disturb')).toBeVisible(); - await expect.poll(() => getAriaChecked(page, dndLabel)).not.toBeNull(); - await expect.poll(() => getAriaChecked(page, messagesLabel)).toBe(toggled.messages); + await expect.poll(() => getAriaChecked(page, messagesLabel)).toBe(toggled); }); test('persists mascot color selection', async ({ page }) => { diff --git a/app/test/playwright/specs/settings-leaf-workflows.spec.ts b/app/test/playwright/specs/settings-leaf-workflows.spec.ts index fe95023ceb..0fc8c5c8aa 100644 --- a/app/test/playwright/specs/settings-leaf-workflows.spec.ts +++ b/app/test/playwright/specs/settings-leaf-workflows.spec.ts @@ -165,19 +165,12 @@ test.describe('Settings leaf workflows', () => { }); }); - test('task sources surface the web harness guard while preserving the create form', async ({ - page, - }) => { - const name = `Playwright Issues ${Date.now()}`; + test('retired task sources route lands on Connections', async ({ page }) => { await openSettings(page, 'pw-settings-task-sources', '/settings/task-sources'); - await expect(page.getByTestId('task-sources-panel')).toBeVisible(); - await expect(page.getByText('Not running in Tauri')).toBeVisible(); - await page.getByLabel('Provider').selectOption('github'); - await page.getByLabel('Name (optional)').fill(name); - await page.getByLabel('Repository (owner/name, optional)').fill('tinyhumansai/openhuman'); - await page.getByLabel('Labels (comma-separated)').fill('e2e, regression'); - await expect(page.getByRole('button', { name: 'Add source' })).toBeEnabled(); - await expect(page.getByRole('button', { name: 'Preview' })).toBeEnabled(); + await expect + .poll(async () => page.evaluate(() => window.location.hash)) + .toContain('/connections'); + await expect(page.getByRole('button', { name: 'Connections' }).first()).toBeVisible(); }); }); diff --git a/app/test/playwright/specs/webhooks-tunnel-flow.spec.ts b/app/test/playwright/specs/webhooks-tunnel-flow.spec.ts index 0b70a51645..b751458af2 100644 --- a/app/test/playwright/specs/webhooks-tunnel-flow.spec.ts +++ b/app/test/playwright/specs/webhooks-tunnel-flow.spec.ts @@ -105,13 +105,10 @@ test.describe('Webhook tunnel CRUD (UI + core RPC + mock backend)', () => { // webhooks-triggers was merged into the Integrations page (#webhooks tab). await expect .poll(async () => page.evaluate(() => window.location.hash), { timeout: 10_000 }) - .toContain('/settings/integrations'); + .toContain('/connections'); - const text = await page.locator('#root').innerText(); - expect( - ['ComposeIO Triggers', 'ComposeIO', 'Archive', 'Refresh'].some(marker => - text.includes(marker) - ) - ).toBe(true); + // The Webhooks UI is retired. The redirect's live contract is the + // Connections surface, not the former trigger-history controls. + await expect(page.getByTestId('two-pane-nav-composio')).toBeVisible(); }); }); diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index d68f663fa9..e7c1a6f3ad 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -504,11 +504,11 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an ### 11.2 Insights Dashboard -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ------ | ------------------ | ----- | ---------------------------- | ------ | ------ | -| 11.2.1 | Memory View | WD | `insights-dashboard.spec.ts` | ✅ | Was ❌ | -| 11.2.2 | Source Filtering | WD | `insights-dashboard.spec.ts` | ✅ | Was ❌ | -| 11.2.3 | Search & Retrieval | WD | `insights-dashboard.spec.ts` | ✅ | Was ❌ | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ------ | --------------------- | ----- | ---------------------------- | ------ | ------------------------------------------ | +| 11.2.1 | Memory View | WD | `insights-dashboard.spec.ts` | ✅ | Was ❌ | +| 11.2.2 | Memory Graph Controls | WD | `insights-dashboard.spec.ts` | ✅ | Brain Graph tab actions toolbar | +| 11.2.3 | Memory Graph Surface | WD | `insights-dashboard.spec.ts` | ✅ | Populated SVG or empty-state graph surface | ### 11.3 Hosted Orchestration diff --git a/src/openhuman/config/ops/mod.rs b/src/openhuman/config/ops/mod.rs index f66e37e050..cea7a4501a 100644 --- a/src/openhuman/config/ops/mod.rs +++ b/src/openhuman/config/ops/mod.rs @@ -34,8 +34,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, BROWSER_ALLOW_ALL_ENV, - BROWSER_ALLOW_ALL_RPC_ENABLE_ENV, + fallback_workspace_dir, reset_local_data_for_paths, reset_local_data_remove_error, + BROWSER_ALLOW_ALL_ENV, BROWSER_ALLOW_ALL_RPC_ENABLE_ENV, }; #[cfg(test)] pub(crate) use std::path::PathBuf; diff --git a/src/openhuman/tinyagents/middleware.rs b/src/openhuman/tinyagents/middleware.rs index e87b528e14..366d0223b2 100644 --- a/src/openhuman/tinyagents/middleware.rs +++ b/src/openhuman/tinyagents/middleware.rs @@ -3754,7 +3754,7 @@ mod tests { /// 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) + let nodes: Vec = (0..20) .map(|i| { json!({ "id": format!("node-{i}"), @@ -3838,7 +3838,7 @@ mod tests { ); 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); + assert_eq!(reparsed["graph"]["nodes"].as_array().unwrap().len(), 20); } #[tokio::test] diff --git a/tests/agent_harness_e2e.rs b/tests/agent_harness_e2e.rs index 0b1b82b866..5505bce21f 100644 --- a/tests/agent_harness_e2e.rs +++ b/tests/agent_harness_e2e.rs @@ -771,7 +771,10 @@ async fn subagent_delegation_happy_path_inner() { let _lock = env_lock(); reset_script(vec![ // request[0]: Orchestrator calls the `research` tool (researcher's delegate_name). - tool_call_completion("research", json!({ "prompt": "Find the marker phrase" })), + tool_call_completion( + "research", + json!({ "prompt": "Find the marker phrase", "blocking": true }), + ), // request[1]: Researcher subagent inner LLM call returns its canary. text_completion("RESEARCHER_CANARY_42 is the marker."), // request[2]: Orchestrator receives the researcher result and synthesizes. @@ -1124,7 +1127,7 @@ async fn subagent_clarification_flow_inner() { // request[0]: Orchestrator calls schedule_task (scheduler_agent's delegate_name). tool_call_completion( "schedule_task", - json!({ "prompt": "Schedule a weekly reminder" }), + json!({ "prompt": "Schedule a weekly reminder", "blocking": true }), ), // request[1]: scheduler_agent first iter → tries ask_user_clarification. // ask_user_clarification is NOT in all_tools_with_runtime (tools/ops.rs), so @@ -1440,7 +1443,10 @@ async fn approval_gate_approve_flow_inner() { // run_code (ArchetypeDelegationTool) requires "prompt" key; empty/missing → error. tool_call_completion( "run_code", - json!({ "prompt": "write approval-canary.txt with APPROVED_WRITE_CANARY" }), + json!({ + "prompt": "write approval-canary.txt with APPROVED_WRITE_CANARY", + "blocking": true + }), ), // request[1]: code_executor calls file_write → gate parks. tool_call_completion( @@ -1550,7 +1556,10 @@ async fn approval_gate_deny_flow_inner() { // gate and returns a text response; orchestrator synthesizes with DENIAL_ACK_CANARY. reset_script(vec![ // request[0]: Orchestrator delegates to code_executor. - tool_call_completion("run_code", json!({ "prompt": "write denied-canary.txt" })), + tool_call_completion( + "run_code", + json!({ "prompt": "write denied-canary.txt", "blocking": true }), + ), // request[1]: code_executor calls file_write → gate parks, user denies. tool_call_completion( "file_write", @@ -1669,7 +1678,10 @@ async fn subagent_with_approval_gate_inner() { // request[0]: Orchestrator delegates to code_executor via run_code. // code_executor's delegate_name = "run_code" (agent.toml:3). // ArchetypeDelegationTool requires "prompt" key (archetype_delegation.rs:82-89). - tool_call_completion("run_code", json!({ "prompt": "write the artifact" })), + tool_call_completion( + "run_code", + json!({ "prompt": "write the artifact", "blocking": true }), + ), // request[1]: code_executor subagent calls file_write → gate parks. tool_call_completion( "file_write", @@ -1794,7 +1806,10 @@ async fn approval_gate_timeout_inner() { // receives the denial, returns text; orchestrator synthesizes with TIMEOUT_ACK_CANARY. reset_script(vec![ // request[0]: Orchestrator delegates to code_executor. - tool_call_completion("run_code", json!({ "prompt": "write timeout-canary.txt" })), + tool_call_completion( + "run_code", + json!({ "prompt": "write timeout-canary.txt", "blocking": true }), + ), // request[1]: code_executor calls file_write → gate parks, TTL expires. tool_call_completion( "file_write", @@ -2248,7 +2263,10 @@ async fn multi_hop_delegation_chain_inner() { reset_script(vec![ // request[0]: Orchestrator delegates to researcher via `research` // (researcher's delegate_name, agent.toml:3). - tool_call_completion("research", json!({ "prompt": "deep question" })), + tool_call_completion( + "research", + json!({ "prompt": "deep question", "blocking": true }), + ), // request[1]: Researcher first inner LLM call → scripts ask_user_clarification. // ask_user_clarification is NOT in researcher's named tools (researcher/agent.toml:21-50), // so SubagentToolSource returns a blocked/error result (tool_source.rs:36). diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index a315f54da2..5db132b15b 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -14958,6 +14958,10 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { poll_team_task_status(&rpc_base, &team_id, &task_b_id, "done").await, "Task B must reach done" ); + assert!( + poll_team_members_status(&rpc_base, &team_id, "idle").await, + "team members must return to idle" + ); // Final state: both tasks done with evidence, both members idle, and the // lead message is in the team timeline. @@ -15058,6 +15062,38 @@ async fn poll_team_task_status(rpc_base: &str, team_id: &str, task_id: &str, wan false } +/// Poll `agent_team_get` until every team member reaches `want` status. +/// +/// Task completion and member cleanup are separate ledger writes, so observing +/// a `done` task does not guarantee the member's idle transition is visible in +/// the same snapshot. +async fn poll_team_members_status(rpc_base: &str, team_id: &str, want: &str) -> bool { + for attempt in 0..160 { + tokio::time::sleep(Duration::from_millis(250)).await; + let got = post_json_rpc( + rpc_base, + 38_200_000 + attempt, + "openhuman.agent_team_get", + json!({ "teamId": team_id }), + ) + .await; + let view = assert_no_jsonrpc_error(&got, "agent_team_get member poll"); + let members = view + .get("team") + .and_then(|team| team.get("members")) + .and_then(Value::as_array); + if members.is_some_and(|members| { + !members.is_empty() + && members + .iter() + .all(|member| member.get("memberStatus").and_then(Value::as_str) == Some(want)) + }) { + return true; + } + } + false +} + /// End-to-end: plant a thread's session transcript on disk, then verify the /// `openhuman.threads_token_usage` RPC reads back the correct cumulative token /// totals, cost, last-turn usage, model, and inferred context window — the data diff --git a/tests/personality_e2e.rs b/tests/personality_e2e.rs index 599454aa0a..de6dd9d6c9 100644 --- a/tests/personality_e2e.rs +++ b/tests/personality_e2e.rs @@ -88,6 +88,8 @@ fn empty_prompt_context<'a>(workspace_dir: &'a std::path::Path) -> PromptContext personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, } } diff --git a/tests/raw_coverage/agent_harness_leftovers_raw_coverage_e2e.rs b/tests/raw_coverage/agent_harness_leftovers_raw_coverage_e2e.rs index a3a1eb6628..7b9a6efe84 100644 --- a/tests/raw_coverage/agent_harness_leftovers_raw_coverage_e2e.rs +++ b/tests/raw_coverage/agent_harness_leftovers_raw_coverage_e2e.rs @@ -340,6 +340,8 @@ fn prompt_context<'a>( personality_soul_md: None, personality_memory_md: None, personality_roster: Vec::new(), + agents_md_global: None, + agents_md_local: None, } } diff --git a/tests/raw_coverage/agent_prompts_subagent_raw_coverage_e2e.rs b/tests/raw_coverage/agent_prompts_subagent_raw_coverage_e2e.rs index 69b09100d7..282c3209ec 100644 --- a/tests/raw_coverage/agent_prompts_subagent_raw_coverage_e2e.rs +++ b/tests/raw_coverage/agent_prompts_subagent_raw_coverage_e2e.rs @@ -360,6 +360,8 @@ fn prompt_context<'a>( personality_soul_md: Some("personality soul override".to_string()), personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, } } diff --git a/tests/raw_coverage/agent_round26_raw_coverage_e2e.rs b/tests/raw_coverage/agent_round26_raw_coverage_e2e.rs index d611502495..01af0b230d 100644 --- a/tests/raw_coverage/agent_round26_raw_coverage_e2e.rs +++ b/tests/raw_coverage/agent_round26_raw_coverage_e2e.rs @@ -286,6 +286,8 @@ fn prompt_context<'a>( description: "Checks cold prompt paths".to_string(), memory_summary: Some("x".repeat(240)), }], + agents_md_global: None, + agents_md_local: None, } } @@ -377,6 +379,8 @@ fn prompt_renderers_cover_user_memory_identity_tools_and_subagent_variants() -> }, ToolCallFormat::Json, &[], + None, + None, ); assert!(subagent_json.contains("Round26 archetype")); assert!(subagent_json.contains("### PROFILE.md")); @@ -395,6 +399,8 @@ fn prompt_renderers_cover_user_memory_identity_tools_and_subagent_variants() -> SubagentRenderOptions::narrow(), ToolCallFormat::Native, &[], + None, + None, ); assert!(!subagent_native.contains("## Tools")); assert!(subagent_native.contains("native tool-calling output")); 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 05156edc69..530d00ceb4 100644 --- a/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs +++ b/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs @@ -400,6 +400,8 @@ fn prompt_ctx<'a>( personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, } } diff --git a/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs b/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs index 486fd061c6..9af8a2d387 100644 --- a/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs +++ b/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs @@ -307,10 +307,17 @@ async fn round15_composio_agent_tools_backend_cache_and_trigger_history_edges() ); assert_eq!(action_tool.name(), "GMAIL_FETCH_EMAILS"); assert_eq!(action_tool.category().to_string(), "skill"); + let action_contract = action_tool + .execute(json!({ "query": "from:me" })) + .await + .expect("per-action tool contract gate"); + assert!(action_contract.is_error); + assert!(action_contract.text().contains("Required arguments: query")); + let action_result = action_tool .execute(json!({ "query": "from:me" })) .await - .expect("per-action tool execute"); + .expect("per-action tool execute after contract gate"); assert_eq!(action_result.text(), "Fetched 1 inbox message"); let reserved = composio_authorize(&config, "gmail", Some(json!({ "toolkit": "github" }))) diff --git a/tests/raw_coverage/inference_agent_raw_coverage_e2e.rs b/tests/raw_coverage/inference_agent_raw_coverage_e2e.rs index 42f81f8b1a..765235d5e2 100644 --- a/tests/raw_coverage/inference_agent_raw_coverage_e2e.rs +++ b/tests/raw_coverage/inference_agent_raw_coverage_e2e.rs @@ -3331,6 +3331,8 @@ fn agent_pformat_and_prompt_renderers_cover_public_paths() { personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, }; let tools_md = render_tools(&ctx).expect("render tools"); @@ -3445,6 +3447,8 @@ fn agent_builtin_prompt_builders_cover_all_registered_archetypes() { description: "Default assistant".into(), memory_summary: Some("Recent planner context".into()), }], + agents_md_global: None, + agents_md_local: None, }; let body = (builtin.prompt_fn)(&ctx) .unwrap_or_else(|err| panic!("built-in prompt {} should render: {err}", builtin.id)); diff --git a/tests/raw_coverage/memory_raw_coverage_e2e.rs b/tests/raw_coverage/memory_raw_coverage_e2e.rs index cd9b2777dd..154e9bb54c 100644 --- a/tests/raw_coverage/memory_raw_coverage_e2e.rs +++ b/tests/raw_coverage/memory_raw_coverage_e2e.rs @@ -623,6 +623,7 @@ fn threads_turn_state_store_skips_corrupt_entries_and_marks_interrupted() { transcript: vec![], }), output: None, + seq: None, }); let second = TurnState::started("thread-b", "req-b", 2, "2026-05-29T12:01:00Z"); store.put(&first).expect("put first"); diff --git a/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs b/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs index 890267a67a..1170e4ec31 100644 --- a/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs +++ b/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs @@ -1165,6 +1165,7 @@ fn thread_title_error_and_turn_state_helpers_cover_wire_shapes() { source_tool_name: Some("memory.search".into()), subagent: None, output: None, + seq: None, }); let wire = serde_json::to_value(GetTurnStateResponse { turn_state: Some(state.clone()), @@ -3478,6 +3479,7 @@ fn turn_state_store_persists_lists_marks_and_clears_snapshots() { transcript: vec![], }), output: None, + seq: None, }); let second = TurnState::started("thread/b", "request-2", 2, "2026-05-29T12:01:00Z"); From 1fb1538a7d4895943b806186fec052fa7f8978e1 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 22 Jul 2026 06:59:14 +0530 Subject: [PATCH 29/56] fix(flows): live-refresh the runs rail when a run starts (B35) (#5099) --- app/src/components/flows/FlowRunsDrawer.tsx | 37 ++++-- .../components/flows/FlowRunsSidebar.test.tsx | 48 +++++++ app/src/components/flows/FlowRunsSidebar.tsx | 22 +++- .../hooks/__tests__/useFlowRunStarted.test.ts | 121 ++++++++++++++++++ app/src/hooks/useFlowRunStarted.ts | 114 +++++++++++++++++ app/src/pages/WorkflowRunsPage.tsx | 25 +++- src/core/event_bus/events.rs | 17 +++ src/core/socketio.rs | 19 +++ src/openhuman/flows/ops.rs | 11 ++ src/openhuman/flows/ops_tests.rs | 77 +++++++++++ 10 files changed, 480 insertions(+), 11 deletions(-) create mode 100644 app/src/hooks/__tests__/useFlowRunStarted.test.ts create mode 100644 app/src/hooks/useFlowRunStarted.ts diff --git a/app/src/components/flows/FlowRunsDrawer.tsx b/app/src/components/flows/FlowRunsDrawer.tsx index 368c73b779..aacb82d363 100644 --- a/app/src/components/flows/FlowRunsDrawer.tsx +++ b/app/src/components/flows/FlowRunsDrawer.tsx @@ -29,6 +29,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useEscapeKey } from '../../hooks/useEscapeKey'; import { useFlowRunsLiveRefresh } from '../../hooks/useFlowRunsLiveRefresh'; +import { useFlowRunStarted } from '../../hooks/useFlowRunStarted'; import { resolveDisplayStatus, useRunsPendingApprovalSet, @@ -83,6 +84,13 @@ export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Pr // 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); + // Per-request generation counter, shared by the initial load effect and + // `refetch` below: a request started before a run-started event (or before + // a newer refetch) can resolve AFTER it and, without this guard, clobber a + // fresh "Running" row with stale data — even for the SAME flowId, where the + // `currentFlowIdRef` check alone can't tell requests apart. Only the + // most-recently-issued request for the current flow may apply its result. + const requestGenRef = useRef(0); useEffect(() => { currentFlowIdRef.current = flowId; @@ -99,16 +107,17 @@ export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Pr } let cancelled = false; + const requestGen = ++requestGenRef.current; setLoading(true); log('loading runs: flowId=%s', flowId); listFlowRuns(flowId) .then(result => { - if (cancelled) return; + if (cancelled || requestGen !== requestGenRef.current) return; setRuns(result); log('loaded runs: flowId=%s count=%d', flowId, result.length); }) .catch(err => { - if (cancelled) return; + if (cancelled || requestGen !== requestGenRef.current) return; const msg = err instanceof Error ? err.message : String(err); log('load failed: flowId=%s err=%s', flowId, msg); setError(msg); @@ -125,27 +134,39 @@ export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Pr // 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). + // Guards against a stale response two ways: 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 (`currentFlowIdRef`); and if two + // requests for the SAME flow race (e.g. the initial load and an + // event-driven refetch, or two refetches back to back), only the response + // to the most-recently-issued one may apply (`requestGenRef`). const refetch = useCallback(() => { if (!flowId) return; const requestFlowId = flowId; + const requestGen = ++requestGenRef.current; listFlowRuns(requestFlowId) .then(result => { - if (currentFlowIdRef.current !== requestFlowId) return; + if (currentFlowIdRef.current !== requestFlowId || requestGen !== requestGenRef.current) { + return; + } setRuns(result); log('refetched runs: flowId=%s count=%d', requestFlowId, result.length); }) .catch(err => { - if (currentFlowIdRef.current !== requestFlowId) return; + if (currentFlowIdRef.current !== requestFlowId || requestGen !== requestGenRef.current) { + return; + } const msg = err instanceof Error ? err.message : String(err); log('refetch failed: flowId=%s err=%s', requestFlowId, msg); }); }, [flowId]); useFlowRunsLiveRefresh(runs, refetch); + // Unconditional (unlike useFlowRunsLiveRefresh, which is gated on an + // already-active run) — fills the empty-list gap ("No runs yet") that hook + // can't reach, so the very first run shows up as "Running" instantly + // instead of waiting for a manual refresh (issue B35). + useFlowRunStarted(() => void refetch(), flowId); const pendingRunIds = useRunsPendingApprovalSet(runs); useEscapeKey( diff --git a/app/src/components/flows/FlowRunsSidebar.test.tsx b/app/src/components/flows/FlowRunsSidebar.test.tsx index 667627949a..3cd6a451f0 100644 --- a/app/src/components/flows/FlowRunsSidebar.test.tsx +++ b/app/src/components/flows/FlowRunsSidebar.test.tsx @@ -24,6 +24,18 @@ vi.mock('../../services/api/flowsApi', () => ({ listFlowRuns })); const fetchPendingApprovals = vi.hoisted(() => vi.fn()); vi.mock('../../services/api/approvalApi', () => ({ fetchPendingApprovals })); +// Stub the run-started hook (issue B35) so tests can trigger its `onStart` +// callback directly, without standing up a real socket subscription — its own +// match/filter/teardown behavior is covered by useFlowRunStarted.test.ts. +const flowRunStartedCalls = vi.hoisted( + () => [] as Array<{ onStart: () => void; flowId?: string | null }> +); +vi.mock('../../hooks/useFlowRunStarted', () => ({ + useFlowRunStarted: (onStart: () => void, flowId?: string | null) => { + flowRunStartedCalls.push({ onStart, flowId }); + }, +})); + // Capture the props handed to the drawer so "Fix with agent" can be invoked // directly without standing up the drawer's own run-polling machinery // (mirrors `FlowApprovalCard.test.tsx`'s stub pattern). @@ -100,6 +112,7 @@ describe('FlowRunsSidebar', () => { beforeEach(() => { vi.clearAllMocks(); inspectorDrawerProps.current = null; + flowRunStartedCalls.length = 0; fetchPendingApprovals.mockResolvedValue([]); }); @@ -212,4 +225,39 @@ describe('FlowRunsSidebar', () => { const runRow = await screen.findByTestId('flow-runs-sidebar-run-run-1'); await waitFor(() => expect(runRow).toHaveTextContent('Running')); }); + + it('registers useFlowRunStarted scoped to this flow and refetches when it fires (B35)', async () => { + listFlowRuns.mockResolvedValue([]); + renderSidebar('flow-1'); + + await screen.findByTestId('flow-runs-sidebar-empty'); + // The hook is called (with the same args) on every render — assert the + // most recent registration is scoped to this flow. + const latestCall = flowRunStartedCalls.at(-1); + expect(latestCall?.flowId).toBe('flow-1'); + expect(listFlowRuns).toHaveBeenCalledTimes(1); + + listFlowRuns.mockResolvedValue([makeRun({ status: 'running' })]); + act(() => { + latestCall?.onStart(); + }); + + await waitFor(() => expect(listFlowRuns.mock.calls.length).toBeGreaterThanOrEqual(2)); + expect(await screen.findByTestId('flow-runs-sidebar-run-run-1')).toHaveTextContent('Running'); + expect(screen.queryByTestId('flow-runs-sidebar-empty')).not.toBeInTheDocument(); + }); + + it('shows runs once a run starts even though the list began empty (B35)', async () => { + listFlowRuns.mockResolvedValue([]); + renderSidebar(); + + expect(await screen.findByTestId('flow-runs-sidebar-empty')).toBeInTheDocument(); + + listFlowRuns.mockResolvedValue([makeRun({ status: 'running' })]); + act(() => { + flowRunStartedCalls.at(-1)?.onStart(); + }); + + expect(await screen.findByTestId('flow-runs-sidebar-run-run-1')).toBeInTheDocument(); + }); }); diff --git a/app/src/components/flows/FlowRunsSidebar.tsx b/app/src/components/flows/FlowRunsSidebar.tsx index a999972526..ac3782634b 100644 --- a/app/src/components/flows/FlowRunsSidebar.tsx +++ b/app/src/components/flows/FlowRunsSidebar.tsx @@ -11,10 +11,11 @@ * appears for a persisted flow (a draft has no runs yet). */ import createDebug from 'debug'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useFlowRunsLiveRefresh } from '../../hooks/useFlowRunsLiveRefresh'; +import { useFlowRunStarted } from '../../hooks/useFlowRunStarted'; import { resolveDisplayStatus, useRunsPendingApprovalSet, @@ -84,12 +85,23 @@ export default function FlowRunsSidebar({ flowId }: FlowRunsSidebarProps) { [navigate] ); + // Per-request generation counter: a `load()` started before a run-started + // event (see `useFlowRunStarted` below) can resolve AFTER the event-driven + // refetch and, without this guard, clobber the fresh "Running" row with + // stale data. Only the most recently-issued request may apply its result. + const requestGenRef = useRef(0); + const load = useCallback(async () => { log('loading runs for flow=%s', flowId); + const requestGen = ++requestGenRef.current; setLoading(true); setError(null); try { const result = await listFlowRuns(flowId); + if (requestGen !== requestGenRef.current) { + log('load: dropped stale response for flow=%s', flowId); + return; + } setRuns(result); log('loaded %d runs', result.length); } catch (err) { @@ -105,6 +117,14 @@ export default function FlowRunsSidebar({ flowId }: FlowRunsSidebarProps) { }, [load]); useFlowRunsLiveRefresh(runs, load); + // Unconditional (unlike useFlowRunsLiveRefresh, which is gated on an + // already-active run) — fills the empty-list gap ("No runs yet") that + // hook can't reach, so the very first run shows up as "Running" instantly + // instead of waiting for a manual refresh (issue B35). + useFlowRunStarted(() => { + log('run-started: refetch flow=%s', flowId); + void load(); + }, flowId); const pendingRunIds = useRunsPendingApprovalSet(runs); return ( diff --git a/app/src/hooks/__tests__/useFlowRunStarted.test.ts b/app/src/hooks/__tests__/useFlowRunStarted.test.ts new file mode 100644 index 0000000000..3b73c46f60 --- /dev/null +++ b/app/src/hooks/__tests__/useFlowRunStarted.test.ts @@ -0,0 +1,121 @@ +/** + * useFlowRunStarted — unit tests (issue B35). + * + * Verifies: subscribes to both socket event aliases unconditionally on mount, + * invokes `onStart` for a matching payload, filters by `flowId` when + * provided, passes every event through when `flowId` is omitted, drops + * invalid payloads, and tears down both subscriptions on unmount. + */ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useFlowRunStarted } from '../useFlowRunStarted'; + +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_started' | 'flow_run_started', payload: unknown) { + act(() => { + for (const cb of handlers.get(event) ?? []) cb(payload); + }); +} + +describe('useFlowRunStarted', () => { + beforeEach(() => { + handlers.clear(); + on.mockClear(); + off.mockClear(); + }); + + it('subscribes to both event aliases unconditionally on mount', () => { + renderHook(() => useFlowRunStarted(vi.fn())); + + expect(on).toHaveBeenCalledWith('flow:run_started', expect.any(Function)); + expect(on).toHaveBeenCalledWith('flow_run_started', expect.any(Function)); + }); + + it('invokes onStart for a matching payload on either alias', () => { + const onStart = vi.fn(); + renderHook(() => useFlowRunStarted(onStart)); + + emit('flow:run_started', { flow_id: 'flow-1', run_id: 'run-1' }); + expect(onStart).toHaveBeenCalledWith({ flow_id: 'flow-1', run_id: 'run-1' }); + + emit('flow_run_started', { flow_id: 'flow-2', run_id: 'run-2' }); + expect(onStart).toHaveBeenCalledWith({ flow_id: 'flow-2', run_id: 'run-2' }); + expect(onStart).toHaveBeenCalledTimes(2); + }); + + it('dedupes the colon and underscore aliases of the same run so onStart fires once', () => { + // The core bridge re-emits one `FlowRunStarted` event under both socket + // aliases with identical payloads — assert the hook collapses them. + const onStart = vi.fn(); + renderHook(() => useFlowRunStarted(onStart)); + + const payload = { flow_id: 'flow-1', run_id: 'run-1' }; + emit('flow:run_started', payload); + emit('flow_run_started', payload); + + expect(onStart).toHaveBeenCalledTimes(1); + expect(onStart).toHaveBeenCalledWith({ flow_id: 'flow-1', run_id: 'run-1' }); + + // A genuinely different run still gets through. + emit('flow:run_started', { flow_id: 'flow-1', run_id: 'run-2' }); + expect(onStart).toHaveBeenCalledTimes(2); + }); + + it('filters to the given flowId when provided', () => { + const onStart = vi.fn(); + renderHook(() => useFlowRunStarted(onStart, 'flow-1')); + + emit('flow:run_started', { flow_id: 'flow-2', run_id: 'run-1' }); + expect(onStart).not.toHaveBeenCalled(); + + emit('flow:run_started', { flow_id: 'flow-1', run_id: 'run-2' }); + expect(onStart).toHaveBeenCalledWith({ flow_id: 'flow-1', run_id: 'run-2' }); + expect(onStart).toHaveBeenCalledTimes(1); + }); + + it('passes every event through when flowId is omitted', () => { + const onStart = vi.fn(); + renderHook(() => useFlowRunStarted(onStart)); + + emit('flow:run_started', { flow_id: 'flow-1', run_id: 'run-1' }); + emit('flow:run_started', { flow_id: 'flow-2', run_id: 'run-2' }); + + expect(onStart).toHaveBeenCalledTimes(2); + }); + + it('drops invalid payloads without invoking onStart', () => { + const onStart = vi.fn(); + renderHook(() => useFlowRunStarted(onStart)); + + emit('flow:run_started', null); + emit('flow:run_started', {}); + emit('flow:run_started', { flow_id: 123, run_id: 'run-1' }); + emit('flow:run_started', { flow_id: 'flow-1', run_id: 456 }); + + expect(onStart).not.toHaveBeenCalled(); + }); + + it('cleans up both subscriptions on unmount', () => { + const { unmount } = renderHook(() => useFlowRunStarted(vi.fn())); + + unmount(); + + expect(off).toHaveBeenCalledWith('flow:run_started', expect.any(Function)); + expect(off).toHaveBeenCalledWith('flow_run_started', expect.any(Function)); + }); +}); diff --git a/app/src/hooks/useFlowRunStarted.ts b/app/src/hooks/useFlowRunStarted.ts new file mode 100644 index 0000000000..83db1bdc48 --- /dev/null +++ b/app/src/hooks/useFlowRunStarted.ts @@ -0,0 +1,114 @@ +/** + * useFlowRunStarted (issue B35 — runs-rail live refresh) + * ------------------------------------------------------- + * + * Subscribes to the core's run-start feed so an open Workflows sidebar/drawer + * shows a just-started run as "Running" immediately, instead of waiting on a + * manual refresh or a navigate-away-and-back. `flows_run` is a blocking RPC + * (up to 610s), so the caller awaiting it can't be the signal — this hook + * lets the UI learn a run began the moment the `flow_runs` row is persisted, + * well before the RPC resolves or the first `FlowRunProgress` step lands. + * + * The backend publishes `DomainEvent::FlowRunStarted` right after + * `flows::ops::start_flow_run_row` returns; the core socket bridge + * (`src/core/socketio.rs`) re-emits it as both `flow:run_started` and + * `flow_run_started` (colon + underscore aliases) with the payload + * `{ flow_id, run_id }`. + * + * Unlike {@link useFlowRunsLiveRefresh}, this hook subscribes unconditionally + * (not gated on an already-active run) — that's the whole point: it fills the + * gap where the runs list is empty ("No runs yet") and so has no active run to + * gate on. Pass `flowId` to filter to a single flow (canvas/sidebar/drawer), + * or omit it to receive every run start (the flow-agnostic runs page). + * + * Because the core bridge re-emits the same `FlowRunStarted` event under both + * aliases, this hook subscribes to both sockets above but de-dupes by + * `${flow_id}:${run_id}` (unique per run) so `onStart` fires exactly once per + * run — a bounded set of recently-delivered keys (oldest evicted first) is + * kept per hook instance, sized well past any plausible in-flight burst. + */ +import debug from 'debug'; +import { useCallback, useEffect, useRef } from 'react'; + +import { socketService } from '../services/socketService'; + +const log = debug('flows:run-started'); + +/** Socket event aliases the core bridge emits (colon + underscore forms). */ +const EVENT_COLON = 'flow:run_started'; +const EVENT_UNDERSCORE = 'flow_run_started'; + +/** Bound on the recently-delivered dedup set — oldest key evicted past this. */ +const DEDUP_CACHE_SIZE = 50; + +/** Payload of a `flow:run_started` socket event (`DomainEvent::FlowRunStarted`). */ +export interface FlowRunStartedEvent { + flow_id: string; + run_id: string; +} + +function parsePayload(data: unknown): FlowRunStartedEvent | null { + if (!data || typeof data !== 'object') return null; + const obj = data as Record; + if (typeof obj.flow_id !== 'string' || typeof obj.run_id !== 'string') return null; + return { flow_id: obj.flow_id, run_id: obj.run_id }; +} + +/** + * Invokes `onStart` whenever a run starts. When `flowId` is provided, only + * starts for that flow are delivered; otherwise every start is. + */ +export function useFlowRunStarted( + onStart: (event: FlowRunStartedEvent) => void, + flowId?: string | null +): void { + // Recently-delivered `${flow_id}:${run_id}` keys, so the colon and + // underscore aliases of the same event only invoke `onStart` once. A `Set` + // preserves insertion order, so eviction just drops its first entry. + const deliveredRef = useRef>(new Set()); + + const handle = useCallback( + (data: unknown) => { + const payload = parsePayload(data); + if (!payload) { + // Never log the raw payload — it may carry PII/secrets. Safe metadata + // only: the runtime type and, if it's an object, its key names. + const keys = data && typeof data === 'object' ? Object.keys(data as object) : []; + log('run-started: dropped — invalid payload (type=%s keys=%o)', typeof data, keys); + return; + } + if (flowId && payload.flow_id !== flowId) return; + + const key = `${payload.flow_id}:${payload.run_id}`; + const delivered = deliveredRef.current; + if (delivered.has(key)) { + log( + 'run-started: dedup skip (alias replay) flow=%s run=%s', + payload.flow_id, + payload.run_id + ); + return; + } + delivered.add(key); + if (delivered.size > DEDUP_CACHE_SIZE) { + const oldest = delivered.values().next().value; + if (oldest !== undefined) delivered.delete(oldest); + } + + log('run-started: flow=%s run=%s', payload.flow_id, payload.run_id); + onStart(payload); + }, + [onStart, flowId] + ); + + useEffect(() => { + socketService.on(EVENT_COLON, handle); + socketService.on(EVENT_UNDERSCORE, handle); + return () => { + socketService.off(EVENT_COLON, handle); + socketService.off(EVENT_UNDERSCORE, handle); + }; + }, [handle]); +} + +export default useFlowRunStarted; diff --git a/app/src/pages/WorkflowRunsPage.tsx b/app/src/pages/WorkflowRunsPage.tsx index 756985400d..dbdf260fbb 100644 --- a/app/src/pages/WorkflowRunsPage.tsx +++ b/app/src/pages/WorkflowRunsPage.tsx @@ -7,12 +7,13 @@ * 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 { useCallback, useEffect, useRef, 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 { useFlowRunStarted } from '../hooks/useFlowRunStarted'; import { resolveDisplayStatus, useRunsPendingApprovalSet, @@ -45,11 +46,20 @@ export default function WorkflowRunsPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // Per-request generation counter: a `load()` started before a run-started + // event (see `useFlowRunStarted` below) can resolve AFTER the event-driven + // `refetchRuns` and, without this guard, clobber the fresh "Running" row + // with stale data. Only the most-recently-issued request may apply its + // result — shared between `load` and `refetchRuns` below. + const requestGenRef = useRef(0); + const load = useCallback(async () => { + const requestGen = ++requestGenRef.current; setLoading(true); setError(null); try { const [allRuns, flows] = await Promise.all([listAllFlowRuns(), listFlows()]); + if (requestGen !== requestGenRef.current) return; const names: Record = {}; flows.forEach((f: Flow) => { names[f.id] = f.name; @@ -57,6 +67,7 @@ export default function WorkflowRunsPage() { setRuns(allRuns); setFlowNames(names); } catch (err) { + if (requestGen !== requestGenRef.current) return; setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); @@ -71,8 +82,12 @@ export default function WorkflowRunsPage() { // too), since flow names rarely change mid-run and re-fetching them on // every live-refresh tick would be wasted work. const refetchRuns = useCallback(() => { + const requestGen = ++requestGenRef.current; listAllFlowRuns() - .then(setRuns) + .then(result => { + if (requestGen !== requestGenRef.current) return; + setRuns(result); + }) .catch(err => { // Best-effort background refresh — a transient failure here shouldn't // clobber the page's existing error/loading state from `load`. @@ -81,6 +96,12 @@ export default function WorkflowRunsPage() { }, []); useFlowRunsLiveRefresh(runs, refetchRuns); + // Unconditional (unlike useFlowRunsLiveRefresh, which is gated on an + // already-active run) — fills the empty-list gap ("No runs yet") that hook + // can't reach, so the very first run across any flow shows up as "Running" + // instantly instead of waiting for a manual refresh (issue B35). No + // `flowId` filter — this is the flow-agnostic "all runs" page. + useFlowRunStarted(() => void refetchRuns()); const pendingRunIds = useRunsPendingApprovalSet(runs); const statusLabel = (status: FlowRunStatus) => diff --git a/src/core/event_bus/events.rs b/src/core/event_bus/events.rs index c73172e5b7..fc3d490084 100644 --- a/src/core/event_bus/events.rs +++ b/src/core/event_bus/events.rs @@ -463,6 +463,21 @@ pub enum DomainEvent { status: String, }, + /// A `flows_run` (or `flows_resume`) invocation just persisted its + /// `flow_runs` row, before execution begins (issue B35, runs-rail live + /// refresh). Published from `flows::ops::flows_run` right after + /// `start_flow_run_row` returns, so the UI can show "Running" immediately + /// instead of waiting for the blocking RPC to resolve or the first + /// `FlowRunProgress` step. Covers every run source (Rpc/Schedule/AppEvent/ + /// Resume, incl. copilot live-run) since they all funnel through + /// `start_flow_run_row`. + FlowRunStarted { + /// The affected flow's id. + flow_id: String, + /// The run's stable identifier (== the tinyflows checkpointer thread id). + run_id: String, + }, + /// A saved flow's definition changed (created / updated / deleted / /// enable-toggled). Bridged to a `flow:changed` socket event so an open /// Workflows list or canvas refetches instead of silently showing stale @@ -1397,6 +1412,7 @@ impl DomainEvent { | Self::ProactiveMessageRequested { .. } | Self::FlowScheduleTick { .. } | Self::FlowRunProgress { .. } + | Self::FlowRunStarted { .. } | Self::FlowChanged { .. } => "cron", Self::WorkflowLoaded { .. } @@ -1561,6 +1577,7 @@ impl DomainEvent { Self::ProactiveMessageRequested { .. } => "ProactiveMessageRequested", Self::FlowScheduleTick { .. } => "FlowScheduleTick", Self::FlowRunProgress { .. } => "FlowRunProgress", + Self::FlowRunStarted { .. } => "FlowRunStarted", Self::FlowChanged { .. } => "FlowChanged", Self::WorkflowLoaded { .. } => "WorkflowLoaded", Self::WorkflowStopped { .. } => "WorkflowStopped", diff --git a/src/core/socketio.rs b/src/core/socketio.rs index cc0397943b..1b35bcec7c 100644 --- a/src/core/socketio.rs +++ b/src/core/socketio.rs @@ -1087,6 +1087,25 @@ pub fn spawn_web_channel_bridge(io: SocketIo) { let _ = io_memory_sync.emit("flow:run_progress", &payload); let _ = io_memory_sync.emit("flow_run_progress", &payload); } + // A `flow_runs` row was just persisted, before execution begins + // (issue B35, runs-rail live refresh). Broadcast so an open + // Workflows canvas/sidebar can show "Running" immediately + // instead of waiting for the blocking `flows_run` RPC to + // resolve or the first `FlowRunProgress` step. Best-effort, + // same rationale as `flow:run_progress` above. + crate::core::event_bus::DomainEvent::FlowRunStarted { flow_id, run_id } => { + let payload = serde_json::json!({ + "flow_id": flow_id, + "run_id": run_id, + }); + log::debug!( + "[socketio] broadcast flow_run_started flow_id={} run_id={}", + flow_id, + run_id + ); + let _ = io_memory_sync.emit("flow:run_started", &payload); + let _ = io_memory_sync.emit("flow_run_started", &payload); + } // A saved flow's definition changed (create/update/delete/ // enable). Broadcast so an open Workflows list/canvas refetches // — most importantly, so an agent `save_workflow` becomes diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index 1baf799536..d65b46c72f 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -3124,6 +3124,17 @@ pub async fn flows_run( start_flow_run_row(config, &thread_id, flow_id); + tracing::debug!( + target: "flows", + flow_id = %flow_id, + run_id = %thread_id, + "[flows] flows_run: publishing FlowRunStarted" + ); + crate::core::event_bus::publish_global(crate::core::event_bus::DomainEvent::FlowRunStarted { + flow_id: flow_id.to_string(), + run_id: thread_id.clone(), + }); + // Register this run as in-flight (issue G4) so a concurrent // `flows_cancel_run` can signal it to abort. The guard deregisters on any // exit from this fn (including the early returns below). diff --git a/src/openhuman/flows/ops_tests.rs b/src/openhuman/flows/ops_tests.rs index 70e5fbc282..96f0aed57b 100644 --- a/src/openhuman/flows/ops_tests.rs +++ b/src/openhuman/flows/ops_tests.rs @@ -1407,6 +1407,83 @@ async fn flows_run_does_not_notify_when_run_completes_without_pending_approvals( ); } +/// Issue B35 (runs-rail live refresh): `flows_run` must publish +/// `DomainEvent::FlowRunStarted` right after the run row is persisted, with +/// the flow id and the run's thread id, so the socket bridge can tell an open +/// Workflows sidebar/drawer to refetch and show "Running" immediately instead +/// of waiting for the (up to 610s) blocking RPC to resolve. +#[tokio::test] +async fn flows_run_publishes_flow_run_started_with_flow_and_run_id() { + use crate::core::event_bus::{ + init_global, subscribe_global, DomainEvent, EventHandler, DEFAULT_CAPACITY, + }; + use async_trait::async_trait; + use std::sync::Mutex as StdMutex; + + #[derive(Default)] + struct Collector { + events: Arc>>, + } + + #[async_trait] + impl EventHandler for Collector { + fn name(&self) -> &str { + "test::flows::ops::flow_run_started_collector" + } + fn domains(&self) -> Option<&[&str]> { + Some(&["cron"]) + } + async fn handle(&self, event: &DomainEvent) { + if let DomainEvent::FlowRunStarted { flow_id, run_id } = event { + self.events + .lock() + .unwrap() + .push((flow_id.clone(), run_id.clone())); + } + } + } + + init_global(DEFAULT_CAPACITY); + let events: Arc>> = Arc::new(StdMutex::new(Vec::new())); + let collector = Arc::new(Collector { + events: Arc::clone(&events), + }); + let _handle = subscribe_global(collector).expect("bus subscriber installed"); + + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let created = flows_create( + &config, + "b35-run-started".to_string(), + trigger_only_graph(), + false, + ) + .await + .unwrap(); + + let run = flows_run(&config, &created.value.id, json!({}), FlowRunTrigger::Rpc) + .await + .unwrap(); + let thread_id = run.value["thread_id"].as_str().unwrap().to_string(); + + // The bus is process-global and shared with concurrently-running tests, + // so filter for our own flow id rather than asserting on total count. + let mut found = None; + for _ in 0..20 { + { + let guard = events.lock().unwrap(); + if let Some(entry) = guard.iter().find(|(fid, _)| *fid == created.value.id) { + found = Some(entry.clone()); + break; + } + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + let (flow_id, run_id) = found.expect("expected a FlowRunStarted event for this flow"); + assert_eq!(flow_id, created.value.id); + assert_eq!(run_id, thread_id); +} + // ── Live run observation (issue G2) ─────────────────────────────────────── use crate::openhuman::tinyflows::observability::FlowRunObserver; From 31fca029d21b865cba6ba447f8c703d4f23544eb Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 22 Jul 2026 03:30:40 +0200 Subject: [PATCH 30/56] fix(inference): keep Claude CLI prompts out of Windows argv (#5103) Co-authored-by: Sami Rusani <14844597+samrusani@users.noreply.github.com> --- .../provider/claude_agent_sdk/subprocess.rs | 204 +++++++++++++++--- .../inference/provider/claude_code/driver.rs | 86 +++++++- 2 files changed, 256 insertions(+), 34 deletions(-) diff --git a/src/openhuman/inference/provider/claude_agent_sdk/subprocess.rs b/src/openhuman/inference/provider/claude_agent_sdk/subprocess.rs index 39ab70fbf1..22488602cd 100644 --- a/src/openhuman/inference/provider/claude_agent_sdk/subprocess.rs +++ b/src/openhuman/inference/provider/claude_agent_sdk/subprocess.rs @@ -2,7 +2,7 @@ use anyhow::Context; use async_trait::async_trait; -use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::Command; use tokio::time::{timeout, Duration}; @@ -15,6 +15,45 @@ pub struct ClaudeAgentSdkProvider { pub(super) config: ClaudeAgentSdkConfig, } +struct ClaudeInvocation { + args: Vec, + stdin: String, +} + +fn build_invocation( + system_prompt: Option<&str>, + message: &str, + model: &str, + max_budget_usd: Option, +) -> ClaudeInvocation { + let stdin = match system_prompt { + Some(system) if !system.trim().is_empty() => { + format!("[SYSTEM]\n{system}\n[/SYSTEM]\n\n{message}") + } + _ => message.to_string(), + }; + let mut args = vec![ + "-p".to_string(), + "--model".to_string(), + model.to_string(), + "--output-format".to_string(), + "stream-json".to_string(), + "--no-color".to_string(), + ]; + if let Some(budget) = max_budget_usd { + args.push("--max-turns".to_string()); + args.push("10".to_string()); + args.push("--budget".to_string()); + args.push(format!("{budget:.4}")); + } + ClaudeInvocation { args, stdin } +} + +fn spawn_error(binary: &str, source: std::io::Error) -> anyhow::Error { + let message = format!("failed to spawn claude binary '{binary}': {source}"); + anyhow::Error::new(source).context(message) +} + impl ClaudeAgentSdkProvider { pub fn new(config: ClaudeAgentSdkConfig) -> Self { Self { config } @@ -36,42 +75,47 @@ impl Provider for ClaudeAgentSdkProvider { model }; - // Prepend system prompt inline — claude -p has no separate system flag. - let full_message = match system_prompt { - Some(s) if !s.trim().is_empty() => { - format!("[SYSTEM]\n{s}\n[/SYSTEM]\n\n{message}") - } - _ => message.to_string(), - }; + // `claude -p` reads stdin in non-interactive mode. Keep the full + // request out of argv so large harness prompts can spawn on Windows. + let invocation = + build_invocation(system_prompt, message, model, self.config.max_budget_usd); let mut cmd = Command::new(&self.config.binary); - cmd.arg("-p") - .arg(&full_message) - .arg("--model") - .arg(model) - .arg("--output-format") - .arg("stream-json") - .arg("--no-color") + cmd.args(&invocation.args) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) - .stdin(std::process::Stdio::null()); - - if let Some(budget) = self.config.max_budget_usd { - cmd.arg("--max-turns").arg("10"); - // Note: --budget flag controls the spend cap in the Claude CLI - cmd.arg("--budget").arg(format!("{budget:.4}")); - } + .stdin(std::process::Stdio::piped()) + .kill_on_drop(true); tracing::debug!( "[claude_agent_sdk] spawning claude binary={} model={} message_len={}", self.config.binary, model, - full_message.len() + invocation.stdin.len() ); - let mut child = cmd - .spawn() - .with_context(|| format!("failed to spawn claude binary '{}'", self.config.binary))?; + let mut child = cmd.spawn().map_err(|source| { + tracing::error!( + error = %source, + binary = %self.config.binary, + "[claude_agent_sdk] failed to spawn claude binary" + ); + spawn_error(&self.config.binary, source) + })?; + + let mut stdin = child + .stdin + .take() + .context("claude subprocess has no stdin")?; + stdin + .write_all(invocation.stdin.as_bytes()) + .await + .context("failed to write claude request to stdin")?; + stdin + .shutdown() + .await + .context("failed to close claude subprocess stdin")?; + drop(stdin); let stdout = child .stdout @@ -213,4 +257,112 @@ mod tests { assert!(!config.enabled); assert!(config.max_budget_usd.is_none()); } + + #[test] + fn large_request_is_delivered_over_stdin_instead_of_argv() { + let system_prompt = "system instruction\n".repeat(2_500); + assert!(system_prompt.len() > 32_767); + + let invocation = build_invocation(Some(&system_prompt), "hello", "claude-sonnet-4-6", None); + + assert_eq!( + invocation.args, + [ + "-p", + "--model", + "claude-sonnet-4-6", + "--output-format", + "stream-json", + "--no-color" + ] + ); + assert!(!invocation + .args + .iter() + .any(|arg| arg.contains(&system_prompt))); + assert_eq!( + invocation.stdin, + format!("[SYSTEM]\n{system_prompt}\n[/SYSTEM]\n\nhello") + ); + } + + #[test] + fn invocation_preserves_plain_message_and_budget_flags() { + let invocation = build_invocation(None, "hello", "claude-opus-4-6", Some(1.25)); + + assert_eq!(invocation.stdin, "hello"); + assert_eq!( + &invocation.args[6..], + ["--max-turns", "10", "--budget", "1.2500"] + ); + } + + #[test] + fn spawn_error_message_includes_the_os_source() { + let source = std::io::Error::from_raw_os_error(206); + let error = spawn_error(r"C:\Users\test\.local\bin\claude.exe", source); + + assert!(error.to_string().contains("os error 206")); + assert_eq!( + error.chain().count(), + 2, + "io::Error source must be preserved" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn provider_pipes_large_request_to_cli_stdin() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir"); + let script = dir.path().join("claude"); + std::fs::write( + &script, + r#"#!/bin/sh +cat > "$0.stdin" +printf '%s\n' '{"type":"result","result":"captured","is_error":false}' +"#, + ) + .expect("write fake claude"); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)) + .expect("make fake claude executable"); + + let mut config = ClaudeAgentSdkConfig::default(); + config.binary = script.display().to_string(); + let provider = ClaudeAgentSdkProvider::new(config); + let system_prompt = "system instruction\n".repeat(2_500); + + let output = provider + .chat_with_system(Some(&system_prompt), "hello", "claude-sonnet-4-6", 0.0) + .await + .expect("fake claude response"); + + assert_eq!(output, "captured"); + assert_eq!( + std::fs::read_to_string(format!("{}.stdin", script.display())).expect("captured stdin"), + format!("[SYSTEM]\n{system_prompt}\n[/SYSTEM]\n\nhello") + ); + } + + #[tokio::test] + async fn provider_spawn_error_includes_the_os_source() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut config = ClaudeAgentSdkConfig::default(); + config.binary = dir.path().join("missing-claude").display().to_string(); + let provider = ClaudeAgentSdkProvider::new(config); + + let error = provider + .chat_with_system(None, "hello", "claude-sonnet-4-6", 0.0) + .await + .expect_err("missing binary must fail"); + + assert!(error.to_string().contains("failed to spawn claude binary")); + assert!(error.to_string().contains("os error")); + assert_eq!( + error.chain().count(), + 2, + "io::Error source must be preserved" + ); + } } diff --git a/src/openhuman/inference/provider/claude_code/driver.rs b/src/openhuman/inference/provider/claude_code/driver.rs index f2eccb414f..a688ee9193 100644 --- a/src/openhuman/inference/provider/claude_code/driver.rs +++ b/src/openhuman/inference/provider/claude_code/driver.rs @@ -186,6 +186,42 @@ fn write_mcp_http_config( Ok(path) } +/// Keep the potentially large harness prompt out of argv. Windows flattens +/// argv into a command line capped at 32,767 UTF-16 code units, while Claude's +/// file flag has no such limit. The per-turn scratch directory owns cleanup. +fn append_system_prompt_args( + dir: &std::path::Path, + prompt: Option<&str>, +) -> std::io::Result> { + let Some(prompt) = prompt.filter(|value| !value.trim().is_empty()) else { + return Ok(Vec::new()); + }; + + let path = dir.join("append-system-prompt.txt"); + log::debug!( + "[claude-code][driver] append-system-prompt file write start path={} bytes={}", + path.display(), + prompt.len() + ); + if let Err(error) = std::fs::write(&path, prompt) { + log::warn!( + "[claude-code][driver] append-system-prompt file write failed path={} error={}", + path.display(), + error + ); + return Err(error); + } + log::debug!( + "[claude-code][driver] append-system-prompt file write complete path={} bytes={}", + path.display(), + prompt.len() + ); + Ok(vec![ + "--append-system-prompt-file".to_string(), + path.display().to_string(), + ]) +} + /// Run one turn against the `claude` CLI. Awaits process exit. Forwards /// `ProviderDelta`s through `ctx.stream` as they arrive and returns the /// aggregated `ChatResponse` when done. @@ -281,14 +317,10 @@ pub async fn run_turn(ctx: TurnContext<'_>) -> anyhow::Result { "--model".into(), ctx.model.clone(), ]; - if let Some(sp) = ctx - .append_system_prompt - .as_ref() - .filter(|s| !s.trim().is_empty()) - { - args.push("--append-system-prompt".into()); - args.push(sp.clone()); - } + args.extend( + append_system_prompt_args(scratch.path(), ctx.append_system_prompt.as_deref()) + .map_err(|e| anyhow::anyhow!("write Claude Code system prompt file: {e}"))?, + ); if let Some(p) = mcp_config_path.as_ref() { args.push("--mcp-config".into()); args.push(p.display().to_string()); @@ -486,6 +518,44 @@ mod tests { assert!(server.get("command").is_none()); } + #[test] + fn large_system_prompt_is_written_to_file_instead_of_argv() { + let dir = tempfile::tempdir().expect("tempdir"); + let prompt = "system instruction\n".repeat(2_500); + assert!(prompt.len() > 32_767); + + let args = append_system_prompt_args(dir.path(), Some(&prompt)).expect("prompt args"); + + assert_eq!(args[0], "--append-system-prompt-file"); + assert_eq!(args.len(), 2); + assert!(!args.iter().any(|arg| arg.contains(&prompt))); + assert_eq!( + std::fs::read_to_string(&args[1]).expect("read prompt file"), + prompt + ); + } + + #[test] + fn empty_system_prompt_does_not_add_an_argument() { + let dir = tempfile::tempdir().expect("tempdir"); + let args = append_system_prompt_args(dir.path(), Some(" \n ")).expect("prompt args"); + + assert!(args.is_empty()); + assert!(!dir.path().join("append-system-prompt.txt").exists()); + } + + #[test] + fn system_prompt_write_error_is_propagated() { + let dir = tempfile::tempdir().expect("tempdir"); + let not_a_directory = dir.path().join("file"); + std::fs::write(¬_a_directory, "occupied").expect("write blocking file"); + + let error = append_system_prompt_args(¬_a_directory, Some("system prompt")) + .expect_err("non-directory parent must fail"); + + assert!(!error.to_string().is_empty()); + } + #[cfg(target_os = "macos")] #[test] fn seatbelt_profile_denies_whole_openhuman_root_not_just_subdir() { From ddeee5b5d6ea7a1af6359bdabf77577ac4759af4 Mon Sep 17 00:00:00 2001 From: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:32:07 +0530 Subject: [PATCH 31/56] feat(core): shed embedded deps behind inference/documents/crash-reporting/http-server gates (#5048) (#5068) --- Cargo.toml | 134 ++++++++++++++--- app/src-tauri/Cargo.toml | 7 + app/src-tauri/src/lib.rs | 16 ++ src/core/agent_cli.rs | 2 + src/core/all.rs | 5 +- src/core/all_tests.rs | 82 ++++++++++ src/core/auth.rs | 32 ++++ src/core/cli.rs | 20 +++ src/core/http_server_status.rs | 54 +++++++ src/core/jsonrpc.rs | 71 ++++++++- src/core/jsonrpc_tests.rs | 39 ++++- src/core/log_redaction.rs | 107 +++++++++++++ src/core/logging.rs | 13 ++ src/core/mod.rs | 5 + src/core/observability.rs | 142 ++++++++++++++++++ src/core/runtime/builder.rs | 55 +++++++ src/core/runtime/mod.rs | 17 +++ src/core/shutdown.rs | 8 +- src/core/socketio.rs | 32 +++- src/main.rs | 60 ++------ src/openhuman/accessibility/permissions.rs | 19 ++- src/openhuman/agent/multimodal.rs | 11 ++ src/openhuman/agent_registry/agents/loader.rs | 79 ++++++++-- src/openhuman/agentbox/mod.rs | 14 +- src/openhuman/artifacts/ops.rs | 43 +++++- src/openhuman/composio/ops_tests.rs | 2 + src/openhuman/credentials/sentry_scope.rs | 9 +- src/openhuman/inference/http/mod.rs | 7 + .../local/service/whisper_engine/mod.rs | 53 +++++++ .../real.rs} | 15 +- .../local/service/whisper_engine/stub.rs | 131 ++++++++++++++++ .../local/service/whisper_engine/types.rs | 18 +++ src/openhuman/inference/mod.rs | 8 + src/openhuman/inference/voice/mod.rs | 5 + src/openhuman/mcp_server/local.rs | 45 +++++- src/openhuman/mcp_server/mod.rs | 9 +- src/openhuman/mcp_server/stdio.rs | 38 +++-- src/openhuman/mod.rs | 6 + src/openhuman/text_input/mod.rs | 21 +++ src/openhuman/tools/impl/mod.rs | 4 + src/openhuman/tools/ops.rs | 2 + src/openhuman/tools/ops_tests.rs | 70 +++++++++ src/openhuman/voice/mod.rs | 6 +- src/openhuman/voice/stub.rs | 5 + 44 files changed, 1400 insertions(+), 121 deletions(-) create mode 100644 src/core/http_server_status.rs create mode 100644 src/core/log_redaction.rs create mode 100644 src/openhuman/inference/local/service/whisper_engine/mod.rs rename src/openhuman/inference/local/service/{whisper_engine.rs => whisper_engine/real.rs} (97%) create mode 100644 src/openhuman/inference/local/service/whisper_engine/stub.rs create mode 100644 src/openhuman/inference/local/service/whisper_engine/types.rs diff --git a/Cargo.toml b/Cargo.toml index 1086c0e21f..70d41d6c00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,10 @@ path = "src/bin/test_mcp_stub.rs" [[bin]] name = "openhuman-fleet" path = "src/bin/fleet.rs" +# The fleet supervisor embeds the axum control-plane server, so it only builds +# with the HTTP transport compiled in (#5048). Under `--no-default-features` +# (no `http-server`) this target is skipped rather than failing to link. +required-features = ["http-server"] # Embedded-RSS benchmark harness (#5046). Gated behind the default-OFF # `rss-bench` feature so no benchmark code enters the shipped build. Build with @@ -118,7 +122,15 @@ serde_yaml = "0.9" # stripper (`fast_html_to_text` in # providers/gmail/post_process.rs) and prefer the email's # `text/plain` MIME part when available.) -reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "native-tls", "stream", "http2", "multipart", "socks"] } +# TLS: rustls only in the base declaration, so Linux/macOS — including the +# headless embedding target (#5046) — link a single TLS stack + cert set +# (Mozilla webpki-roots). Windows re-adds `native-tls` via the +# `[target.'cfg(windows)'.dependencies]` block below (Cargo unions features +# per target), because `src/openhuman/tls/mod.rs` deliberately routes the +# Windows client through the SChannel/OS cert store for corporate MITM + +# AV root CAs. So the two TLS backends coexist only on Windows, never on +# the RAM-sensitive platforms. +reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "stream", "http2", "multipart", "socks"] } # Already in-tree via reqwest/hyper; named directly so `IntegrationClient::get_bytes` # can return `bytes::Bytes` without a copy. bytes = "1" @@ -211,16 +223,22 @@ sysinfo = { version = "0.33", default-features = false, features = ["system"] } keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] } clap = { version = "4.5", features = ["derive"] } lettre = { version = "0.11.22", default-features = false, features = ["builder", "smtp-transport", "rustls-tls"], optional = true } -axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio", "query", "ws", "macros"] } -tower = { version = "0.5", default-features = false } -sentry = { version = "0.47.0", default-features = false, features = ["backtrace", "contexts", "panic", "tracing", "debug-images", "reqwest", "rustls"] } +# HTTP + Socket.IO server transport — the `/rpc` JSON-RPC endpoint, `/v1` +# OpenAI-compat router, agentbox/http_host sub-servers, and the Socket.IO +# live-event bridge. Exclusive to the default-ON `http-server` feature (#5048): +# a slim/embedded build without it (`--no-default-features`) sheds `axum` + +# `socketioxide` and never binds a listener — the core still runs background +# services and answers over the CLI/native dispatch surface. See the +# `http-server` feature note below and `src/core/http_server_status.rs`. +axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio", "query", "ws", "macros"], optional = true } +sentry = { version = "0.47.0", default-features = false, optional = true, features = ["backtrace", "contexts", "panic", "tracing", "debug-images", "reqwest", "rustls"] } tokio-stream = { version = "0.1.18", features = ["full"] } url = "2" -socketioxide = { version = "0.15", features = ["extensions"] } -whisper-rs = "0.16" +socketioxide = { version = "0.15", features = ["extensions"], optional = true } +whisper-rs = { version = "0.16", optional = true } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } tempfile = "3" -cpal = "0.15" +cpal = { version = "0.15", optional = true } hound = { version = "3.5", optional = true } enigo = "0.3" arboard = "3" @@ -249,11 +267,11 @@ coins-bip39 = "0.8" curve25519-dalek = { version = "4", default-features = false, features = ["alloc"], optional = true } fantoccini = { version = "0.22.0", optional = true, default-features = false, features = ["rustls-tls"] } -pdf-extract = "0.10" +pdf-extract = { version = "0.10", optional = true } # The WhatsApp Web provider (and its `whatsapp-rust` / `wacore` / `serde-big-array` # stack) now lives in the tinychannels crate; the `whatsapp-web` feature forwards # to `tinychannels/whatsapp-web`. -ppt-rs = "0.2.14" +ppt-rs = { version = "0.2.14", optional = true } # Terminal chat UI (`openhuman tui` / `chat`). Exclusive to the default-ON # `tui` feature (see `[features]` below): a slim / headless build without `tui` # drops both `ratatui` and `crossterm`. Kept in lockstep — ratatui 0.30 @@ -271,13 +289,19 @@ unicode-width = { version = "0.2", optional = true } # `ppt-rs` presentation engine: synthesise bytes in-process, hand them to # the byte-agnostic artifact pipeline. `default-features` keeps the crate's # image support (unused here, but avoids a bespoke feature list drifting). -docx-rs = "0.4.20" +docx-rs = { version = "0.4.20", optional = true } [target.'cfg(windows)'.dependencies] # Windows: tokio-tungstenite uses native-tls (schannel) so wss:// # connections honor the Windows cert store, including corporate CAs # installed by AV / TLS-inspection proxies. See run-dev-win.sh notes. tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "handshake", "native-tls"] } +# Windows re-adds reqwest's native-tls backend (dropped from the base decl in +# `[dependencies]`) so `tls::tls_client_builder()` can call `.use_native_tls()` +# and reach the SChannel/OS cert store — same rationale as tokio-tungstenite +# above. Cargo unions these features with the base rustls decl, so on Windows +# reqwest carries both backends; Linux/macOS keep rustls only. +reqwest = { version = "0.12", default-features = false, features = ["native-tls"] } # AppContainer / process-jail backend in `openhuman::cwd_jail`. # Feature list mirrors the Win32 surface used by cwd_jail/windows.rs: # AppContainer profile APIs, ACL editing, STARTUPINFOEXW process spawn, @@ -305,7 +329,7 @@ uiautomation = { version = "0.25", optional = true } tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "handshake", "rustls-tls-webpki-roots"] } [target.'cfg(target_os = "macos")'.dependencies] -whisper-rs = { version = "0.16", features = ["metal"] } +whisper-rs = { version = "0.16", features = ["metal"], optional = true } # Contacts framework bindings for address book seeding. objc2 = "0.6" objc2-foundation = { version = "0.3", features = ["NSArray", "NSError", "NSObject", "NSString", "NSPredicate"] } @@ -324,6 +348,18 @@ rppal = { version = "0.22", optional = true } # crates we never use (and that bloat the dev Cargo.lock noticeably). # TestTransport only needs the `test` feature. sentry = { version = "0.47.0", default-features = false, features = ["test"] } +# axum is optional in `[dependencies]` (exclusive to the default-ON +# `http-server` feature, #5048), but ~17 `#[cfg(test)]` modules stand up +# in-process axum mock servers / call `build_core_http_router` regardless of +# the feature set under test. Declaring a plain (non-optional) dev-dep keeps +# those tests compiling in ALL test builds without per-file `#[cfg]` — mirrors +# the `sentry` dual-declaration above. The prod fns those tests exercise are +# still gated, so the *tests* naming them carry `#[cfg(feature = "http-server")]`. +axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio", "query", "ws", "macros"] } +# `tower` was a plain runtime dep only for its re-exports in the axum server +# path; with `http-server` optional it is test-only (tower::ServiceExt::oneshot +# drives the mock routers), so it lives here as a dev-dep now. +tower = { version = "0.5", default-features = false } # Mock HTTP server for provider E2E tests (inference_provider_e2e). wiremock = "0.6" # Used in json_rpc_e2e to backdate mtime on stale lock files. @@ -338,12 +374,56 @@ tokio = { version = "1", features = ["test-util"] } proptest = "1" [features] -default = ["tokenjuice-treesitter", "voice", "web3", "media", "meet", "skills", "flows", "mcp", "desktop-automation", "tui"] +default = ["tokenjuice-treesitter", "inference", "voice", "web3", "media", "documents", "meet", "skills", "flows", "mcp", "crash-reporting", "http-server", "desktop-automation", "tui"] +# HTTP + Socket.IO server transport (#5048): the `/rpc` JSON-RPC endpoint and +# its auth middleware/CORS layer (`core::jsonrpc`, `core::auth`), the `/v1` +# OpenAI-compatible router (`inference::http`), the ad-hoc static-dir file +# server (`openhuman::http_host`), the AgentBox `/run` HTTP surface +# (`agentbox::http`), the WebSocket dictation stream +# (`inference::voice::streaming`), the `openhuman text-input run` dev server, +# the MCP Streamable-HTTP transport (`mcp_server::http`, additionally gated by +# `mcp`), and the Socket.IO live-event bridge (`core::socketio`). Default-ON — +# the desktop shell REQUIRES it (see the `HTTP_SERVER_COMPILED_IN` compile +# assert in `app/src-tauri/src/lib.rs`). Slim / headless-embedding builds opt +# out via `--no-default-features --features ""`, which drops the exclusive `axum` + `socketioxide` deps; the +# core then runs background services without binding a listener (`serve()` +# returns early) and is driven over the CLI / native dispatch surface instead. +# +# TYPE CARVE-OUT (see AGENTS.md): `core::socketio`'s inert event payload types +# (`WebChannelEvent`, `TurnUsagePayload`, `SubagentUsagePayload`, +# `SubagentProgressDetail`) stay compiled in BOTH builds — ~10 always-on +# domains construct them — so `pub mod socketio;` is UNGATED and only the +# socketioxide/axum-touching bodies are gated. Likewise `inference::http::types` +# and `EXTERNAL_OPENAI_COMPAT_PROVIDER` stay compiled for `core::auth`. +http-server = ["dep:axum", "dep:socketioxide"] # 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 = [ "tinyjuice/tinyjuice-treesitter", ] +# In-process inference engine: the whisper.cpp STT engine +# (`inference::local::service::whisper_engine`) plus the `cpal` audio-device +# probe shared by voice capture and the accessibility microphone-permission +# check. Default-ON. Slim / headless builds opt out via +# `--no-default-features --features ""`, which +# drops the exclusive `whisper-rs` (including the macOS `metal` variant and the +# `whisper-rs-sys` patch, which go inert once whisper-rs leaves the graph) and +# `cpal` dependencies. When off, the whisper facade resolves to +# `whisper_engine::stub` — in-process STT returns a disabled error, while the +# local-AI service still reaches Ollama / LM Studio over HTTP — and the +# microphone probe reports `Unknown`. `voice` requires this gate, so building +# `voice` always pulls `inference` in transitively. +inference = ["dep:whisper-rs", "dep:cpal"] +# Office-document tools: the `generate_presentation` (ppt-rs) and +# `generate_document` (docx-rs) agent tools, plus `pdf-extract` for PDF text +# extraction during multimodal file ingest. Default-ON. Slim / headless builds +# opt out via `--no-default-features --features ""`, which drops all three crates. Leaf gate (no stub facade): when +# off, the two tools are absent from the tool list rather than degraded to an +# error, and PDF ingest degrades the file to a reference instead of extracted +# text (`agent::multimodal::extract_pdf_text`). +documents = ["dep:pdf-extract", "dep:ppt-rs", "dep:docx-rs"] # Voice + audio_toolkit domains: STT/TTS providers, the standalone dictation # server, always-on listening, and podcast audio generation/email delivery. # Default-ON — the desktop app always ships with voice. Slim / headless builds @@ -351,10 +431,9 @@ tokenjuice-treesitter = [ # which also drops the exclusive `hound` (WAV I/O) + `lettre` (podcast email) # dependencies. Composes with the runtime `DomainSet::voice` flag (#4796): the # feature narrows the compile-time surface, `DomainSet` gates it at runtime. -# NOTE: this gate does NOT drop whisper-rs / llama / cpal — those live in the -# inference domain (shared with accessibility for cpal) and await a separate -# `inference` gate. -voice = ["dep:hound", "dep:lettre"] +# Requires `inference` (the whisper engine + the cpal capture stack), so +# enabling `voice` turns `inference` on transitively. +voice = ["inference", "dep:hound", "dep:lettre"] # Web3 domains: openhuman::wallet + openhuman::web3 + openhuman::x402 — the # crypto wallet (multi-chain sign/broadcast), the high-level swap/bridge/dapp # surface, and the x402 machine-payment protocol. Default-ON — the desktop app @@ -449,6 +528,28 @@ skills = [] # `sanitize::sanitize_for_llm`). The gate follows the real dependency graph, # not the directory name. mcp = [] +# Sentry crash/error reporting: the `sentry::init` guard + secret-scrubbing +# `before_send` hook (src/main.rs), the sentry-tracing bridge layer +# (core::logging), the ~24 `before_send` Event classifiers + the two +# `report_*_message` capture paths (core::observability), the session-boundary +# scope binding (credentials::sentry_scope), and the `openhuman sentry-test` +# CLI probe. Default-ON — the desktop app always ships with crash reporting. +# Slim / headless builds opt out via `--no-default-features --features +# ""`, which drops the exclusive +# `sentry` dependency (and its transitive backtrace/contexts/panic/tracing/ +# debug-images/reqwest/rustls stack) from the non-test build. +# +# TYPE CARVE-OUT (see AGENTS.md "Compile-time domain gates"): the sentry-free +# string classifiers (`is_transient_message_failure`, +# `is_insufficient_credits_message`, `contains_transient_transport_phrase`, …) +# and the `report_*_message` / `sentry_scope::{bind,clear}` signatures stay +# compiled in BOTH builds — only the `sentry::`-touching bodies are gated — so +# NO stub file is needed and always-on callers need no per-call `#[cfg]`. When +# off, `report_error_message`/`report_warning_message` still emit their +# `tracing::{error,warn}!` diagnostic, `sentry_tracing_layer()` degrades to a +# no-op `Identity` layer, and `openhuman sentry-test` returns a "built without +# the crash-reporting feature" error. +crash-reporting = ["dep:sentry"] # Desktop-automation cluster (#5049): the five modules that read/drive the local # desktop UI — `openhuman::accessibility` (macOS AX / Windows UIA FFI middleware), @@ -484,7 +585,6 @@ desktop-automation = ["dep:uiautomation"] # build-fact "tui feature disabled at compile time" error from the untouched # `"tui" | "chat"` CLI arm (mirrors the `mcp` stub pattern). tui = ["dep:ratatui", "dep:crossterm", "dep:unicode-width"] - 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 5b23d5e930..8e20776d81 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -163,12 +163,19 @@ cef = { version = "=146.4.1", default-features = false } openhuman_core = { path = "../..", package = "openhuman", default-features = false, features = [ "media", "tokenjuice-treesitter", + "inference", "voice", "web3", + "documents", "flows", "meet", "skills", "mcp", + "crash-reporting", + # The desktop shell reaches the in-process core only over + # http://127.0.0.1:/rpc, so it REQUIRES the HTTP + Socket.IO transport + # (#5048). Enforced by the HTTP_SERVER_COMPILED_IN compile assert in lib.rs. + "http-server", "desktop-automation", ] } 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 b597458325..7f4a0d3c56 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -17,6 +17,21 @@ const _: () = assert!( Add \"voice\" to the openhuman_core `features` list in app/src-tauri/Cargo.toml." ); +// The shell talks to the in-process core only over http://127.0.0.1:/rpc, +// so the core MUST embed the HTTP + Socket.IO transport (#5048). Same failure +// class as #4901: with `http-server` dropped the core never binds a listener +// and every RPC is unreachable — silent and runtime-only. The marker lives in +// the core's always-compiled facade (`core::http_server_status`) precisely so +// this assert can observe the core's feature state (a dependent's own +// `#[cfg(feature = ...)]` would test THIS crate's features, not the core's). +const _: () = assert!( + openhuman_core::core::http_server_status::HTTP_SERVER_COMPILED_IN, + "openhuman_core must be built with the `http-server` feature: the desktop app reaches \ + the core only over http://127.0.0.1:/rpc, and without it the core binds no \ + listener so every RPC is unreachable (#5048). \ + Add \"http-server\" 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. @@ -2387,6 +2402,7 @@ pub fn run() { let custom_runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .thread_stack_size(openhuman_core::core::runtime::AGENT_WORKER_STACK_BYTES) + .max_blocking_threads(openhuman_core::core::runtime::MAX_BLOCKING_THREADS) .build() .expect("build custom tokio runtime for tauri async surface"); let handle = custom_runtime.handle().clone(); diff --git a/src/core/agent_cli.rs b/src/core/agent_cli.rs index 4a51e47c7b..b35a4a3d2a 100644 --- a/src/core/agent_cli.rs +++ b/src/core/agent_cli.rs @@ -123,6 +123,7 @@ fn run_dump_all(args: &[String]) -> Result<()> { let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .thread_stack_size(crate::core::runtime::AGENT_WORKER_STACK_BYTES) + .max_blocking_threads(crate::core::runtime::MAX_BLOCKING_THREADS) .build()?; log::debug!("[agent-cli] run_dump_all: calling dump_all_agent_prompts"); let dumps = rt.block_on(async { @@ -255,6 +256,7 @@ fn run_dump_prompt(args: &[String]) -> Result<()> { let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .thread_stack_size(crate::core::runtime::AGENT_WORKER_STACK_BYTES) + .max_blocking_threads(crate::core::runtime::MAX_BLOCKING_THREADS) .build()?; log::debug!("[agent-cli] run_dump_prompt: calling dump_agent_prompt"); let dumped = rt.block_on(async { dump_agent_prompt(options).await })?; diff --git a/src/core/all.rs b/src/core/all.rs index 9374b78bcf..5881e8cab5 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -355,7 +355,10 @@ fn build_registered_controllers() -> Vec { DomainGroup::Platform, crate::openhuman::heartbeat::all_heartbeat_registered_controllers(), ); - // Ad-hoc static directory HTTP hosting for local file sharing / previews + // Ad-hoc static directory HTTP hosting for local file sharing / previews. + // Gated with the `http-server` feature (#5048): the domain is an axum server, + // so a slim build has no `http_host.*` controllers to register. + #[cfg(feature = "http-server")] push( &mut controllers, DomainGroup::Platform, diff --git a/src/core/all_tests.rs b/src/core/all_tests.rs index cdad5265ce..efecb15977 100644 --- a/src/core/all_tests.rs +++ b/src/core/all_tests.rs @@ -233,6 +233,38 @@ fn voice_and_audio_controllers_absent_when_feature_off() { ); } +/// With the `inference` feature on (the default), the in-process whisper STT +/// engine is compiled in — `INFERENCE_COMPILED_IN` reflects that, and +/// `whisper-rs` + `cpal` are linked (dependency shed proven separately by +/// `cargo tree -i whisper-rs` / `cargo tree -i cpal`). +#[test] +#[cfg(feature = "inference")] +fn inference_engine_compiled_in_when_feature_on() { + assert!(crate::openhuman::inference::INFERENCE_COMPILED_IN); +} + +/// With the `inference` feature off, the whisper engine is compiled out: the +/// marker flips and the always-compiled `whisper_engine` facade resolves to the +/// disabled stub — every transcription call returns the disabled error, while +/// the local-AI service still reaches Ollama / LM Studio over HTTP. This is the +/// compile-time stub-facade correctness gate (see +/// `inference::local::service::whisper_engine::stub`); `whisper-rs` + `cpal` +/// leave the dependency graph. +#[test] +#[cfg(not(feature = "inference"))] +fn inference_engine_compiled_out_when_feature_off() { + use crate::openhuman::inference::local::service::whisper_engine; + assert!(!crate::openhuman::inference::INFERENCE_COMPILED_IN); + let handle = whisper_engine::new_handle(); + assert!(!whisper_engine::is_loaded(&handle)); + let err = whisper_engine::transcribe_pcm_f32(&handle, &[0.0; 16], None, None) + .expect_err("in-process STT must be disabled when `inference` is off"); + assert!( + err.contains("inference"), + "disabled error should name the gate: {err}" + ); +} + /// With the `skills` feature on (the default), all three skill domains are /// compiled in and registered — the desktop build is byte-identical. #[test] @@ -1095,6 +1127,56 @@ fn meet_controllers_absent_when_feature_off() { } } +/// With the `http-server` feature on (the default), the HTTP + Socket.IO +/// transport is compiled in — `HTTP_SERVER_COMPILED_IN` reflects that, and +/// `socketioxide` is linked. `socketioxide` is the only dependency this gate +/// actually sheds (proven by `cargo tree -i socketioxide`); `axum` stays in the +/// graph either way because `tinychannels` pulls it transitively. +#[test] +#[cfg(feature = "http-server")] +fn http_server_compiled_in_when_feature_on() { + assert!(crate::core::http_server_status::HTTP_SERVER_COMPILED_IN); +} + +/// With the `http-server` feature off, the transport is compiled out: the +/// marker flips, `serve()` returns without binding a listener, and the +/// exclusive `socketioxide` dependency leaves the graph (`axum` remains, pulled +/// transitively by `tinychannels`). The desktop shell's compile-time assert on +/// this marker (`app/src-tauri/src/lib.rs`) turns a silent listener-less core +/// into a build failure (cf. voice #4901). +#[test] +#[cfg(not(feature = "http-server"))] +fn http_server_compiled_out_when_feature_off() { + assert!(!crate::core::http_server_status::HTTP_SERVER_COMPILED_IN); +} + +/// With `http-server` on, the `http_host` static-directory server registers its +/// controllers, so the `http_host.*` RPC surface is present in `/schema`. +#[test] +#[cfg(feature = "http-server")] +fn http_host_controllers_registered_when_http_server_on() { + let schemas = all_controller_schemas(); + assert!( + schemas.iter().any(|s| s.namespace == "http_host"), + "`http_host` controllers must be registered when the `http-server` feature is on" + ); +} + +/// With `http-server` off, the whole `http_host` axum domain is compiled out and +/// its controller-registration push in `core::all` is gated in lockstep, so the +/// `http_host` namespace never enters the registry (unknown-method over `/rpc`, +/// absent from `/schema`). This is the negative half that proves the gate +/// removes the surface. +#[test] +#[cfg(not(feature = "http-server"))] +fn http_host_controllers_absent_when_http_server_off() { + let schemas = all_controller_schemas(); + assert!( + !schemas.iter().any(|s| s.namespace == "http_host"), + "`http_host` controllers must be compiled out when the `http-server` feature is off" + ); +} + /// All three desktop-automation namespaces register under /// `DomainGroup::DesktopAutomation` when the `desktop-automation` feature is on /// (#5049). Paired with `desktop_automation_controllers_absent_when_feature_off` diff --git a/src/core/auth.rs b/src/core/auth.rs index fa03c688e8..8bcce49055 100644 --- a/src/core/auth.rs +++ b/src/core/auth.rs @@ -62,14 +62,28 @@ use std::sync::OnceLock; #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt as _; +// The axum RPC-auth middleware + the `/v1` external-inference bearer helpers +// are exclusive to the `http-server` feature (#5048). The always-compiled token +// primitives (`verify_bearer_token`, `init_rpc_token`, `get_rpc_token`, +// `init_rpc_token_with_value`, `bearer_matches`) stay ungated — non-HTTP +// transports and `CoreBuilder` call them in every build. `Config`/`AuthService`/ +// the provider-id import are consumed only by the gated `/v1` helpers. +#[cfg(feature = "http-server")] use axum::http::{header, Method, StatusCode}; +#[cfg(feature = "http-server")] use axum::middleware::Next; +#[cfg(feature = "http-server")] use axum::response::{IntoResponse, Response}; +#[cfg(feature = "http-server")] use axum::Json; +#[cfg(feature = "http-server")] use serde_json::json; +#[cfg(feature = "http-server")] use crate::openhuman::config::Config; +#[cfg(feature = "http-server")] use crate::openhuman::credentials::AuthService; +#[cfg(feature = "http-server")] use crate::openhuman::inference::http::EXTERNAL_OPENAI_COMPAT_PROVIDER; static RPC_TOKEN: OnceLock = OnceLock::new(); @@ -85,6 +99,7 @@ static RPC_TOKEN: OnceLock = OnceLock::new(); /// is bearer-gated by this middleware via [`QUERY_TOKEN_PATHS`] (header or /// `?token=`) so an unauthenticated upgrade is rejected with 401 before the /// WebSocket handshake; the handler adds an origin check on top (finding C4). +#[cfg(feature = "http-server")] const PUBLIC_PATHS: &[&str] = &[ "/", "/health", @@ -106,6 +121,7 @@ const PUBLIC_PATHS: &[&str] = &[ /// /// Use this only when the suffix is dynamic (path params). For exact paths, /// add to [`PUBLIC_PATHS`] instead. +#[cfg(feature = "http-server")] const PUBLIC_PATH_PREFIXES: &[&str] = &[ // AgentBox `GET /jobs/{job_id}` — `{job_id}` is a UUID per submission. "/jobs/", @@ -115,6 +131,7 @@ const PUBLIC_PATH_PREFIXES: &[&str] = &[ /// /// A path is public when it appears in [`PUBLIC_PATHS`] (exact match) or /// begins with any entry in [`PUBLIC_PATH_PREFIXES`] (prefix match). +#[cfg(feature = "http-server")] fn is_public_path(path: &str) -> bool { PUBLIC_PATHS.contains(&path) || PUBLIC_PATH_PREFIXES @@ -135,6 +152,7 @@ fn is_public_path(path: &str) -> bool { /// Add new entries here only for SSE / WebSocket routes whose clients cannot /// send headers and that carry per-user data. The follow-up approvals stream /// (#1339) is the next planned addition. +#[cfg(feature = "http-server")] const QUERY_TOKEN_PATHS: &[&str] = &["/events/webhooks", "/ws/dictation"]; /// Operator-supplied environment variable that carries the RPC bearer in @@ -279,6 +297,7 @@ pub fn verify_bearer_token(supplied: &str) -> bool { /// bypass this check. `/rpc` requires the exact per-launch bearer token that /// was written to `core.token` at startup; `/v1/*` additionally accepts a /// stable user-managed external API key. +#[cfg(feature = "http-server")] pub async fn rpc_auth_middleware(req: axum::extract::Request, next: Next) -> Response { let path = req.uri().path().to_string(); @@ -369,10 +388,12 @@ fn constant_time_eq(a: &str, b: &str) -> bool { (len_diff == 0) & (byte_diff == 0) } +#[cfg(feature = "http-server")] fn is_external_inference_path(path: &str) -> bool { path == "/v1" || path.starts_with("/v1/") } +#[cfg(feature = "http-server")] fn verify_external_inference_bearer_for_config(config: &Config, supplied: &str) -> bool { if supplied.trim().is_empty() { return false; @@ -389,6 +410,7 @@ fn verify_external_inference_bearer_for_config(config: &Config, supplied: &str) } } +#[cfg(feature = "http-server")] async fn verify_external_inference_bearer(supplied: &str) -> bool { if supplied.trim().is_empty() { return false; @@ -411,6 +433,7 @@ async fn verify_external_inference_bearer(supplied: &str) -> bool { /// value is empty after trimming. URL decoding is delegated to /// [`url::form_urlencoded`] so percent-encoded tokens decode the same way /// they were encoded by the FE via `encodeURIComponent`. +#[cfg(feature = "http-server")] fn extract_query_token(query: Option<&str>) -> Option { let query = query?; for (key, value) in url::form_urlencoded::parse(query.as_bytes()) { @@ -558,22 +581,26 @@ mod tests { ); } + #[cfg(feature = "http-server")] #[test] fn extract_query_token_returns_none_on_missing_query() { assert_eq!(extract_query_token(None), None); } + #[cfg(feature = "http-server")] #[test] fn extract_query_token_returns_none_when_key_absent() { assert_eq!(extract_query_token(Some("other=1&foo=bar")), None); } + #[cfg(feature = "http-server")] #[test] fn extract_query_token_returns_none_on_empty_value() { assert_eq!(extract_query_token(Some("token=")), None); assert_eq!(extract_query_token(Some("token=%20%20")), None); } + #[cfg(feature = "http-server")] #[test] fn extract_query_token_returns_first_value_on_duplicate_keys() { // Last-wins vs first-wins is a question the FE never hits; pin @@ -584,6 +611,7 @@ mod tests { ); } + #[cfg(feature = "http-server")] #[test] fn extract_query_token_url_decodes_value() { // `encodeURIComponent` on the FE may percent-encode a hex token @@ -594,11 +622,13 @@ mod tests { ); } + #[cfg(feature = "http-server")] #[test] fn public_paths_include_desktop_auth_callback() { assert!(PUBLIC_PATHS.contains(&"/auth")); } + #[cfg(feature = "http-server")] #[test] fn agentbox_run_and_jobs_paths_are_public() { // AgentBox marketplace surface bypasses bearer auth (gated externally @@ -625,6 +655,7 @@ mod tests { std::fs::remove_dir_all(&tmp).ok(); } + #[cfg(feature = "http-server")] #[test] fn is_external_inference_path_matches_only_v1_routes() { assert!(is_external_inference_path("/v1")); @@ -634,6 +665,7 @@ mod tests { assert!(!is_external_inference_path("/v10/models")); } + #[cfg(feature = "http-server")] #[test] fn verify_external_inference_bearer_for_config_accepts_stored_key() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/core/cli.rs b/src/core/cli.rs index 87e67be5c4..78fa79390d 100644 --- a/src/core/cli.rs +++ b/src/core/cli.rs @@ -113,6 +113,12 @@ pub fn run_from_cli_args(args: &[String]) -> Result<()> { /// alias) or baked into the binary at build time via `option_env!`. Absent a /// DSN, the command exits non-zero with a diagnostic instead of silently /// producing no telemetry. +/// +/// Only compiled with the `crash-reporting` feature; the `#[cfg(not(...))]` +/// companion below returns a disabled-build error (mirrors the `mcp` CLI +/// precedent, where the subcommand arm + top-level help stay compiled and the +/// handler reports the build fact rather than a bogus "unknown command"). +#[cfg(feature = "crash-reporting")] fn run_sentry_test_command(args: &[String]) -> Result<()> { let mut message: Option = None; let mut do_panic = false; @@ -197,6 +203,17 @@ fn run_sentry_test_command(args: &[String]) -> Result<()> { Ok(()) } +/// Disabled-build stand-in for [`run_sentry_test_command`]. Same signature as +/// the `crash-reporting` version; reports that the probe is unavailable in a +/// build compiled without the feature rather than pretending to succeed. +#[cfg(not(feature = "crash-reporting"))] +fn run_sentry_test_command(_args: &[String]) -> Result<()> { + Err(anyhow::anyhow!( + "sentry-test unavailable: built without the crash-reporting feature — \ + rebuild with `--features crash-reporting`" + )) +} + /// Loads key/value pairs from a `.env` file into the process environment. /// /// This is used for all CLI entrypoints so direct namespace commands pick up @@ -311,6 +328,7 @@ fn run_server_command(args: &[String]) -> Result<()> { let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .thread_stack_size(crate::core::runtime::AGENT_WORKER_STACK_BYTES) + .max_blocking_threads(crate::core::runtime::MAX_BLOCKING_THREADS) .build()?; rt.block_on(async { if headless_api { @@ -368,6 +386,7 @@ fn run_call_command(args: &[String]) -> Result<()> { let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .thread_stack_size(crate::core::runtime::AGENT_WORKER_STACK_BYTES) + .max_blocking_threads(crate::core::runtime::MAX_BLOCKING_THREADS) .build()?; let value = rt .block_on(async { invoke_method(default_state(), &method, params).await }) @@ -449,6 +468,7 @@ fn run_namespace_command( let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .thread_stack_size(crate::core::runtime::AGENT_WORKER_STACK_BYTES) + .max_blocking_threads(crate::core::runtime::MAX_BLOCKING_THREADS) .build()?; let value = rt .block_on(async { invoke_method(default_state(), &method, Value::Object(params)).await }) diff --git a/src/core/http_server_status.rs b/src/core/http_server_status.rs new file mode 100644 index 0000000000..1243f7b8e6 --- /dev/null +++ b/src/core/http_server_status.rs @@ -0,0 +1,54 @@ +//! Compile-time visibility into the `http-server` gate. +//! +//! Deliberately **ungated**: unlike the rest of the transport surface, this +//! module is compiled in both feature states, because its whole purpose is to +//! report which state the binary ended up in. It mirrors +//! [`crate::openhuman::voice::VOICE_COMPILED_IN`] and +//! [`crate::openhuman::inference::INFERENCE_COMPILED_IN`]. + +/// Whether the real HTTP + Socket.IO server transport was compiled into this +/// binary. +/// +/// Cargo features are per-crate and invisible to dependents' `#[cfg]`, so a +/// consumer that *requires* the transport (the desktop shell, which reaches the +/// core only over `http://127.0.0.1:/rpc`) has no other way to detect +/// that it silently got a slim build with no listener — exactly the class of +/// silent drop that shipped `voice` broken from v0.58.19 (#4901). +/// +/// The shell asserts this at compile time (`const _: () = assert!(...)` in +/// `app/src-tauri/src/lib.rs`), turning that silent runtime failure (every RPC +/// unreachable — the frontend can't talk to a core that never bound a socket) +/// into a build failure. When `false`, the direct `socketioxide` dependency is +/// dropped from the graph (verify with `cargo tree -i socketioxide`); `axum` +/// stays linked transitively via `tinychannels`, so only the gated HTTP + +/// Socket.IO transport surface — not `axum` itself — leaves the slim build. +pub const HTTP_SERVER_COMPILED_IN: bool = cfg!(feature = "http-server"); + +#[cfg(test)] +mod tests { + use super::HTTP_SERVER_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!(HTTP_SERVER_COMPILED_IN, cfg!(feature = "http-server")); + } + + /// The default build ships the HTTP transport; this is the state the + /// desktop app requires. Skipped when the slim build is under test. + #[test] + #[cfg(feature = "http-server")] + fn is_true_when_the_http_server_feature_is_on() { + assert!(HTTP_SERVER_COMPILED_IN); + } + + /// The slim build must report honestly, otherwise the shell's const assert + /// would pass against a listener-less core. + #[test] + #[cfg(not(feature = "http-server"))] + fn is_false_when_the_http_server_feature_is_off() { + assert!(!HTTP_SERVER_COMPILED_IN); + } +} diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index 4b4244eab4..5eb751c237 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -6,22 +6,48 @@ //! - SSE (Server-Sent Events) for real-time event streaming. //! - Helper routes for health checks, schema discovery, and Telegram authentication. +// Only the gated router build (`build_core_http_router`) uses the bare `Arc`; +// the kept dispatch/bootstrap paths qualify it (`std::sync::Arc`) or import it +// locally, so this module-level import is `http-server`-only (#5048). +#[cfg(feature = "http-server")] use std::sync::Arc; +// The axum server surface — router, handlers, middleware, extractors, SSE — is +// exclusive to the `http-server` feature (#5048). Only the RPC-dispatch surface +// (`invoke_method`, `parse_json_params`, `default_state`, the `run_server*` +// CoreBuilder shims, `register_domain_subscribers`, `bootstrap_core_runtime`) +// stays compiled; a slim build drives it over the CLI / native dispatch path +// without binding a listener. `Map`/`Value` stay ungated (dispatch uses them). +#[cfg(feature = "http-server")] use axum::extract::{DefaultBodyLimit, Query, State, WebSocketUpgrade}; +#[cfg(feature = "http-server")] use axum::http::{header, HeaderValue, Method, StatusCode}; +#[cfg(feature = "http-server")] use axum::middleware::{self, Next}; +#[cfg(feature = "http-server")] use axum::response::sse::{Event, KeepAlive, Sse}; +#[cfg(feature = "http-server")] use axum::response::{IntoResponse, Response}; +#[cfg(feature = "http-server")] use axum::routing::{get, post}; +#[cfg(feature = "http-server")] use axum::{extract::Request, Json, Router}; +#[cfg(feature = "http-server")] use serde::Serialize; -use serde_json::{json, Map, Value}; +#[cfg(feature = "http-server")] +use serde_json::json; +use serde_json::{Map, Value}; +#[cfg(feature = "http-server")] use tokio_stream::StreamExt; use tokio_util::sync::CancellationToken; use crate::core::all; -use crate::core::types::{AppState, RpcError, RpcFailure, RpcRequest, RpcSuccess}; +use crate::core::types::AppState; +// The JSON-RPC envelope types are only shaped into HTTP responses by the gated +// `rpc_handler`; the kept dispatch surface returns `Result`. +#[cfg(feature = "http-server")] +use crate::core::types::{RpcError, RpcFailure, RpcRequest, RpcSuccess}; +#[cfg(feature = "http-server")] use crate::rpc::StructuredRpcError; /// Axum handler for JSON-RPC POST requests. @@ -36,6 +62,7 @@ use crate::rpc::StructuredRpcError; /// /// * `state` - The application state, injected by Axum. /// * `req` - The parsed [`RpcRequest`]. +#[cfg(feature = "http-server")] pub async fn rpc_handler(State(state): State, Json(req): Json) -> Response { let id = req.id.clone(); let method = req.method.clone(); @@ -374,6 +401,7 @@ fn is_unconfirmed_unauthorized_error(msg: &str) -> bool { /// session-expired predicate uses `.contains()` because session-expired markers /// can appear mid-message — flip these to match and the test /// `is_param_validation_error_does_not_match_unrelated_errors` will break. +#[cfg(feature = "http-server")] fn is_param_validation_error(msg: &str) -> bool { msg.starts_with("unknown param '") || msg.starts_with("missing required param '") @@ -397,6 +425,7 @@ fn is_param_validation_error(msg: &str) -> bool { /// Matched against the shared wallet constant (exact equality) so a wording /// change in the wallet layer fails the coupling test in `jsonrpc_tests.rs` /// rather than silently letting the noise back into Sentry. +#[cfg(feature = "http-server")] fn is_wallet_not_configured_error(msg: &str) -> bool { msg == crate::openhuman::wallet::WALLET_NOT_CONFIGURED_MESSAGE } @@ -472,6 +501,7 @@ pub fn default_state() -> AppState { // --- HTTP server (Axum) ---------------------------------------------------- /// Query parameters for the Telegram authentication callback. +#[cfg(feature = "http-server")] #[derive(Debug, serde::Deserialize)] struct TelegramAuthQuery { /// The one-time login token received from the Telegram bot. @@ -479,6 +509,7 @@ struct TelegramAuthQuery { } /// Query parameters for the generic desktop auth callback. +#[cfg(feature = "http-server")] #[derive(Debug, serde::Deserialize)] struct DesktopAuthQuery { /// One-time login token consumed through the backend. @@ -488,6 +519,7 @@ struct DesktopAuthQuery { } /// Returns the HTML for a successful connection page. +#[cfg(feature = "http-server")] fn success_html(message: &str) -> String { let escaped_message = escape_html(message); r#" @@ -517,6 +549,7 @@ fn success_html(message: &str) -> String { } /// Simple HTML escaping for error messages. +#[cfg(feature = "http-server")] fn escape_html(s: &str) -> String { s.replace('&', "&") .replace('<', "<") @@ -526,6 +559,7 @@ fn escape_html(s: &str) -> String { } /// Returns the HTML for an error page. +#[cfg(feature = "http-server")] fn error_html(message: &str) -> String { let escaped_message = escape_html(message); format!( @@ -556,6 +590,7 @@ fn error_html(message: &str) -> String { } /// Query params for the MCP browser-OAuth callback (`/oauth/mcp/callback`). +#[cfg(feature = "http-server")] #[derive(Debug, serde::Deserialize)] struct OAuthMcpCallbackQuery { code: Option, @@ -568,6 +603,7 @@ struct OAuthMcpCallbackQuery { /// server redirects the browser here with `?code=…&state=…`; we hand it to /// `mcp_registry::oauth::complete`, which exchanges the code for a token, stores /// it as the server's `Authorization` header, and reconnects. +#[cfg(feature = "http-server")] async fn oauth_mcp_callback_handler( Query(query): Query, ) -> impl IntoResponse { @@ -647,6 +683,7 @@ async fn oauth_mcp_callback_handler( /// The preferred Tauri loopback listener has a per-login state nonce. This /// legacy core fallback cannot rely on that state, so it must reject embedded /// resource loads (``, iframe, fetch, script) before token exchange. +#[cfg(feature = "http-server")] fn desktop_callback_navigation_ok(headers: &axum::http::HeaderMap) -> Result<(), &'static str> { let get_str = |name: &str| -> Option<&str> { headers @@ -696,6 +733,7 @@ fn desktop_callback_navigation_ok(headers: &axum::http::HeaderMap) -> Result<(), /// iframe embeds from malicious pages). /// - `Sec-Fetch-Site: cross-site` with a `Referer`/`Origin` that is not /// `https://t.me/...` (CSRF redirect from a third-party site). +#[cfg(feature = "http-server")] fn telegram_callback_origin_ok(headers: &axum::http::HeaderMap) -> Result<(), &'static str> { let get_str = |name: &str| -> Option<&str> { headers @@ -756,6 +794,7 @@ fn telegram_callback_origin_ok(headers: &axum::http::HeaderMap) -> Result<(), &' /// /// It consumes a one-time token, exchanges it for a JWT from the backend, /// and stores the session locally. +#[cfg(feature = "http-server")] async fn telegram_auth_handler( headers: axum::http::HeaderMap, Query(query): Query, @@ -890,6 +929,7 @@ async fn telegram_auth_handler( /// flows can fall back to the local core callback (`/auth`). This route is /// public because the callback carries its own one-time login token; raw /// session JWT callbacks are intentionally rejected on this public surface. +#[cfg(feature = "http-server")] async fn desktop_auth_handler( headers: axum::http::HeaderMap, Query(query): Query, @@ -1010,6 +1050,7 @@ async fn desktop_auth_handler( /// the FE forwards the per-process core bearer as a `?token=…` query param — /// validated against the same in-process RPC token via [`verify_bearer_token`] /// (single source of truth, no separate credential). +#[cfg(feature = "http-server")] #[derive(Debug, serde::Deserialize)] struct DictationQuery { #[serde(default)] @@ -1024,6 +1065,7 @@ struct DictationQuery { /// set headers), and — when an `Origin` header is present — that origin must be /// on the local-app allowlist, mirroring the Socket.IO handshake check. Missing /// or wrong credentials are rejected with 401 and the socket is never upgraded. +#[cfg(feature = "http-server")] async fn dictation_ws_handler( headers: axum::http::HeaderMap, Query(query): Query, @@ -1100,6 +1142,7 @@ async fn dictation_ws_handler( /// maximum image payload — 4 × 8 MiB raw ≈ 43 MiB once base64-encoded into /// `[IMAGE:data:…]` markers — plus message text and JSON-RPC envelope overhead. /// Axum's 2 MiB default would otherwise reject any image attachment (#3205). +#[cfg(feature = "http-server")] const MAX_RPC_BODY_BYTES: usize = 64 * 1024 * 1024; /// Builds the main Axum router for the core HTTP server. @@ -1111,6 +1154,7 @@ const MAX_RPC_BODY_BYTES: usize = 64 * 1024 * 1024; /// 1. `cors_middleware` — handles `OPTIONS` preflight and adds CORS headers /// 2. `rpc_auth_middleware` — validates `Authorization: Bearer ` on protected paths /// 3. `http_request_log_middleware` — logs non-RPC HTTP requests with timing +#[cfg(feature = "http-server")] pub fn build_core_http_router(socketio_enabled: bool) -> Router { let mut router = Router::new() .route("/", get(root_handler)) @@ -1202,6 +1246,7 @@ pub fn build_core_http_router(socketio_enabled: bool) -> Router { /// /// The `/rpc` path is logged inside [`rpc_handler`] instead (with the /// JSON-RPC method name), so we skip it here to avoid a redundant line. +#[cfg(feature = "http-server")] async fn http_request_log_middleware(req: Request, next: Next) -> Response { let method = req.method().clone(); let path = req.uri().path().to_string(); @@ -1229,6 +1274,7 @@ async fn http_request_log_middleware(req: Request, next: Next) -> Response { /// Environment variable for additional comma-separated origins to allow. /// Intended for debug harnesses and E2E setups that don't run on loopback — /// e.g. `OPENHUMAN_CORE_ALLOWED_ORIGINS=https://e2e.internal,http://my-debugger:8080`. +#[cfg(feature = "http-server")] const ALLOWED_ORIGINS_ENV: &str = "OPENHUMAN_CORE_ALLOWED_ORIGINS"; /// Decides whether a browser `Origin` header value is allowed to make @@ -1245,11 +1291,13 @@ const ALLOWED_ORIGINS_ENV: &str = "OPENHUMAN_CORE_ALLOWED_ORIGINS"; /// token via leaked logs / screenshots / a compromised third-party origin /// loaded in a CEF child webview) must be refused — the bearer token alone /// is not enough authorization without an origin binding. +#[cfg(feature = "http-server")] pub(super) fn is_origin_allowed(origin: &str) -> bool { let extra_origins = std::env::var(ALLOWED_ORIGINS_ENV).ok(); is_origin_allowed_with_extra(origin, extra_origins.as_deref()) } +#[cfg(feature = "http-server")] pub(super) fn is_origin_allowed_with_extra(origin: &str, extra_origins: Option<&str>) -> bool { // Tauri v2 webview origins. Windows uses an HTTP(S) custom host; macOS // and Linux use the `tauri://` scheme. We accept both for portability. @@ -1290,6 +1338,7 @@ pub(super) fn is_origin_allowed_with_extra(origin: &str, extra_origins: Option<& /// /// Reads the request's `Origin` header before invoking the inner handler so /// the same value can be echoed back (when allowed) on the response. +#[cfg(feature = "http-server")] async fn cors_middleware(req: Request, next: Next) -> Response { let origin = req .headers() @@ -1317,6 +1366,7 @@ async fn cors_middleware(req: Request, next: Next) -> Response { /// For Docker / cloud deployments where the server binds to `0.0.0.0`, /// extend the allowlist via the `OPENHUMAN_CORE_ALLOWED_ORIGINS` env var /// (comma-separated) rather than wildcarding `Access-Control-Allow-Origin`. +#[cfg(feature = "http-server")] pub(super) fn with_cors_headers(mut response: Response, origin: Option<&str>) -> Response { let headers = response.headers_mut(); headers.append(header::VARY, HeaderValue::from_static("Origin")); @@ -1354,6 +1404,7 @@ pub(super) fn with_cors_headers(mut response: Response, origin: Option<&str>) -> /// `health::CRITICAL_COMPONENTS`); otherwise it returns 200 — with a `degraded` /// flag and per-component buckets in the body so readiness probes and operators /// can still see partial failures. +#[cfg(feature = "http-server")] async fn health_handler() -> impl IntoResponse { let snapshot = crate::openhuman::health::snapshot(); let verdict = crate::openhuman::health::verdict(&snapshot); @@ -1394,6 +1445,7 @@ async fn health_handler() -> impl IntoResponse { } /// Handler for the schema discovery endpoint. +#[cfg(feature = "http-server")] async fn schema_handler(State(_state): State) -> impl IntoResponse { (StatusCode::OK, Json(build_http_schema_dump())).into_response() } @@ -1405,6 +1457,7 @@ async fn schema_handler(State(_state): State) -> impl IntoResponse { /// Both are required — browser `EventSource` cannot attach an /// `Authorization` header, so the bind token is the only credential the /// endpoint accepts. +#[cfg(feature = "http-server")] #[derive(Debug, serde::Deserialize)] struct EventsQuery { client_id: String, @@ -1426,6 +1479,7 @@ struct EventsQuery { /// /// Both paths converge on the same broadcast stream filtered by /// `client_id`. +#[cfg(feature = "http-server")] async fn events_handler( headers: axum::http::HeaderMap, Query(query): Query, @@ -1503,6 +1557,7 @@ async fn events_handler( } /// Handler for the webhook debug events SSE endpoint. +#[cfg(feature = "http-server")] async fn webhook_events_handler() -> Response { let stream = tokio_stream::once(Ok::( Event::default() @@ -1518,6 +1573,7 @@ async fn webhook_events_handler() -> Response { /// /// Requires bearer auth. Streams all domain events as JSON with event type /// set to the domain name (agent, tool, memory, etc.). +#[cfg(feature = "http-server")] async fn domain_events_handler(headers: axum::http::HeaderMap) -> Response { let bearer = headers .get(header::AUTHORIZATION) @@ -1610,6 +1666,7 @@ async fn domain_events_handler(headers: axum::http::HeaderMap) -> Response { } /// Handler for the root endpoint, returning server information and available endpoints. +#[cfg(feature = "http-server")] async fn root_handler() -> impl IntoResponse { let api_server = match crate::openhuman::config::Config::load_or_init().await { Ok(cfg) => crate::api::config::effective_backend_api_url(&cfg.api_url), @@ -1640,6 +1697,7 @@ async fn root_handler() -> impl IntoResponse { } /// Fallback handler for unknown routes. +#[cfg(feature = "http-server")] async fn not_found_handler() -> impl IntoResponse { ( StatusCode::NOT_FOUND, @@ -1652,6 +1710,7 @@ async fn not_found_handler() -> impl IntoResponse { } /// Resolves the port for the core server from environment variables or defaults. +#[cfg(feature = "http-server")] pub(crate) fn core_port() -> u16 { std::env::var("OPENHUMAN_CORE_PORT") .ok() @@ -1660,6 +1719,7 @@ pub(crate) fn core_port() -> u16 { } /// Resolves the bind address host for the core server from environment variables or defaults. +#[cfg(feature = "http-server")] pub(crate) fn core_host() -> String { std::env::var("OPENHUMAN_CORE_HOST") .ok() @@ -2481,6 +2541,7 @@ pub fn start_core_runtime_services( } /// JSON-serializable wrapper for the entire RPC schema dump. +#[cfg(feature = "http-server")] #[derive(Serialize)] struct HttpSchemaDump { /// List of all available RPC methods and their schemas. @@ -2488,6 +2549,7 @@ struct HttpSchemaDump { } /// JSON-serializable schema for a single RPC method. +#[cfg(feature = "http-server")] #[derive(Serialize)] struct HttpMethodSchema { /// Fully qualified JSON-RPC method name. @@ -2507,6 +2569,7 @@ struct HttpMethodSchema { /// Aggregates schemas from all registered controllers into a single dump. /// /// Also includes built-in core methods like `core.ping` and `core.version`. +#[cfg(feature = "http-server")] fn build_http_schema_dump() -> HttpSchemaDump { let mut methods: Vec = all::all_http_method_schemas() .into_iter() @@ -2530,6 +2593,8 @@ fn build_http_schema_dump() -> HttpSchemaDump { #[path = "jsonrpc_tests.rs"] mod tests; -#[cfg(test)] +// Every cors test names a gated CORS symbol (`is_origin_allowed`, +// `with_cors_headers`), so the whole module gates in lockstep (#5048). +#[cfg(all(test, feature = "http-server"))] #[path = "jsonrpc_cors_tests.rs"] mod cors_tests; diff --git a/src/core/jsonrpc_tests.rs b/src/core/jsonrpc_tests.rs index 50198a0321..0de528d68a 100644 --- a/src/core/jsonrpc_tests.rs +++ b/src/core/jsonrpc_tests.rs @@ -6,9 +6,16 @@ use std::time::Duration; use tokio_util::sync::CancellationToken; use super::{ - build_http_schema_dump, default_state, escape_html, invoke_method, is_param_validation_error, - is_session_expired_error, is_unconfirmed_unauthorized_error, is_wallet_not_configured_error, - params_to_object, parse_json_params, rpc_handler, type_name, DomainSubscriberPlan, + default_state, invoke_method, is_session_expired_error, is_unconfirmed_unauthorized_error, + params_to_object, parse_json_params, type_name, DomainSubscriberPlan, +}; +// These are the `http-server`-gated RPC-surface symbols (#5048); the tests that +// name them below carry the same `#[cfg]` so the disabled-build test compile +// (`cargo test --no-default-features`) stays green. +#[cfg(feature = "http-server")] +use super::{ + build_http_schema_dump, escape_html, is_param_validation_error, is_wallet_not_configured_error, + rpc_handler, }; // ---- domain-subscriber gating (#4796 DoD item 3) ---------------------------- @@ -476,6 +483,7 @@ async fn invoke_migrate_hermes_rejects_unknown_param() { } #[test] +#[cfg(feature = "http-server")] fn http_schema_dump_includes_openhuman_and_core_methods() { let dump = build_http_schema_dump(); let methods = dump.methods; @@ -681,6 +689,7 @@ async fn team_revoke_invite_missing_invite_id_fails_validation() { } #[tokio::test] +#[cfg(feature = "http-server")] async fn schema_dump_includes_new_billing_and_team_methods() { let dump = build_http_schema_dump(); let methods: Vec<&str> = dump.methods.iter().map(|m| m.method.as_str()).collect(); @@ -954,6 +963,7 @@ fn is_session_expired_error_skips_discord_rewrap_for_2285() { } #[test] +#[cfg(feature = "http-server")] fn is_param_validation_error_matches_the_three_validator_shapes() { // Regression guard for OPENHUMAN-TAURI-20: pre-#1467 cores rejected // `api_key` because it wasn't in the schema yet. The error string @@ -973,6 +983,7 @@ fn is_param_validation_error_matches_the_three_validator_shapes() { } #[test] +#[cfg(feature = "http-server")] fn is_param_validation_error_does_not_match_unrelated_errors() { // Handler-side / network / auth failures must still be reported. assert!(!is_param_validation_error( @@ -1009,6 +1020,7 @@ fn is_session_expired_error_matches_missing_backend_session_token() { } #[tokio::test(flavor = "current_thread")] +#[cfg(feature = "http-server")] async fn structured_rpc_error_envelope_passes_through_generic_dispatch() { // The transport layer must surface any controller-emitted // `StructuredRpcError` payload without inspecting the method name — @@ -1048,7 +1060,9 @@ async fn structured_rpc_error_envelope_passes_through_generic_dispatch() { assert!(message.contains("thread-ghost")); } +#[cfg(feature = "crash-reporting")] #[tokio::test(flavor = "current_thread")] +#[cfg(feature = "http-server")] async fn thread_not_found_rpc_error_does_not_report_to_sentry() { use axum::body::to_bytes; use axum::extract::State; @@ -1161,7 +1175,9 @@ async fn thread_not_found_rpc_error_does_not_report_to_sentry() { ); } +#[cfg(feature = "crash-reporting")] #[tokio::test(flavor = "current_thread")] +#[cfg(feature = "http-server")] async fn unknown_method_severity_split_by_probe_allow_list() { // #3567: prove the full severity split at the transport boundary — // (1) an allow-listed probe name is NOT captured to Sentry (debug-only), @@ -1289,6 +1305,7 @@ fn is_session_expired_error_matches_session_jwt_required() { } #[test] +#[cfg(feature = "http-server")] fn escape_html_escapes_all_special_chars() { let raw = r#""#; let escaped = escape_html(raw); @@ -1305,6 +1322,7 @@ fn escape_html_escapes_all_special_chars() { } #[test] +#[cfg(feature = "http-server")] fn escape_html_is_noop_for_safe_text() { assert_eq!(escape_html("safe text 123"), "safe text 123"); assert_eq!(escape_html(""), ""); @@ -1312,6 +1330,7 @@ fn escape_html_is_noop_for_safe_text() { // --- telegram callback fetch-metadata gate -------------------------------- +#[cfg(feature = "http-server")] fn hdr_map(pairs: &[(&str, &str)]) -> axum::http::HeaderMap { let mut m = axum::http::HeaderMap::new(); for (k, v) in pairs { @@ -1324,6 +1343,7 @@ fn hdr_map(pairs: &[(&str, &str)]) -> axum::http::HeaderMap { } #[test] +#[cfg(feature = "http-server")] fn telegram_callback_origin_ok_accepts_no_metadata_headers() { // Older browsers and CLI clients (curl) send neither Sec-Fetch-* nor // Origin/Referer. The legacy flow has to keep working — reject only @@ -1333,6 +1353,7 @@ fn telegram_callback_origin_ok_accepts_no_metadata_headers() { } #[test] +#[cfg(feature = "http-server")] fn telegram_callback_origin_ok_accepts_legit_top_nav_from_telegram() { let headers = hdr_map(&[ ("sec-fetch-mode", "navigate"), @@ -1344,6 +1365,7 @@ fn telegram_callback_origin_ok_accepts_legit_top_nav_from_telegram() { } #[test] +#[cfg(feature = "http-server")] fn telegram_callback_origin_ok_accepts_same_origin_local_nav() { let headers = hdr_map(&[ ("sec-fetch-mode", "navigate"), @@ -1354,6 +1376,7 @@ fn telegram_callback_origin_ok_accepts_same_origin_local_nav() { } #[test] +#[cfg(feature = "http-server")] fn telegram_callback_origin_ok_rejects_image_embed() { let headers = hdr_map(&[ ("sec-fetch-mode", "no-cors"), @@ -1364,6 +1387,7 @@ fn telegram_callback_origin_ok_rejects_image_embed() { } #[test] +#[cfg(feature = "http-server")] fn telegram_callback_origin_ok_rejects_iframe_embed() { let headers = hdr_map(&[ ("sec-fetch-mode", "navigate"), @@ -1374,6 +1398,7 @@ fn telegram_callback_origin_ok_rejects_iframe_embed() { } #[test] +#[cfg(feature = "http-server")] fn telegram_callback_origin_ok_rejects_cross_site_from_non_telegram() { let headers = hdr_map(&[ ("sec-fetch-mode", "navigate"), @@ -1385,12 +1410,14 @@ fn telegram_callback_origin_ok_rejects_cross_site_from_non_telegram() { } #[test] +#[cfg(feature = "http-server")] fn telegram_callback_origin_ok_rejects_non_telegram_referer_without_fetch_metadata() { let headers = hdr_map(&[("referer", "https://attacker.example/post")]); assert!(super::telegram_callback_origin_ok(&headers).is_err()); } #[test] +#[cfg(feature = "http-server")] fn telegram_callback_origin_ok_rejects_localhost_host_prefix_decoy() { // Regression: prefix-matching the referer accepted hostnames like // `http://localhost.attacker.example/...`. With exact-host parsing @@ -1472,6 +1499,7 @@ async fn invoke_method_core_version_via_tier1_reflects_state() { } #[tokio::test] +#[cfg(feature = "http-server")] async fn test_http_health_handler_returns_correct_status() { use axum::body::to_bytes; use axum::http::StatusCode; @@ -1533,6 +1561,7 @@ async fn test_http_health_handler_returns_correct_status() { } #[tokio::test] +#[cfg(feature = "http-server")] async fn desktop_auth_rejects_deprecated_direct_session_token_marker() { use axum::body::to_bytes; use axum::extract::Query; @@ -1560,6 +1589,7 @@ async fn desktop_auth_rejects_deprecated_direct_session_token_marker() { } #[tokio::test] +#[cfg(feature = "http-server")] async fn desktop_auth_rejects_embedded_fetch_metadata() { use axum::body::to_bytes; use axum::extract::Query; @@ -1590,6 +1620,7 @@ async fn desktop_auth_rejects_embedded_fetch_metadata() { } #[test] +#[cfg(feature = "http-server")] fn is_wallet_not_configured_error_matches_wallet_constant() { // The classifier keys off the wallet layer's exact "not configured" // message so a wallet-less user's tinyplace RPC stays out of Sentry. @@ -1599,6 +1630,7 @@ fn is_wallet_not_configured_error_matches_wallet_constant() { } #[test] +#[cfg(feature = "http-server")] fn is_wallet_not_configured_error_is_coupled_to_the_wallet_constant() { // Drift guard: if the wallet wording changes without updating the shared // constant the classifier matches, this fails — preventing the noise from @@ -1610,6 +1642,7 @@ fn is_wallet_not_configured_error_is_coupled_to_the_wallet_constant() { } #[test] +#[cfg(feature = "http-server")] fn is_wallet_not_configured_error_does_not_match_other_errors() { // Other wallet/seed-derivation failures (decrypt, key derivation, locked // keychain) are real defects and must keep reaching Sentry. diff --git a/src/core/log_redaction.rs b/src/core/log_redaction.rs new file mode 100644 index 0000000000..48522a6b40 --- /dev/null +++ b/src/core/log_redaction.rs @@ -0,0 +1,107 @@ +//! Shared secret-scrubbing for anything written to stderr / file logs. +//! +//! Diagnostic log lines (e.g. `core::observability::report_error_message`) can +//! carry error strings that embed bearer tokens, API keys, or other secrets. In +//! slim builds compiled without `crash-reporting` there is no Sentry +//! `before_send` hook to sanitise them, and even in full builds the +//! `before_send` hook only scrubs the *Sentry event* — not the parallel +//! `tracing` log line. This module owns the one redaction pass used by both the +//! Sentry path (`src/main.rs`) and the always-on log path, so the patterns +//! cannot drift between them. Always compiled (no feature gate). + +use once_cell::sync::Lazy; +use regex::Regex; + +static SECRET_PATTERNS: Lazy> = Lazy::new(|| { + vec![ + // Matches "Bearer " and redacts the token. + (Regex::new(r"(?i)(bearer\s+)\S+").unwrap(), "${1}[REDACTED]"), + // Matches "api-key: " or "api_key=" and redacts the key. + ( + Regex::new(r"(?i)(api[_-]?key[=:\s]+)\S+").unwrap(), + "${1}[REDACTED]", + ), + // \b anchor prevents matching `cancellation_token=` etc. + ( + Regex::new(r"(?i)\b(token[=:\s]+)\S+").unwrap(), + "${1}[REDACTED]", + ), + // Anthropic keys (sk-ant-api03-...) contain hyphens the generic + // sk- pattern below won't match. + ( + Regex::new(r"sk-ant-[A-Za-z0-9\-_]{16,}").unwrap(), + "[REDACTED]", + ), + // OpenAI admin keys (sk-admin-...). + ( + Regex::new(r"sk-admin-[A-Za-z0-9\-_]{12,}").unwrap(), + "[REDACTED]", + ), + // OpenAI project-scoped and org-scoped keys (sk-proj-... / sk-org-...). + ( + Regex::new(r"sk-(?:proj|org)-[A-Za-z0-9\-_]{12,}").unwrap(), + "[REDACTED]", + ), + // Generic catch-all for any sk- format not covered above. Includes `-` + // and `_` in the suffix so a separator mid-token can't leave a trailing + // fragment unredacted (e.g. `sk-…_uv` → `[REDACTED]_uv`). + (Regex::new(r"sk-[A-Za-z0-9_-]{20,}").unwrap(), "[REDACTED]"), + ] +}); + +/// Replace substrings that look like secrets with `[REDACTED]`. +/// +/// Intended for anything about to be written to a log sink or an error report; +/// it redacts the secret-looking span in place and leaves the rest of the +/// diagnostic message intact (unlike a whole-value prefix redaction). +pub fn scrub_secrets(input: &str) -> String { + let mut result = input.to_string(); + for (re, replacement) in SECRET_PATTERNS.iter() { + result = re.replace_all(&result, *replacement).into_owned(); + } + result +} + +#[cfg(test)] +mod tests { + use super::scrub_secrets; + + #[test] + fn scrubs_bearer_token() { + assert_eq!( + scrub_secrets("Authorization: Bearer abc123xyz"), + "Authorization: Bearer [REDACTED]" + ); + } + + #[test] + fn scrubs_api_key_assignment() { + assert_eq!(scrub_secrets("api_key=sk-abc123"), "api_key=[REDACTED]"); + } + + #[test] + fn scrubs_anthropic_key() { + assert_eq!( + scrub_secrets("key: sk-ant-api03-abcdefghijklmnop"), + "key: [REDACTED]" + ); + } + + #[test] + fn scrubs_bare_generic_sk_key() { + assert_eq!(scrub_secrets("sk-abcdefghijklmnopqrstuvwx"), "[REDACTED]"); + } + + #[test] + fn scrubs_generic_sk_key_with_separators() { + // A `_` or `-` mid-suffix must not leave a trailing fragment unredacted. + assert_eq!(scrub_secrets("sk-abcdefghijklmnopqrst_uv"), "[REDACTED]"); + assert_eq!(scrub_secrets("sk-abcdefghij-klmnopqrst_uv"), "[REDACTED]"); + } + + #[test] + fn leaves_plain_diagnostics_intact() { + let msg = "profile 42: derived rate clamp exceeded (max_iterations=8)"; + assert_eq!(scrub_secrets(msg), msg); + } +} diff --git a/src/core/logging.rs b/src/core/logging.rs index 639d247aa7..fe26d0ccb4 100644 --- a/src/core/logging.rs +++ b/src/core/logging.rs @@ -472,6 +472,7 @@ fn build_env_filter(verbose: bool, default_scope: CliLogDefault) -> tracing_subs }) } +#[cfg(feature = "crash-reporting")] fn sentry_tracing_layer() -> impl Layer where S: tracing::Subscriber + for<'a> LookupSpan<'a>, @@ -492,6 +493,18 @@ where }) } +/// Sentry-free build: the Sentry breadcrumb/event bridge collapses to a no-op +/// `Identity` layer so the two `.with(sentry_tracing_layer())` call sites keep +/// compiling unchanged (they add a layer that does nothing). Same signature as +/// the `crash-reporting` version above. +#[cfg(not(feature = "crash-reporting"))] +fn sentry_tracing_layer() -> impl Layer +where + S: tracing::Subscriber + for<'a> LookupSpan<'a>, +{ + tracing_subscriber::layer::Identity::new() +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/core/mod.rs b/src/core/mod.rs index 52c448e4a7..3b7b030536 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -14,8 +14,13 @@ pub mod cli; pub mod dispatch; pub mod event_bind_tokens; pub mod event_bus; +// Ungated compile-time marker for the `http-server` gate (#5048) — the desktop +// shell asserts `HTTP_SERVER_COMPILED_IN` so a listener-less core fails the +// build instead of shipping silently (cf. voice #4901). +pub mod http_server_status; pub mod jsonrpc; pub mod legacy_aliases; +pub mod log_redaction; pub mod logging; pub mod memory_cli; pub mod observability; diff --git a/src/core/observability.rs b/src/core/observability.rs index 1ae514c17d..1f3ff24d33 100644 --- a/src/core/observability.rs +++ b/src/core/observability.rs @@ -2394,6 +2394,17 @@ pub(crate) fn report_error_message( operation: &str, extra: &[Tag<'_>], ) { + // Redact secret-looking spans (bearer tokens, API keys, `sk-` keys) before + // `message` reaches any log sink or Sentry event. The parallel `tracing` + // log line below is emitted in every build — including slim builds with no + // `crash-reporting` `before_send` hook — so scrub once, up front. + let scrubbed = crate::core::log_redaction::scrub_secrets(message); + let message = scrubbed.as_str(); + // Sentry-touching behaviour is gated behind `crash-reporting`. The + // diagnostic `tracing::error!` stays compiled in both builds (see the + // `#[cfg(not(...))]` companion below) so stderr / file appenders keep the + // record even in a sentry-free build. + #[cfg(feature = "crash-reporting")] sentry::with_scope( |scope| { scope.set_tag("domain", domain); @@ -2419,6 +2430,20 @@ pub(crate) fn report_error_message( ); }, ); + #[cfg(not(feature = "crash-reporting"))] + { + // Sentry compiled out: `extra` tags have no scope to attach to, so + // discard them explicitly to avoid an unused-variable warning while + // still emitting the diagnostic log line. + let _ = extra; + tracing::error!( + target: REPORT_ERROR_TRACING_TARGET, + domain = domain, + operation = operation, + error = %message, + "[observability] {domain}.{operation} failed: {message}" + ); + } } /// Capture a message to Sentry at **warning** severity with structured tags. @@ -2436,12 +2461,24 @@ pub(crate) fn report_error_message( /// `sentry::capture_message` rather than the `sentry-tracing` bridge; the /// accompanying diagnostic line is tagged with [`REPORT_ERROR_TRACING_TARGET`] /// so the production layer ignores it and we never double-report. +// Its sole caller is the `http-server`-gated RPC handler (unrecognised-method +// reporting, #3567), so it has no caller in a slim build (#5048). Kept compiled +// for the crash-reporting carve-out; the allow keeps the disabled build quiet. +#[cfg_attr(not(feature = "http-server"), allow(dead_code))] pub(crate) fn report_warning_message( message: &str, domain: &str, operation: &str, extra: &[Tag<'_>], ) { + // Redact secret-looking spans before `message` reaches any log sink or + // Sentry event — see the note in `report_error_message`. + let scrubbed = crate::core::log_redaction::scrub_secrets(message); + let message = scrubbed.as_str(); + // Sentry-touching behaviour is gated behind `crash-reporting`; the + // diagnostic `tracing::warn!` stays compiled in both builds (see the + // `#[cfg(not(...))]` companion below). + #[cfg(feature = "crash-reporting")] sentry::with_scope( |scope| { scope.set_tag("domain", domain); @@ -2461,6 +2498,17 @@ pub(crate) fn report_warning_message( ); }, ); + #[cfg(not(feature = "crash-reporting"))] + { + let _ = extra; + tracing::warn!( + target: REPORT_ERROR_TRACING_TARGET, + domain = domain, + operation = operation, + message = %message, + "[observability] {domain}.{operation} warning: {message}" + ); + } } /// Returns true when a Sentry event is a per-attempt provider HTTP failure @@ -2480,6 +2528,7 @@ pub(crate) fn report_warning_message( /// for its own reasons doesn't get silently dropped /// - tag `failure == "non_2xx"` (the marker set by `ops::api_error`) /// - tag `status` parses to one of [`TRANSIENT_PROVIDER_HTTP_STATUSES`] +#[cfg(feature = "crash-reporting")] pub fn is_transient_provider_http_failure(event: &sentry::protocol::Event<'_>) -> bool { let tags = &event.tags; if tags.get("domain").map(String::as_str) != Some("llm_provider") { @@ -2507,6 +2556,7 @@ pub fn is_transient_provider_http_failure(event: &sentry::protocol::Event<'_>) - /// single-source [`crate::openhuman::inference::provider::managed_error_skips_sentry`] /// (managed-envelope gated, so a BYO payload carrying an `errorCode`-shaped /// field is not wrongly dropped) so the layers can't drift. +#[cfg(feature = "crash-reporting")] pub fn is_backend_error_code_event(event: &sentry::protocol::Event<'_>) -> bool { let direct = event.message.as_deref(); let from_logentry = event.logentry.as_ref().map(|log| log.message.as_str()); @@ -2534,6 +2584,7 @@ pub fn is_backend_error_code_event(event: &sentry::protocol::Event<'_>) -> bool /// suppressed. A non-streaming `domain=llm_provider, failure=transport` event /// carries a different `operation` tag and must keep paging, so the /// observability blind spot stays as narrow as F7 intends. +#[cfg(feature = "crash-reporting")] pub fn is_transient_provider_transport_failure(event: &sentry::protocol::Event<'_>) -> bool { let tags = &event.tags; if tags.get("domain").map(String::as_str) != Some("llm_provider") { @@ -2559,6 +2610,7 @@ pub fn is_transient_provider_transport_failure(event: &sentry::protocol::Event<' /// where the aggregate body starts with the reliable-provider exhaustion /// prefix and contains transient HTTP/transport wording already classified by /// [`is_transient_message_failure`]. +#[cfg(feature = "crash-reporting")] pub fn is_all_transient_provider_exhaustion_event(event: &sentry::protocol::Event<'_>) -> bool { let tags = &event.tags; if tags.get("domain").map(String::as_str) != Some("llm_provider") { @@ -2577,6 +2629,7 @@ pub fn is_all_transient_provider_exhaustion_event(event: &sentry::protocol::Even .any(all_provider_attempts_are_transient) } +#[cfg(feature = "crash-reporting")] fn all_provider_attempts_are_transient(message: &str) -> bool { let Some(attempts) = message.strip_prefix("All providers/models failed. Attempts:") else { return false; @@ -2610,6 +2663,7 @@ fn all_provider_attempts_are_transient(message: &str) -> bool { /// the last exception's `value` (the shape `sentry-tracing` produces when /// stacktraces are attached). Both fields are checked for the canonical /// prefix so the filter stays robust to future Sentry plumbing changes. +#[cfg(feature = "crash-reporting")] pub fn is_max_iterations_event(event: &sentry::protocol::Event<'_>) -> bool { let direct = event.message.as_deref(); let from_exception = event.exception.last().and_then(|e| e.value.as_deref()); @@ -2633,6 +2687,7 @@ pub fn is_max_iterations_event(event: &sentry::protocol::Event<'_>) -> bool { /// Scope: only the three domains that surface session-expired today /// (`llm_provider`, `backend_api`, `rpc`). Composio's OAuth-state 401 /// is excluded — that's actionable and must reach Sentry. +#[cfg(feature = "crash-reporting")] pub fn is_session_expired_event(event: &sentry::protocol::Event<'_>) -> bool { let tags = &event.tags; let Some(domain) = tags.get("domain").map(String::as_str) else { @@ -2695,6 +2750,7 @@ pub fn is_session_expired_event(event: &sentry::protocol::Event<'_>) -> bool { /// - `event.message` (or last exception `value`) trims to **exactly** /// `"GET /auth/me"` — strict equality, not `contains`, so a body with /// the chain appended still surfaces. +#[cfg(feature = "crash-reporting")] pub fn is_auth_get_me_opaque_transport_event(event: &sentry::protocol::Event<'_>) -> bool { let tags = &event.tags; if tags.get("domain").map(String::as_str) != Some("rpc") { @@ -2743,6 +2799,7 @@ pub fn is_updater_transient_message(message: &str) -> bool { .any(|phrase| lower.contains(phrase)) } +#[cfg(feature = "crash-reporting")] fn event_has_transient_transport_phrase(event: &sentry::protocol::Event<'_>) -> bool { event .message @@ -2760,6 +2817,7 @@ fn event_has_transient_transport_phrase(event: &sentry::protocol::Event<'_>) -> }) } +#[cfg(feature = "crash-reporting")] fn event_has_updater_transient_message(event: &sentry::protocol::Event<'_>) -> bool { event .message @@ -2777,6 +2835,7 @@ fn event_has_updater_transient_message(event: &sentry::protocol::Event<'_>) -> b }) } +#[cfg(feature = "crash-reporting")] fn event_has_updater_domain(event: &sentry::protocol::Event<'_>) -> bool { matches!( event.tags.get("domain").map(String::as_str), @@ -2784,6 +2843,7 @@ fn event_has_updater_domain(event: &sentry::protocol::Event<'_>) -> bool { ) } +#[cfg(feature = "crash-reporting")] fn is_transient_domain_failure(event: &sentry::protocol::Event<'_>, domain: &str) -> bool { let tags = &event.tags; if tags.get("domain").map(String::as_str) != Some(domain) { @@ -2801,6 +2861,7 @@ fn is_transient_domain_failure(event: &sentry::protocol::Event<'_>, domain: &str /// Transient backend API failures (gateway hiccups, scheduled downtime). /// Match by event tags written by report_error at the authed_json call site. +#[cfg(feature = "crash-reporting")] pub fn is_transient_backend_api_failure(event: &sentry::protocol::Event<'_>) -> bool { is_transient_domain_failure(event, "backend_api") } @@ -2811,6 +2872,7 @@ pub fn is_transient_backend_api_failure(event: &sentry::protocol::Event<'_>) -> /// path is missing, private, or otherwise unavailable to that user. The install /// RPC still returns the error so the UI can surface it, but Sentry should keep /// reporting server-side and transport failures only. +#[cfg(feature = "crash-reporting")] pub fn is_skill_install_user_fetch_failure(event: &sentry::protocol::Event<'_>) -> bool { let tags = &event.tags; if tags.get("domain").map(String::as_str) != Some("skills") { @@ -2841,6 +2903,7 @@ pub fn is_skill_install_user_fetch_failure(event: &sentry::protocol::Event<'_>) /// would otherwise escape the integrations-scoped filter (OPENHUMAN-TAURI-35 /// ~139ev, -2H ~26ev: `[composio] list_connections failed: Backend returned /// 502 …` events that landed in Sentry under `domain=composio`). +#[cfg(feature = "crash-reporting")] pub fn is_transient_integrations_failure(event: &sentry::protocol::Event<'_>) -> bool { is_transient_domain_failure(event, "integrations") || is_transient_domain_failure(event, "composio") @@ -2858,6 +2921,7 @@ pub fn is_transient_integrations_failure(event: &sentry::protocol::Event<'_>) -> /// `domain=skills`, `failure=non_2xx`, and a 4xx `status`. A 5xx is a genuine /// remote failure and stays reportable. Drops TAURI-RUST-CGE (~1,446 events / /// 72 users on `openhuman@0.57.53`). +#[cfg(feature = "crash-reporting")] pub fn is_skills_install_client_error_event(event: &sentry::protocol::Event<'_>) -> bool { let tags = &event.tags; if tags.get("domain").map(String::as_str) != Some("skills") { @@ -2879,6 +2943,7 @@ pub fn is_skills_install_client_error_event(event: &sentry::protocol::Event<'_>) /// `"failed to check for updates: error sending request for url (...latest.json)"`. /// Match both shapes, but never drop an arbitrary update-domain event unless /// it also has a transient status/transport marker. +#[cfg(feature = "crash-reporting")] pub fn is_updater_transient_event(event: &sentry::protocol::Event<'_>) -> bool { if event_has_updater_transient_message(event) { return true; @@ -2967,6 +3032,7 @@ pub fn is_suppressed_usage_probe_backoff(msg: &str) -> bool { /// the emit-site classifier — any non_2xx/400 event that carries the /// budget-exhausted phrasing is dropped regardless of which domain produced /// it, so a future re-emitter under a different tag still gets filtered. +#[cfg(feature = "crash-reporting")] pub fn is_budget_event(event: &sentry::protocol::Event<'_>) -> bool { let tags = &event.tags; if tags.get("failure").map(String::as_str) != Some("non_2xx") { @@ -3033,6 +3099,7 @@ pub fn is_insufficient_credits_message(text: &str) -> bool { /// failure (`"402"` or `"payment required"`), AND /// - that same text carries an insufficient-credits phrase /// (`provider::body_indicates_insufficient_credits`). +#[cfg(feature = "crash-reporting")] pub fn is_insufficient_credits_event(event: &sentry::protocol::Event<'_>) -> bool { if event .message @@ -3073,6 +3140,7 @@ pub fn is_quota_exhausted_message(text: &str) -> bool { /// that catches all of them, keyed on the formatted message rather than tags so /// it matches regardless of which path emitted it (and regardless of whether /// the upstream wrapped the 402 in a 500 envelope). +#[cfg(feature = "crash-reporting")] pub fn is_quota_exhausted_event(event: &sentry::protocol::Event<'_>) -> bool { if event .message @@ -3117,6 +3185,7 @@ pub fn is_ollama_cloud_internal_500_message_any(text: &str) -> bool { /// net for any other compatible-provider path (`chat_with_system`, /// `chat_with_history`, the non-native cascades) that reports the same body, /// keyed on the message rather than tags so it matches regardless of emitter. +#[cfg(feature = "crash-reporting")] pub fn is_ollama_cloud_internal_500_event(event: &sentry::protocol::Event<'_>) -> bool { if event .message @@ -3145,6 +3214,7 @@ pub fn is_ollama_cloud_internal_500_event(event: &sentry::protocol::Event<'_>) - /// - tag `status == "404"` /// - tag `method == "PATCH"` or `"DELETE"` /// - event message or exception value contains both `"/channels/"` and `"/messages/"` +#[cfg(feature = "crash-reporting")] pub fn is_channel_message_not_found_event(event: &sentry::protocol::Event<'_>) -> bool { let tags = &event.tags; if tags.get("domain").map(String::as_str) != Some("backend_api") { @@ -3163,6 +3233,7 @@ pub fn is_channel_message_not_found_event(event: &sentry::protocol::Event<'_>) - event_contains_channel_message_path(event) } +#[cfg(feature = "crash-reporting")] fn event_contains_channel_message_path(event: &sentry::protocol::Event<'_>) -> bool { let has_pattern = |s: &str| s.contains("/channels/") && s.contains("/messages/"); if event.message.as_deref().is_some_and(has_pattern) { @@ -3175,6 +3246,7 @@ fn event_contains_channel_message_path(event: &sentry::protocol::Event<'_>) -> b .any(|exc| exc.value.as_deref().is_some_and(has_pattern)) } +#[cfg(feature = "crash-reporting")] fn event_contains_budget_exhausted_message(event: &sentry::protocol::Event<'_>) -> bool { if event .message @@ -6286,6 +6358,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] fn event_with_tags(pairs: &[(&str, &str)]) -> sentry::protocol::Event<'static> { let mut event = sentry::protocol::Event::default(); let mut tags: std::collections::BTreeMap = @@ -6297,6 +6370,7 @@ mod tests { event } + #[cfg(feature = "crash-reporting")] fn event_with_tags_and_message( pairs: &[(&str, &str)], message: &str, @@ -6306,6 +6380,7 @@ mod tests { event } + #[cfg(feature = "crash-reporting")] #[test] fn transient_filter_drops_429_408_502_503_504() { for status in ["429", "408", "502", "503", "504"] { @@ -6321,6 +6396,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn custom_openai_502_event_shape_is_transient_provider_http() { let event = event_with_tags_and_message( @@ -6338,6 +6414,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn transient_filter_keeps_permanent_failures() { for status in ["400", "401", "403", "404", "500"] { @@ -6353,6 +6430,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn transient_filter_keeps_aggregate_all_exhausted() { let event = event_with_tags(&[ @@ -6366,6 +6444,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn transient_filter_keeps_events_with_no_status_tag() { let event = event_with_tags(&[("domain", "llm_provider"), ("failure", "non_2xx")]); @@ -6382,6 +6461,7 @@ mod tests { // "llm_provider", ..)` so the domain tag is consistent), but the broader // point is: any future caller that re-uses the same tag set for a // different domain must NOT be silently dropped by this filter. + #[cfg(feature = "crash-reporting")] #[test] fn transient_filter_keeps_events_with_no_domain_tag() { let event = event_with_tags(&[("failure", "non_2xx"), ("status", "503")]); @@ -6391,6 +6471,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn transient_filter_keeps_events_from_other_domains() { let event = event_with_tags(&[ @@ -6404,6 +6485,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn backend_api_filter_drops_transient_statuses() { for status in TRANSIENT_HTTP_STATUSES { @@ -6419,6 +6501,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn backend_api_filter_drops_transient_transport_phrases() { for phrase in TRANSIENT_TRANSPORT_PHRASES { @@ -6433,6 +6516,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn backend_api_filter_keeps_non_transient_failures() { for status in ["404", "500"] { @@ -6467,6 +6551,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn skills_install_fetch_filter_drops_client_error_statuses() { for status in ["400", "401", "403", "404", "410", "499"] { @@ -6483,6 +6568,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn skills_install_fetch_filter_keeps_server_and_wrong_shape_failures() { for status in ["500", "502", "503"] { @@ -6526,6 +6612,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn integrations_filter_drops_transient_statuses() { for status in TRANSIENT_HTTP_STATUSES { @@ -6541,6 +6628,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn integrations_filter_drops_transient_transport_phrases() { for phrase in TRANSIENT_TRANSPORT_PHRASES { @@ -6555,6 +6643,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn integrations_filter_keeps_non_transient_failures() { for status in ["404", "500"] { @@ -6598,6 +6687,7 @@ mod tests { /// `SKILL.md`) is expected user-input state — the before_send net must drop /// it, while a genuine 5xx remote failure and unrelated domains stay /// reportable. + #[cfg(feature = "crash-reporting")] #[test] fn skills_install_client_error_filter_drops_4xx_keeps_5xx() { // 4xx (esp. 404/410) = missing skill / wrong URL → dropped. @@ -6646,6 +6736,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn composio_domain_routes_through_integrations_filter() { // OPENHUMAN-TAURI-35 (~139 events) / -2H (~26 events): @@ -6697,6 +6788,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn composio_list_connections_503_504_wrappers_stay_filtered() { for (status, reason) in [("503", "Service Unavailable"), ("504", "Gateway Timeout")] { @@ -6738,6 +6830,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn updater_transient_403_is_dropped() { let event = event_with_tags_and_message( @@ -6755,6 +6848,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn updater_github_403_message_only_shapes_are_dropped() { for event in [ @@ -6768,6 +6862,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn updater_transient_502_is_dropped() { let event = event_with_tags_and_message( @@ -6784,6 +6879,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn updater_real_panic_still_reported() { let event = event_with_tags_and_message( @@ -6796,6 +6892,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn updater_endpoint_non_success_message_is_dropped() { // TAURI-RUST-CD (~151 events / 9 days, Windows): `tauri-plugin-updater` @@ -6817,6 +6914,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn updater_endpoint_non_success_anchor_does_not_silence_unrelated_errors() { // The new anchor is the literal plugin string. Other updater failures @@ -6887,6 +6985,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn budget_filter_drops_budget_message_on_tagged_400() { let event = event_with_tags_and_message( @@ -6897,6 +6996,7 @@ mod tests { assert!(is_budget_event(&event)); } + #[cfg(feature = "crash-reporting")] #[test] fn budget_filter_drops_budget_exception_on_tagged_400() { let mut event = event_with_tags(&[("failure", "non_2xx"), ("status", "400")]); @@ -6908,6 +7008,7 @@ mod tests { assert!(is_budget_event(&event)); } + #[cfg(feature = "crash-reporting")] #[test] fn budget_filter_keeps_non_budget_400() { let event = event_with_tags_and_message( @@ -6918,6 +7019,7 @@ mod tests { assert!(!is_budget_event(&event)); } + #[cfg(feature = "crash-reporting")] #[test] fn budget_filter_requires_non_2xx_failure_and_400_status() { let message = "Budget exceeded — add credits to continue"; @@ -6964,12 +7066,14 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] fn event_with_message(msg: &str) -> sentry::protocol::Event<'static> { let mut event = sentry::protocol::Event::default(); event.message = Some(msg.to_string()); event } + #[cfg(feature = "crash-reporting")] fn event_with_exception_value(value: &str) -> sentry::protocol::Event<'static> { let mut event = sentry::protocol::Event::default(); event.exception = vec![sentry::protocol::Exception { @@ -6980,6 +7084,7 @@ mod tests { event } + #[cfg(feature = "crash-reporting")] #[test] fn quota_exhausted_filter_matches_500_wrapped_kiro_event() { // TAURI-RUST-C9A: verbatim message as formatted by the provider emit @@ -6994,6 +7099,7 @@ mod tests { assert!(is_quota_exhausted_message(body)); } + #[cfg(feature = "crash-reporting")] #[test] fn quota_exhausted_filter_matches_responses_usage_limit_reached_event() { // TAURI-RUST-AFE: verbatim message as formatted by the `chat_via_responses` @@ -7013,6 +7119,7 @@ mod tests { assert!(is_quota_exhausted_message(body)); } + #[cfg(feature = "crash-reporting")] #[test] fn quota_exhausted_filter_ignores_generic_500_and_rate_limit() { // A generic 500 outage and a 429 rate-limit are not plan-quota @@ -7025,6 +7132,7 @@ mod tests { ))); } + #[cfg(feature = "crash-reporting")] #[test] fn insufficient_credits_filter_matches_message_path() { // Verbatim TAURI-RUST-C62 message as formatted by the provider emit @@ -7036,6 +7144,7 @@ mod tests { assert!(is_insufficient_credits_event(&event)); } + #[cfg(feature = "crash-reporting")] #[test] fn insufficient_credits_filter_matches_exception_path() { let event = event_with_exception_value( @@ -7044,6 +7153,7 @@ mod tests { assert!(is_insufficient_credits_event(&event)); } + #[cfg(feature = "crash-reporting")] #[test] fn insufficient_credits_filter_requires_both_402_and_credit_phrase() { // A 402 with no credit phrase must NOT be swallowed (could be another @@ -7058,6 +7168,7 @@ mod tests { ))); } + #[cfg(feature = "crash-reporting")] #[test] fn insufficient_credits_filter_ignores_402_digits_in_a_non_402_body() { // A non-402 error whose body merely contains the digits "402" and a @@ -7112,6 +7223,7 @@ mod tests { )); } + #[cfg(feature = "crash-reporting")] #[test] fn is_insufficient_credits_event_delegates_to_message_matcher() { // Parity: the event-level filter is now a thin wrapper over the @@ -7141,6 +7253,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn ollama_cloud_internal_500_before_send_matches_raw_and_reraised_shapes() { // The outermost net catches BOTH the raw emit body (any compatible @@ -7170,6 +7283,7 @@ mod tests { ))); } + #[cfg(feature = "crash-reporting")] #[test] fn session_expired_before_send_matches_core_401_events() { let msg = "SESSION_EXPIRED: backend session not active — sign in to resume LLM work"; @@ -7189,6 +7303,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn session_expired_before_send_stays_domain_scoped() { let event = event_with_tags_and_message( @@ -7201,6 +7316,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn max_iterations_filter_matches_message_path() { // `report_error_message` calls `sentry::capture_message`, which @@ -7210,6 +7326,7 @@ mod tests { assert!(is_max_iterations_event(&event)); } + #[cfg(feature = "crash-reporting")] #[test] fn max_iterations_filter_matches_exception_path() { // sentry-tracing with attach_stacktrace=true populates the @@ -7221,6 +7338,7 @@ mod tests { assert!(is_max_iterations_event(&event)); } + #[cfg(feature = "crash-reporting")] #[test] fn max_iterations_filter_keeps_unrelated_events() { assert!(!is_max_iterations_event(&event_with_message( @@ -7232,6 +7350,7 @@ mod tests { // ── is_channel_message_not_found_event (TAURI-R7) ──────────────────────── + #[cfg(feature = "crash-reporting")] fn channel_message_404_event(method: &str) -> sentry::protocol::Event<'static> { let mut event = sentry::protocol::Event::default(); event.tags.insert("domain".into(), "backend_api".into()); @@ -7245,6 +7364,7 @@ mod tests { event } + #[cfg(feature = "crash-reporting")] #[test] fn channel_message_not_found_filter_matches_patch() { // Canonical TAURI-R7 shape: PATCH 404 on a channel-message path. @@ -7253,6 +7373,7 @@ mod tests { )); } + #[cfg(feature = "crash-reporting")] #[test] fn channel_message_not_found_filter_matches_delete() { assert!(is_channel_message_not_found_event( @@ -7260,6 +7381,7 @@ mod tests { )); } + #[cfg(feature = "crash-reporting")] #[test] fn channel_message_not_found_filter_ignores_get_404() { // GET 404 on a channel-message path is NOT an expected state — must keep Sentry signal. @@ -7268,6 +7390,7 @@ mod tests { )); } + #[cfg(feature = "crash-reporting")] #[test] fn channel_message_not_found_filter_ignores_non_channel_path() { let mut event = channel_message_404_event("PATCH"); @@ -7275,6 +7398,7 @@ mod tests { assert!(!is_channel_message_not_found_event(&event)); } + #[cfg(feature = "crash-reporting")] #[test] fn channel_message_not_found_filter_ignores_wrong_status() { let mut event = channel_message_404_event("PATCH"); @@ -7282,6 +7406,7 @@ mod tests { assert!(!is_channel_message_not_found_event(&event)); } + #[cfg(feature = "crash-reporting")] #[test] fn channel_message_not_found_filter_ignores_wrong_domain() { let mut event = channel_message_404_event("PATCH"); @@ -7289,6 +7414,7 @@ mod tests { assert!(!is_channel_message_not_found_event(&event)); } + #[cfg(feature = "crash-reporting")] #[test] fn channel_message_not_found_filter_matches_exception_path() { // sentry-tracing with attach_stacktrace=true populates exception list. @@ -7709,6 +7835,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn expected_kind_ignores_byo_errors_that_carry_an_error_code_token() { // CodeRabbit: a BYO / direct-provider envelope whose body happens to @@ -7727,6 +7854,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn before_send_filter_drops_backend_owned_error_code_events() { for code in [ @@ -7760,6 +7888,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn before_send_filter_keeps_malformed_bad_request_event() { let event = event_with_message( @@ -7772,12 +7901,14 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn before_send_filter_matches_error_code_in_exception_value() { let event = event_with_exception_value(&managed_body("500", "INTERNAL_ERROR")); assert!(is_backend_error_code_event(&event)); } + #[cfg(feature = "crash-reporting")] #[test] fn transient_provider_transport_filter_drops_flaky_network_blips() { // F7: a streaming transport timeout/reset under @@ -7806,6 +7937,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn transient_provider_transport_filter_keeps_non_transient_transport() { // A genuine, non-transient transport failure (e.g. an unexpected @@ -7821,6 +7953,7 @@ mod tests { assert!(!is_transient_provider_transport_failure(&event)); } + #[cfg(feature = "crash-reporting")] #[test] fn transient_provider_transport_filter_scoped_to_streaming_operations() { // CodeRabbit: a NON-streaming llm_provider transport failure with the @@ -7847,6 +7980,7 @@ mod tests { assert!(!is_transient_provider_transport_failure(&no_op)); } + #[cfg(feature = "crash-reporting")] #[test] fn transient_provider_transport_filter_scoped_to_llm_provider() { // Same shape under a different domain must not be claimed by this @@ -7870,6 +8004,7 @@ mod tests { // `openhuman::credentials::ops::auth_get_me` for the broader // context. + #[cfg(feature = "crash-reporting")] fn auth_get_me_tags() -> Vec<(&'static str, &'static str)> { vec![ ("domain", "rpc"), @@ -7879,6 +8014,7 @@ mod tests { ] } + #[cfg(feature = "crash-reporting")] #[test] fn auth_get_me_opaque_filter_drops_bare_method_path_message() { let event = event_with_tags_and_message(&auth_get_me_tags(), "GET /auth/me"); @@ -7888,6 +8024,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn auth_get_me_opaque_filter_tolerates_surrounding_whitespace() { let event = event_with_tags_and_message(&auth_get_me_tags(), " GET /auth/me "); @@ -7897,6 +8034,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn auth_get_me_opaque_filter_keeps_full_anyhow_chain_message() { // Post-fix shape from `auth_get_me` now using `format!("{e:#}")`. @@ -7912,6 +8050,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn auth_get_me_opaque_filter_keeps_other_rpc_methods() { // Same opaque shape but for a different RPC must NOT be dropped — @@ -7937,6 +8076,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn auth_get_me_opaque_filter_requires_rpc_invoke_method_domain() { // Wrong domain → must surface. @@ -7956,6 +8096,7 @@ mod tests { assert!(!is_auth_get_me_opaque_transport_event(&event)); } + #[cfg(feature = "crash-reporting")] #[test] fn auth_get_me_opaque_filter_matches_exception_value_path() { // sentry-tracing path: message empty, exception last value carries @@ -7971,6 +8112,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn auth_get_me_opaque_filter_ignores_empty_and_unrelated() { // No message and no exception → false. diff --git a/src/core/runtime/builder.rs b/src/core/runtime/builder.rs index 8af72b7d30..a1a5c1a270 100644 --- a/src/core/runtime/builder.rs +++ b/src/core/runtime/builder.rs @@ -389,6 +389,12 @@ impl CoreRuntime { /// When `rpc_http` is not selected this returns immediately (a harness-only /// embedder has no transport to run); background services selected in the /// [`ServiceSet`] are still spawned. + /// + /// In a slim build compiled without the `http-server` feature an `rpc_http` + /// request cannot be honoured — the axum / Socket.IO transport is compiled + /// out — so `serve` returns a build-feature `Err` rather than binding no + /// listener and reporting success. The no-transport (`!rpc_http`) path above + /// is unaffected and still returns `Ok(())`. pub async fn serve( &self, ready_tx: Option>, @@ -401,6 +407,55 @@ impl CoreRuntime { return Ok(()); } + // Transport compiled out (#5048): run the selected background services + // and return without binding an HTTP/Socket.IO listener — same shape as + // the no-`rpc_http` guard above. The desktop shell always ships + // `http-server`; this keeps slim / headless-embedding builds linkable. + #[cfg(not(feature = "http-server"))] + { + // `rpc_http` was requested (we passed the guard above) but the HTTP + + // Socket.IO transport is compiled out of this slim build. Fail loudly + // rather than returning Ok with no listener bound — a supervisor / CLI + // (`openhuman run`, `serve`, `--headless-api`) would otherwise observe + // a clean start while the requested API is unavailable. Embedders that + // genuinely want no transport leave `ServiceSet::rpc_http` unset, which + // is handled by the early return above. + // + // The bind inputs are only read by the compiled-out `serve_http`; touch + // them so they don't read as dead fields in the slim build. + let _ = ( + ready_tx, + shutdown_token, + self.has_operator_token, + self.host.as_ref(), + self.port, + ); + anyhow::bail!( + "rpc_http transport was requested but this build was compiled \ + without the `http-server` feature; rebuild with the default \ + `http-server` feature, or use an embedding that does not set \ + `ServiceSet::rpc_http`" + ); + } + + #[cfg(feature = "http-server")] + { + self.serve_http(ready_tx, shutdown_token).await + } + } + + /// HTTP + Socket.IO transport body of [`Self::serve`]. + /// + /// Compiled only under the `http-server` feature (#5048): builds the axum + /// router, binds the listener, starts the selected background services, and + /// serves until shutdown. With the feature off, [`serve`](Self::serve) runs + /// background services and returns without binding (see the arms above). + #[cfg(feature = "http-server")] + async fn serve_http( + &self, + ready_tx: Option>, + shutdown_token: Option, + ) -> anyhow::Result<()> { // --- Host / port resolution --- let (resolved_port, port_source) = match self.port { Some(p) => (p, "builder port"), diff --git a/src/core/runtime/mod.rs b/src/core/runtime/mod.rs index 4b3e3a314f..299a6a15f2 100644 --- a/src/core/runtime/mod.rs +++ b/src/core/runtime/mod.rs @@ -24,6 +24,23 @@ //! on every multi-thread runtime that may host an agent turn. pub const AGENT_WORKER_STACK_BYTES: usize = 16 * 1024 * 1024; +/// Upper bound on tokio's blocking-thread pool for the long-lived multi-thread +/// runtimes tuned with [`AGENT_WORKER_STACK_BYTES`] (the desktop Tauri host and +/// the `openhuman-core` JSON-RPC / `agent_cli` servers). +/// +/// Tokio defaults `max_blocking_threads` to **512**. That is doubly wasteful on +/// these runtimes: `thread_stack_size` sizes *blocking* threads too, not just +/// workers, so an idle pool that grew to the cap could pin up to +/// `512 × 16 MiB` of stack — the opposite of the embedded RAM budget in #5046. +/// `spawn_blocking` on these paths backs SQLite, filesystem grep/glob, document +/// parsing, and URL guarding: bounded, bursty concurrency. 64 leaves generous +/// headroom over any realistic concurrent-blocking count while capping the idle +/// footprint, and threads still retire after tokio's 10 s idle timeout. +/// +/// Set `.max_blocking_threads(MAX_BLOCKING_THREADS)` alongside +/// `.thread_stack_size(AGENT_WORKER_STACK_BYTES)` on every such runtime. +pub const MAX_BLOCKING_THREADS: usize = 64; + pub mod builder; pub mod context; pub mod services; diff --git a/src/core/shutdown.rs b/src/core/shutdown.rs index fe93ecb0fc..4d569f4d74 100644 --- a/src/core/shutdown.rs +++ b/src/core/shutdown.rs @@ -56,8 +56,12 @@ async fn run_hooks() { /// signal (SIGINT on all platforms, plus SIGTERM on Unix), then runs all /// registered shutdown hooks. /// -/// This is intended to be used with [`axum::serve`]'s `with_graceful_shutdown` -/// method or in the main loop to handle clean exits. +/// This is intended to be used with `axum::serve`'s `with_graceful_shutdown` +/// method or in the main loop to handle clean exits. (Plain code span, not an +/// intra-doc link: the direct `axum` dependency/API surface is unavailable in +/// slim builds — the `http-server` feature (#5048) gates it, and it remains +/// only transitively via `tinychannels` — where an intra-doc link to it would +/// fail rustdoc.) pub async fn signal() { // Wait for the OS to send a termination signal. wait_for_signal().await; diff --git a/src/core/socketio.rs b/src/core/socketio.rs index 1b35bcec7c..663a36a5de 100644 --- a/src/core/socketio.rs +++ b/src/core/socketio.rs @@ -1,7 +1,18 @@ use serde::Deserialize; use serde::Serialize; +// `json!` + socketioxide are used only by the socketioxide event-transport +// bodies below, all gated with the `http-server` feature (#5048). The inert +// event payload types further down (`WebChannelEvent`, `TurnUsagePayload`, +// `SubagentUsagePayload`, `SubagentProgressDetail`) stay compiled in every build +// — ~10 always-on domains (web_chat, cron, channels, agent, agentbox, …) +// construct them — so `serde` stays ungated and only the transport surface is +// gated (type carve-out; see AGENTS.md and this module's `pub mod` in +// `core::mod`, which is intentionally NOT gated). +#[cfg(feature = "http-server")] use serde_json::json; +#[cfg(feature = "http-server")] use socketioxide::extract::{Data, SocketRef, TryData}; +#[cfg(feature = "http-server")] use socketioxide::SocketIo; /// Marker stored in [`SocketRef::extensions`] once a connection has presented a @@ -11,6 +22,7 @@ use socketioxide::SocketIo; /// into the JSON-RPC dispatcher or the web-chat orchestrator: an unauthenticated /// socket that never picked up the marker is allowed to receive broadcast-style /// events (read-only) but cannot trigger executable work. +#[cfg(feature = "http-server")] #[derive(Clone, Copy, Debug)] struct AuthedConnection; @@ -20,6 +32,7 @@ struct AuthedConnection; /// headers, so the handshake `auth` map is the only header-equivalent slot /// available for our per-process bearer. The socket-IO Node/JS clients all /// surface `io(url, { auth: { token: "" } })` for this. +#[cfg(feature = "http-server")] #[derive(Debug, Default, Deserialize)] struct HandshakeAuth { #[serde(default)] @@ -47,6 +60,7 @@ struct HandshakeAuth { /// A missing `Origin` header is treated as a native (non-browser) client /// and accepted — only the cross-origin browser-page case is the targeted /// bad actor here. +#[cfg(feature = "http-server")] pub(crate) fn origin_is_allowed(origin: Option<&str>) -> bool { let Some(origin) = origin else { return true; // native clients (CLI, Tauri shell) — no Origin header @@ -73,6 +87,7 @@ pub(crate) fn origin_is_allowed(origin: Option<&str>) -> bool { } /// True when `socket` finished the handshake with a valid bearer token. +#[cfg(feature = "http-server")] fn socket_is_authed(socket: &SocketRef) -> bool { socket.extensions.get::().is_some() } @@ -80,6 +95,7 @@ fn socket_is_authed(socket: &SocketRef) -> bool { /// Best-effort disconnect. Called when we discover an unauthenticated socket /// inside an event handler — the connect path already disconnects the bad /// origins / wrong tokens, so this is purely a defense-in-depth path. +#[cfg(feature = "http-server")] fn drop_unauthed(socket: &SocketRef, reason: &'static str) { log::warn!( "[socketio] dropping unauthenticated socket id={} reason={}", @@ -343,6 +359,7 @@ pub struct SubagentProgressDetail { pub dirty_status: Option, } +#[cfg(feature = "http-server")] #[derive(Debug, Deserialize)] struct SocketRpcRequest { id: serde_json::Value, @@ -351,6 +368,7 @@ struct SocketRpcRequest { params: serde_json::Value, } +#[cfg(feature = "http-server")] #[derive(Debug, Deserialize)] struct ChatStartPayload { thread_id: String, @@ -369,6 +387,7 @@ struct ChatStartPayload { queue_mode: Option, } +#[cfg(feature = "http-server")] #[derive(Debug, Deserialize)] struct ChatCancelPayload { thread_id: String, @@ -379,6 +398,7 @@ struct ChatCancelPayload { request_id: Option, } +#[cfg(feature = "http-server")] #[derive(Debug, Deserialize)] struct ThreadSubscribePayload { thread_id: String, @@ -391,6 +411,7 @@ struct ThreadSubscribePayload { /// - `rpc:request`: Invoking JSON-RPC methods over WebSocket. /// - `chat:start`: Initiating a new chat turn. /// - `chat:cancel`: Aborting an active chat turn. +#[cfg(feature = "http-server")] pub fn attach_socketio() -> (socketioxide::layer::SocketIoLayer, SocketIo) { let (layer, io) = SocketIo::new_layer(); @@ -612,6 +633,7 @@ pub fn attach_socketio() -> (socketioxide::layer::SocketIoLayer, SocketIo) { /// 3. **Overlay Bridge**: Forwards attention bubble events to all clients. /// 4. **Core Notification Bridge**: Forwards core notification events to all clients. /// 5. **Transcription Bridge**: Forwards real-time speech-to-text results to all clients. +#[cfg(feature = "http-server")] pub fn spawn_web_channel_bridge(io: SocketIo) { // 1. Web channel events → per-client rooms. let io_web = io.clone(); @@ -1462,6 +1484,7 @@ pub fn spawn_web_channel_bridge(io: SocketIo) { /// listener for telegram/discord); `last_error` carries the disconnect reason. /// Matches the shape consumed by the frontend /// `normalizeChannelConnectionUpdatePayload`. +#[cfg(feature = "http-server")] pub(crate) fn channel_connection_update_payload( channel: &str, status: &str, @@ -1486,6 +1509,7 @@ pub(crate) fn channel_connection_update_payload( /// so both the happy and error paths are logged with enough context /// (room name + client id) to diagnose missing welcome messages from /// logs alone. +#[cfg(feature = "http-server")] fn join_room_logged(socket: &SocketRef, room: &str, client_id: &str) { match socket.join(room.to_string()) { Ok(()) => log::debug!("[socketio] joined room '{room}' for client {client_id}"), @@ -1493,6 +1517,7 @@ fn join_room_logged(socket: &SocketRef, room: &str, client_id: &str) { } } +#[cfg(feature = "http-server")] fn emit_web_channel_event(io: &SocketIo, event: WebChannelEvent) { let name = event.event.clone(); // Deliver to the initiating client's own room AND the per-thread room. The @@ -1555,8 +1580,10 @@ fn emit_web_channel_event(io: &SocketIo, event: WebChannelEvent) { /// is suppressed for exactly these. Enumerated explicitly rather than matched by /// a `*_delta` suffix, so a future *discrete* event whose name happens to end in /// `_delta` still gets its compat alias instead of being silently dropped. +#[cfg(feature = "http-server")] const STREAMING_DELTA_EVENTS: &[&str] = &["text_delta", "thinking_delta", "tool_args_delta"]; +#[cfg(feature = "http-server")] fn event_alias(name: &str) -> Option { // Match against the canonical underscore form after stripping a `subagent_` // prefix (subagent streaming mirrors the parent's deltas), so `text_delta`, @@ -1576,6 +1603,7 @@ fn event_alias(name: &str) -> Option { None } +#[cfg(feature = "http-server")] fn emit_with_aliases(socket: &SocketRef, name: &str, payload: &serde_json::Value) { let _ = socket.emit(name, payload); if let Some(alias) = event_alias(name) { @@ -1583,7 +1611,9 @@ fn emit_with_aliases(socket: &SocketRef, name: &str, payload: &serde_json::Value } } -#[cfg(test)] +// Every test here names a gated fn (`channel_connection_update_payload`, +// `event_alias`, `origin_is_allowed`), so the module gates in lockstep (#5048). +#[cfg(all(test, feature = "http-server"))] mod tests { use super::{channel_connection_update_payload, event_alias, origin_is_allowed}; diff --git a/src/main.rs b/src/main.rs index fbd3b41672..b958d7a02e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,9 +5,6 @@ //! - Setting up secret scrubbing for outgoing error reports. //! - Dispatching command-line arguments to the core logic in `openhuman_core`. -use once_cell::sync::Lazy; -use regex::Regex; - /// Main application entry point. /// /// It initializes the Sentry SDK for error monitoring, ensuring that sensitive @@ -33,6 +30,10 @@ fn main() { // the GH org-level variable can be renamed) // 3. Each of the same names baked at compile time via `option_env!` // If none resolve to a non-empty value, `sentry::init` returns a no-op guard. + // + // The whole init (guard + secret-scrubbing `before_send`) is gated on the + // `crash-reporting` feature; a slim build compiles it out entirely. + #[cfg(feature = "crash-reporting")] let _sentry_guard = sentry::init(sentry::ClientOptions { dsn: std::env::var("OPENHUMAN_CORE_SENTRY_DSN") .ok() @@ -259,6 +260,7 @@ fn restore_default_sigpipe() {} /// `app/src/utils/config.ts`) so events from every surface group under /// the same release in the Sentry dashboard and benefit from the same /// source-map upload. +#[cfg(feature = "crash-reporting")] fn build_release_tag() -> String { let version = env!("CARGO_PKG_VERSION"); let sha = option_env!("OPENHUMAN_BUILD_SHA").unwrap_or("").trim(); @@ -275,6 +277,7 @@ fn build_release_tag() -> String { /// Honors `OPENHUMAN_APP_ENV` at runtime (`staging` / `production`) so the /// same binary could in principle be redeployed between environments; falls /// back to debug/release detection when unset. +#[cfg(feature = "crash-reporting")] fn resolve_environment() -> String { if let Ok(value) = std::env::var("OPENHUMAN_APP_ENV") { let trimmed = value.trim().to_ascii_lowercase(); @@ -293,53 +296,16 @@ fn resolve_environment() -> String { // Secret scrubbing // --------------------------------------------------------------------------- -/// Ordered most-specific → least-specific. Keep in sync with -/// `src/openhuman/memory/safety/mod.rs`. -static SECRET_PATTERNS: Lazy> = Lazy::new(|| { - vec![ - // Matches "Bearer " and redacts the token. - (Regex::new(r"(?i)(bearer\s+)\S+").unwrap(), "${1}[REDACTED]"), - // Matches "api-key: " or "api_key=" and redacts the key. - ( - Regex::new(r"(?i)(api[_-]?key[=:\s]+)\S+").unwrap(), - "${1}[REDACTED]", - ), - // \b anchor prevents matching `cancellation_token=` etc. - ( - Regex::new(r"(?i)\b(token[=:\s]+)\S+").unwrap(), - "${1}[REDACTED]", - ), - // Anthropic keys (sk-ant-api03-...) contain hyphens the generic - // sk- pattern below won't match. - ( - Regex::new(r"sk-ant-[A-Za-z0-9\-_]{16,}").unwrap(), - "[REDACTED]", - ), - // OpenAI admin keys (sk-admin-...). - ( - Regex::new(r"sk-admin-[A-Za-z0-9\-_]{12,}").unwrap(), - "[REDACTED]", - ), - // OpenAI project-scoped and org-scoped keys (sk-proj-... / sk-org-...). - ( - Regex::new(r"sk-(?:proj|org)-[A-Za-z0-9\-_]{12,}").unwrap(), - "[REDACTED]", - ), - // Generic catch-all for any sk- format not covered above. - (Regex::new(r"sk-[a-zA-Z0-9]{20,}").unwrap(), "[REDACTED]"), - ] -}); - -/// Replaces patterns that look like secrets with `[REDACTED]`. +/// Sentry `before_send` secret scrubbing. Delegates to the shared, always-on +/// [`openhuman_core::core::log_redaction::scrub_secrets`] so the redaction +/// patterns stay a single source of truth (the same pass also runs on the +/// always-on diagnostic logs in `core::observability`). +#[cfg(feature = "crash-reporting")] fn scrub_secrets(input: &str) -> String { - let mut result = input.to_string(); - for (re, replacement) in SECRET_PATTERNS.iter() { - result = re.replace_all(&result, *replacement).into_owned(); - } - result + openhuman_core::core::log_redaction::scrub_secrets(input) } -#[cfg(test)] +#[cfg(all(test, feature = "crash-reporting"))] mod tests { use super::*; diff --git a/src/openhuman/accessibility/permissions.rs b/src/openhuman/accessibility/permissions.rs index 1fda7a7c0a..eeb03b5425 100644 --- a/src/openhuman/accessibility/permissions.rs +++ b/src/openhuman/accessibility/permissions.rs @@ -148,7 +148,7 @@ pub fn detect_input_monitoring_permission() -> PermissionState { /// /// **Linux** standard desktops don't enforce per-app permissions; Flatpak/Snap /// sandboxes are detected separately. -#[cfg(any(target_os = "macos", target_os = "windows"))] +#[cfg(all(feature = "inference", any(target_os = "macos", target_os = "windows")))] pub fn detect_microphone_permission() -> PermissionState { use cpal::traits::HostTrait; let host = cpal::default_host(); @@ -168,7 +168,7 @@ pub fn detect_microphone_permission() -> PermissionState { } } -#[cfg(target_os = "linux")] +#[cfg(all(feature = "inference", target_os = "linux"))] pub fn detect_microphone_permission() -> PermissionState { // Standard Linux desktops (PulseAudio/PipeWire) don't enforce app-level mic permissions. // Detect Flatpak sandbox — if sandboxed, probe CPAL as a permission proxy. @@ -189,6 +189,21 @@ pub fn detect_microphone_permission() -> PermissionState { } } +/// With the `inference` feature off, the `cpal` audio-device probe is compiled +/// out along with the whisper engine, so the microphone cannot be inspected. +/// Report `Unknown` on otherwise-supported desktop platforms rather than a +/// misleading `Granted`/`Denied`. +#[cfg(all( + not(feature = "inference"), + any(target_os = "macos", target_os = "windows", target_os = "linux") +))] +pub fn detect_microphone_permission() -> PermissionState { + log::debug!( + "[permissions] microphone probe unavailable (built without the `inference` feature)" + ); + PermissionState::Unknown +} + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] pub fn detect_microphone_permission() -> PermissionState { PermissionState::Unsupported diff --git a/src/openhuman/agent/multimodal.rs b/src/openhuman/agent/multimodal.rs index bba7579ea0..f5a7e5a2ee 100644 --- a/src/openhuman/agent/multimodal.rs +++ b/src/openhuman/agent/multimodal.rs @@ -32,6 +32,7 @@ const FILE_MARKER_PREFIX: &str = "[FILE:"; /// may run before the worker is abandoned and the file degrades to a /// metadata-only reference. PDFs known to choke the parser (extremely /// large, encrypted, malformed) must not stall a chat turn. +#[cfg(feature = "documents")] const PDF_EXTRACTION_TIMEOUT: Duration = Duration::from_secs(60); /// Worst-case length budget reserved for the rendered truncation @@ -1550,6 +1551,7 @@ fn extract_utf8_text(bytes: &[u8]) -> Result { /// extracted text on success; on timeout / panic / parse error the /// caller degrades the file to [`FilePayload::Reference`] rather than /// surface the failure to the user (avoids Sentry noise on broken PDFs). +#[cfg(feature = "documents")] async fn extract_pdf_text(bytes: Vec) -> Result { let extraction = tokio::task::spawn_blocking(move || { pdf_extract::extract_text_from_mem(&bytes).map_err(|error| error.to_string()) @@ -1566,6 +1568,15 @@ async fn extract_pdf_text(bytes: Vec) -> Result { } } +/// Disabled variant when the `documents` feature is off: `pdf-extract` is not +/// compiled in, so signal failure and let the caller degrade the file to +/// [`FilePayload::Reference`] — the same path a parse error / timeout takes. +#[cfg(not(feature = "documents"))] +async fn extract_pdf_text(_bytes: Vec) -> Result { + log::debug!("[multimodal] pdf text extraction skipped: built without the `documents` feature"); + Err("pdf text extraction disabled (built without the `documents` feature)".to_string()) +} + /// Truncate `text` to at most `max_chars` Unicode scalar values, leaving /// room for the rendered `"\n[…truncated {dropped} chars]"` suffix. /// The reservation uses [`TEXT_TRUNCATION_SUFFIX_BUDGET`] — the diff --git a/src/openhuman/agent_registry/agents/loader.rs b/src/openhuman/agent_registry/agents/loader.rs index 6d32e71062..ed7288714d 100644 --- a/src/openhuman/agent_registry/agents/loader.rs +++ b/src/openhuman/agent_registry/agents/loader.rs @@ -340,12 +340,32 @@ pub const BUILTINS: &[BuiltinAgent] = &[ /// baked into the binary and therefore must always be valid. Unit tests /// below keep that invariant honest. pub fn load_builtins() -> Result> { - let defs: Vec = BUILTINS.iter().map(parse_builtin).collect::>()?; + let defs: Vec = BUILTINS + .iter() + .filter(|b| builtin_enabled(b)) + .map(parse_builtin) + .collect::>()?; validate_tier_hierarchy(&defs) .context("built-in agents violate the spawn-hierarchy contract")?; Ok(defs) } +/// Compile-time gate for built-ins whose deck/document tool is feature-gated. +/// +/// `presentation_agent` delegates deck creation to `generate_presentation`, +/// which only registers under the `documents` feature (see `tools::ops`). In a +/// slim build without `documents`, the agent would still be advertised as +/// `make_presentation` while its filtered tool surface no longer contains any +/// tool able to produce a deck, so it is dropped from the registry in lockstep +/// with its tool. +fn builtin_enabled(_b: &BuiltinAgent) -> bool { + #[cfg(not(feature = "documents"))] + if _b.id == "presentation_agent" { + return false; + } + true +} + /// Validate the cross-agent spawn-hierarchy contract documented on /// [`AgentTier`]. /// @@ -464,7 +484,35 @@ mod tests { #[test] fn all_builtins_parse() { let defs = load_builtins().expect("built-in TOML must parse"); - assert_eq!(defs.len(), BUILTINS.len()); + // `load_builtins` filters feature-gated built-ins (e.g. `presentation_agent` + // when `documents` is off), so compare against the same filtered count + // rather than the raw `BUILTINS` length. + let expected = BUILTINS.iter().filter(|b| builtin_enabled(b)).count(); + assert_eq!(defs.len(), expected); + } + + /// Pins the `presentation_agent` compile-time gate, both directions: it is + /// registered under the `documents` feature (its `generate_presentation` + /// deck tool lives there) and filtered out of the registry without it, so + /// slim builds never advertise `make_presentation` with no tool to fulfil it. + #[cfg(feature = "documents")] + #[test] + fn presentation_agent_registered_when_documents_on() { + let defs = load_builtins().expect("built-in TOML must parse"); + assert!( + defs.iter().any(|d| d.id == "presentation_agent"), + "presentation_agent must register when the `documents` feature is on" + ); + } + + #[cfg(not(feature = "documents"))] + #[test] + fn presentation_agent_absent_when_documents_off() { + let defs = load_builtins().expect("built-in TOML must parse"); + assert!( + !defs.iter().any(|d| d.id == "presentation_agent"), + "presentation_agent must be filtered from the registry when `documents` is off" + ); } #[test] @@ -1305,18 +1353,25 @@ mod tests { other => panic!("scheduler_agent must use Named tool scope, got {other:?}"), } - let presentation = find("presentation_agent"); - match &presentation.tools { - ToolScope::Named(names) => { - assert!(names.iter().any(|name| name == "generate_presentation")); - assert!(!names.iter().any(|name| name == "call_memory_agent")); - assert!(names.iter().any(|name| name == "web_search_tool")); + // `presentation_agent` is only registered under the `documents` feature + // (its deck tool `generate_presentation` is gated there and the agent is + // filtered from the registry in lockstep — see `builtin_enabled`), so + // skip its assertions in slim builds where it is intentionally absent. + #[cfg(feature = "documents")] + { + let presentation = find("presentation_agent"); + match &presentation.tools { + ToolScope::Named(names) => { + assert!(names.iter().any(|name| name == "generate_presentation")); + assert!(!names.iter().any(|name| name == "call_memory_agent")); + assert!(names.iter().any(|name| name == "web_search_tool")); + } + other => panic!("presentation_agent must use Named tool scope, got {other:?}"), } - other => panic!("presentation_agent must use Named tool scope, got {other:?}"), + // Memory pre-fetch is no longer eager; `omit_memory_context = false` + // still gives the deck builder the cheap per-turn recall. + assert_eq!(presentation.trigger_memory_agent, TriggerMemoryAgent::Never); } - // Memory pre-fetch is no longer eager; `omit_memory_context = false` - // still gives the deck builder the cheap per-turn recall. - assert_eq!(presentation.trigger_memory_agent, TriggerMemoryAgent::Never); let desktop = find("desktop_control_agent"); match &desktop.tools { diff --git a/src/openhuman/agentbox/mod.rs b/src/openhuman/agentbox/mod.rs index 29c786f22b..9d359edaec 100644 --- a/src/openhuman/agentbox/mod.rs +++ b/src/openhuman/agentbox/mod.rs @@ -7,6 +7,12 @@ //! See `docs/superpowers/specs/2026-06-12-agentbox-marketplace-integration-design.md`. pub mod env; +// The `/run` + `/jobs/{id}` HTTP surface is axum-only, so it and the +// `agentbox_router` re-export are exclusive to the `http-server` feature +// (#5048). The axum-free `ops`/`status`/`store`/`schemas`/`invoker` stay +// compiled — the AgentBox controllers + status RPC remain available in slim +// builds; only the router (merged by the gated `core::jsonrpc` router) is shed. +#[cfg(feature = "http-server")] pub mod http; pub mod invoker; pub mod ops; @@ -16,17 +22,21 @@ pub mod store; pub mod types; pub use env::{agentbox_mode_enabled, register_gmi_provider_if_present}; +#[cfg(feature = "http-server")] pub use http::router as agentbox_router; pub use schemas::{all_agentbox_controller_schemas, all_agentbox_registered_controllers}; pub use status::agentbox_status; pub use store::JobStore; pub use types::{AgentBoxProviderInfo, AgentBoxStatus}; -#[cfg(test)] +// Exercises `build_core_http_router` (axum) — gated in lockstep (#5048). +#[cfg(all(test, feature = "http-server"))] mod disabled_mode_tests; #[cfg(test)] mod env_tests; -#[cfg(test)] +// Drives the gated `agentbox::http::router` via `tower::ServiceExt` — gated in +// lockstep (#5048). +#[cfg(all(test, feature = "http-server"))] mod http_tests; #[cfg(test)] mod ops_tests; diff --git a/src/openhuman/artifacts/ops.rs b/src/openhuman/artifacts/ops.rs index 8924364373..8dedc41533 100644 --- a/src/openhuman/artifacts/ops.rs +++ b/src/openhuman/artifacts/ops.rs @@ -1,11 +1,18 @@ use serde_json::{json, Value}; -use crate::openhuman::approval::{ApprovalChatContext, APPROVAL_CHAT_CONTEXT}; use crate::openhuman::config::Config; +use crate::rpc::RpcOutcome; + +// Imports used only by the `documents`-gated presentation regeneration helper +// below; when the feature is off the PresentationTool is compiled out. +#[cfg(feature = "documents")] +use crate::openhuman::approval::{ApprovalChatContext, APPROVAL_CHAT_CONTEXT}; +#[cfg(feature = "documents")] use crate::openhuman::security::SecurityPolicy; +#[cfg(feature = "documents")] use crate::openhuman::tools::traits::Tool; +#[cfg(feature = "documents")] use crate::openhuman::tools::PresentationTool; -use crate::rpc::RpcOutcome; use super::store; use super::types::ArtifactKind; @@ -177,6 +184,20 @@ pub async fn ai_regenerate( )); } + regenerate_presentation(config, artifact_id, thread_id, client_id).await +} + +/// Re-run the presentation producer tool against an existing artifact id. +/// Extracted so the whole `documents`-gated tool path (PresentationTool + +/// approval scope) compiles only when the feature is on; `ai_regenerate` stays +/// a thin, always-compiled RPC handler. +#[cfg(feature = "documents")] +async fn regenerate_presentation( + config: &Config, + artifact_id: &str, + thread_id: &str, + client_id: &str, +) -> Result, String> { let args = store::read_artifact_args(&config.workspace_dir, artifact_id).await?; // A fresh policy from the live config — cheap, sync, and mirrors how @@ -223,6 +244,24 @@ pub async fn ai_regenerate( } } +/// Disabled variant: with the `documents` feature off the PresentationTool is +/// compiled out (and no presentation artifacts can exist), so report the build +/// limitation rather than pretend to regenerate. +#[cfg(not(feature = "documents"))] +async fn regenerate_presentation( + _config: &Config, + artifact_id: &str, + _thread_id: &str, + _client_id: &str, +) -> Result, String> { + log::debug!( + "[artifacts] presentation regeneration rejected for id={artifact_id}: built without the `documents` feature" + ); + Err(format!( + "[artifacts] presentation regeneration is unavailable for id={artifact_id}: built without the `documents` feature" + )) +} + #[cfg(test)] #[path = "ops_tests.rs"] mod tests; diff --git a/src/openhuman/composio/ops_tests.rs b/src/openhuman/composio/ops_tests.rs index be050c62b8..a85cbae194 100644 --- a/src/openhuman/composio/ops_tests.rs +++ b/src/openhuman/composio/ops_tests.rs @@ -2316,6 +2316,7 @@ fn extract_backend_returned_status_handles_mixed_case() { // `report_composio_op_error` events flood Sentry again with no test in // the composio crate to catch it. These guards make the link explicit. +#[cfg(feature = "crash-reporting")] #[test] fn composio_domain_502_is_dropped_by_before_send() { let mut event = sentry::protocol::Event::default(); @@ -2330,6 +2331,7 @@ fn composio_domain_502_is_dropped_by_before_send() { ); } +#[cfg(feature = "crash-reporting")] #[test] fn composio_transport_timeout_is_dropped_by_before_send() { let mut event = sentry::protocol::Event::default(); diff --git a/src/openhuman/credentials/sentry_scope.rs b/src/openhuman/credentials/sentry_scope.rs index 76b40d6267..d800b22003 100644 --- a/src/openhuman/credentials/sentry_scope.rs +++ b/src/openhuman/credentials/sentry_scope.rs @@ -30,6 +30,10 @@ pub fn bind(id: &str) { return; } let id = trimmed.to_string(); + // Sentry-touching body gated on `crash-reporting`; the signature and the + // diagnostic log line stay compiled in both builds. `id` is still consumed + // by the `tracing::debug!` below, so no unused-variable guard is needed. + #[cfg(feature = "crash-reporting")] sentry::configure_scope(|scope| { scope.set_user(Some(sentry::User { id: Some(id.clone()), @@ -43,13 +47,16 @@ pub fn bind(id: &str) { /// background loops that survive the teardown grace window are not /// mis-attributed to the previously signed-in account. pub fn clear() { + #[cfg(feature = "crash-reporting")] sentry::configure_scope(|scope| { scope.set_user(None); }); tracing::debug!("[sentry] scope user cleared"); } -#[cfg(test)] +// All four tests use `sentry::test::with_captured_events`, so the module is +// gated on `crash-reporting` in addition to `test`. +#[cfg(all(test, feature = "crash-reporting"))] mod tests { use super::*; diff --git a/src/openhuman/inference/http/mod.rs b/src/openhuman/inference/http/mod.rs index 9984bed9b0..bfaa4e7653 100644 --- a/src/openhuman/inference/http/mod.rs +++ b/src/openhuman/inference/http/mod.rs @@ -18,7 +18,14 @@ /// secret encrypted at rest and scoped to the active user workspace. pub const EXTERNAL_OPENAI_COMPAT_PROVIDER: &str = "external-openai-compat"; +// The `/v1/*` axum router lives here and is exclusive to the `http-server` +// feature (#5048). CARVE-OUT: `EXTERNAL_OPENAI_COMPAT_PROVIDER` (above) and +// `types` stay UNGATED — `core::auth` consumes the provider id (and its inert +// request/response types are dep-free) in ALL builds, so only the axum +// `server`/`router` surface is gated. +#[cfg(feature = "http-server")] pub mod server; pub mod types; +#[cfg(feature = "http-server")] pub use server::router; diff --git a/src/openhuman/inference/local/service/whisper_engine/mod.rs b/src/openhuman/inference/local/service/whisper_engine/mod.rs new file mode 100644 index 0000000000..3254608f2e --- /dev/null +++ b/src/openhuman/inference/local/service/whisper_engine/mod.rs @@ -0,0 +1,53 @@ +//! In-process whisper.cpp STT engine, gated behind the `inference` feature. +//! +//! This is the whisper/`cpal` dependency shed the `voice` gate deferred (see +//! the `inference` feature in the root `Cargo.toml`). Structure follows the +//! type-carve-out variant of the repo's facade + stub pattern (AGENTS.md): +//! +//! - [`types`] holds `TranscriptionResult` — an inert, dependency-free data +//! type named by always-compiled callers (the local-AI service in +//! `../speech.rs`/`../bootstrap.rs`) and by `inference::voice::streaming`. +//! It stays compiled in **both** build states, so its fields can never drift. +//! - `real` owns the actual `whisper-rs` / `WhisperContext` engine and is +//! compiled only with `--features inference`. +//! - `stub` mirrors `real`'s function + handle surface exactly with +//! disabled-error / no-op bodies, so those always-compiled callers need no +//! per-call `#[cfg]`. +//! +//! The stub signatures must match `real` exactly — the disabled build +//! (`--no-default-features --features tokenjuice-treesitter`) is the only thing +//! that catches drift, so run it after touching either side. + +mod types; +// The facade re-exports the whole engine surface, but which items a given build +// actually names depends on downstream feature gates — the voice STT factory +// (`voice`) consumes `looks_like_wav` / `transcribe_wav_bytes` / +// `loaded_model_path` etc., while the always-compiled local-AI service uses only +// a subset. Allow unused re-exports so the enabled and disabled builds keep an +// identical public surface instead of drifting on which subset they pull. +#[allow(unused_imports)] +pub use types::TranscriptionResult; + +#[cfg(feature = "inference")] +mod real; +#[cfg(feature = "inference")] +#[allow(unused_imports)] +pub use real::{ + is_loaded, load_engine, loaded_model_path, new_handle, transcribe_pcm_f32, transcribe_pcm_i16, + transcribe_wav_file, unload_engine, WhisperEngineHandle, +}; +#[cfg(feature = "inference")] +#[allow(unused_imports)] +pub(crate) use real::{looks_like_wav, transcribe_wav_bytes}; + +#[cfg(not(feature = "inference"))] +mod stub; +#[cfg(not(feature = "inference"))] +#[allow(unused_imports)] +pub use stub::{ + is_loaded, load_engine, loaded_model_path, new_handle, transcribe_pcm_f32, transcribe_pcm_i16, + transcribe_wav_file, unload_engine, WhisperEngineHandle, +}; +#[cfg(not(feature = "inference"))] +#[allow(unused_imports)] +pub(crate) use stub::{looks_like_wav, transcribe_wav_bytes}; diff --git a/src/openhuman/inference/local/service/whisper_engine.rs b/src/openhuman/inference/local/service/whisper_engine/real.rs similarity index 97% rename from src/openhuman/inference/local/service/whisper_engine.rs rename to src/openhuman/inference/local/service/whisper_engine/real.rs index 02d552a5f0..1a50b3ffd8 100644 --- a/src/openhuman/inference/local/service/whisper_engine.rs +++ b/src/openhuman/inference/local/service/whisper_engine/real.rs @@ -14,25 +14,14 @@ use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextPar use crate::openhuman::util::utf8_safe_prefix_at_byte_boundary; +use super::types::TranscriptionResult; + /// Per-segment confidence threshold: reject segments with avg log-probability below this. const SEGMENT_LOGPROB_REJECT: f32 = -0.7; /// Per-segment entropy threshold: reject segments with entropy above this. const SEGMENT_ENTROPY_REJECT: f32 = 2.4; -/// Result of a transcription call, including confidence metadata. -#[derive(Debug, Clone)] -pub struct TranscriptionResult { - /// The transcribed text (may be empty if all segments were rejected). - pub text: String, - /// Average log-probability across accepted segments (higher = more confident). - /// `None` if no segments were accepted. - pub avg_logprob: Option, - /// Number of segments accepted / total segments produced by Whisper. - pub segments_accepted: usize, - pub segments_total: usize, -} - const LOG_PREFIX: &str = "[whisper_engine]"; /// Wraps a loaded `WhisperContext` for reuse across transcription calls. diff --git a/src/openhuman/inference/local/service/whisper_engine/stub.rs b/src/openhuman/inference/local/service/whisper_engine/stub.rs new file mode 100644 index 0000000000..894108dcb4 --- /dev/null +++ b/src/openhuman/inference/local/service/whisper_engine/stub.rs @@ -0,0 +1,131 @@ +//! Disabled facade for the whisper engine — compiled when the `inference` +//! feature is OFF (`whisper-rs` and `cpal` are dropped from the build). +//! +//! Mirrors `real`'s public function + handle surface exactly so the +//! always-compiled callers (`../speech.rs`, `../bootstrap.rs`, +//! `inference::voice::streaming`, and the voice STT factory when `voice` is on) +//! need no per-call `#[cfg]`. Every transcription path returns the disabled +//! error; loading is a no-op and nothing is ever "loaded". + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use parking_lot::Mutex; + +use super::types::TranscriptionResult; + +const DISABLED: &str = "in-process whisper STT is disabled: this build was compiled without the `inference` feature (rebuild with `--features inference`)"; + +/// Whisper-free mirror of the real engine handle. Always empty — with the +/// engine compiled out nothing loads — but keeps the same +/// `Arc>>` shape so `LocalAiService.whisper` still constructs. +pub type WhisperEngineHandle = Arc>>; + +/// Create a new (permanently empty) engine handle. +pub fn new_handle() -> WhisperEngineHandle { + Arc::new(Mutex::new(None)) +} + +/// No-op: there is no engine to load. Returns the disabled error so callers +/// log/fall back exactly as they would on a real load failure. +pub fn load_engine( + _handle: &WhisperEngineHandle, + _model_path: &Path, + _has_gpu: bool, + _gpu_description: Option<&str>, +) -> Result<(), String> { + log::debug!("[whisper_engine::stub] load_engine no-op — {DISABLED}"); + Err(DISABLED.to_string()) +} + +/// No-op: nothing is ever loaded. +pub fn unload_engine(_handle: &WhisperEngineHandle) {} + +/// Always `false` — the in-process engine is compiled out. +pub fn is_loaded(_handle: &WhisperEngineHandle) -> bool { + false +} + +/// Always `None` — no model can be loaded. +pub fn loaded_model_path(_handle: &WhisperEngineHandle) -> Option { + None +} + +pub fn transcribe_pcm_f32( + _handle: &WhisperEngineHandle, + _audio_f32: &[f32], + _language: Option<&str>, + _initial_prompt: Option<&str>, +) -> Result { + log::debug!("[whisper] transcribe_pcm_f32 unavailable: built without the `inference` feature"); + Err(DISABLED.to_string()) +} + +pub fn transcribe_pcm_i16( + _handle: &WhisperEngineHandle, + _audio_i16: &[i16], + _language: Option<&str>, + _initial_prompt: Option<&str>, +) -> Result { + log::debug!("[whisper] transcribe_pcm_i16 unavailable: built without the `inference` feature"); + Err(DISABLED.to_string()) +} + +pub fn transcribe_wav_file( + _handle: &WhisperEngineHandle, + _wav_path: &Path, + _language: Option<&str>, + _initial_prompt: Option<&str>, +) -> Result { + log::debug!("[whisper] transcribe_wav_file unavailable: built without the `inference` feature"); + Err(DISABLED.to_string()) +} + +/// Cheap RIFF/WAVE header sniff — dependency-free, so the stub keeps the real +/// behaviour rather than a misleading constant (matches `real::looks_like_wav`). +pub(crate) fn looks_like_wav(bytes: &[u8]) -> bool { + bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WAVE" +} + +pub(crate) fn transcribe_wav_bytes( + _handle: &WhisperEngineHandle, + _wav_bytes: &[u8], + _language: Option<&str>, + _initial_prompt: Option<&str>, +) -> Result { + log::debug!( + "[whisper] transcribe_wav_bytes unavailable: built without the `inference` feature" + ); + Err(DISABLED.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stub_handle_never_loads_and_transcribe_errors() { + let h = new_handle(); + assert!(!is_loaded(&h)); + assert!(loaded_model_path(&h).is_none()); + let err = transcribe_pcm_f32(&h, &[0.0; 16], None, None).unwrap_err(); + assert!( + err.contains("inference"), + "disabled error names the gate: {err}" + ); + // i16 + wav paths error the same way. + assert!(transcribe_pcm_i16(&h, &[0i16; 16], None, None).is_err()); + assert!(transcribe_wav_bytes(&h, b"not a wav", None, None).is_err()); + } + + #[test] + fn stub_looks_like_wav_matches_real_behaviour() { + let mut wav = Vec::new(); + wav.extend_from_slice(b"RIFF"); + wav.extend_from_slice(&[0u8; 4]); + wav.extend_from_slice(b"WAVE"); + assert!(looks_like_wav(&wav)); + assert!(!looks_like_wav(b"OggS....")); + assert!(!looks_like_wav(b"RIFF")); + } +} diff --git a/src/openhuman/inference/local/service/whisper_engine/types.rs b/src/openhuman/inference/local/service/whisper_engine/types.rs new file mode 100644 index 0000000000..5506497755 --- /dev/null +++ b/src/openhuman/inference/local/service/whisper_engine/types.rs @@ -0,0 +1,18 @@ +//! Inert transcription result type — dependency-free and compiled in both +//! build states. The `inference` feature gates only the engine (`real`), not +//! this data type, so always-compiled callers (`../speech.rs`, +//! `inference::voice::streaming`) name one stable definition regardless of the +//! feature. See the module docs in `mod.rs` for the carve-out rationale. + +/// Result of a transcription call, including confidence metadata. +#[derive(Debug, Clone)] +pub struct TranscriptionResult { + /// The transcribed text (may be empty if all segments were rejected). + pub text: String, + /// Average log-probability across accepted segments (higher = more confident). + /// `None` if no segments were accepted. + pub avg_logprob: Option, + /// Number of segments accepted / total segments produced by Whisper. + pub segments_accepted: usize, + pub segments_total: usize, +} diff --git a/src/openhuman/inference/mod.rs b/src/openhuman/inference/mod.rs index b0c4bfb29f..019050cd20 100644 --- a/src/openhuman/inference/mod.rs +++ b/src/openhuman/inference/mod.rs @@ -12,6 +12,14 @@ //! The RPC surface is `inference.*`; old `local_ai_*` RPC names are resolved //! by the legacy alias layer for backwards compatibility. +/// `true` when the crate was compiled with the `inference` feature (the +/// default), i.e. the in-process whisper.cpp STT engine and the `cpal` audio +/// probe are linked. Lets tests and callers distinguish a slim/headless build +/// from the desktop build without naming gated symbols. When `false`, +/// `whisper-rs` and `cpal` are dropped from the dependency graph (verify with +/// `cargo tree -i whisper-rs` / `cargo tree -i cpal`). +pub const INFERENCE_COMPILED_IN: bool = cfg!(feature = "inference"); + pub mod device; pub mod http; pub mod local; diff --git a/src/openhuman/inference/voice/mod.rs b/src/openhuman/inference/voice/mod.rs index b436408640..4b35ab3146 100644 --- a/src/openhuman/inference/voice/mod.rs +++ b/src/openhuman/inference/voice/mod.rs @@ -9,4 +9,9 @@ pub mod hallucination; pub mod local_speech; pub mod local_transcribe; pub mod postprocess; +// The dictation WebSocket handler (`handle_dictation_ws`) is the module's whole +// public surface and axum-only, and its sole caller is the gated core HTTP +// router (`core::jsonrpc::dictation_ws_handler`). The module is therefore +// exclusive to the `http-server` feature (#5048) — nothing else references it. +#[cfg(feature = "http-server")] pub mod streaming; diff --git a/src/openhuman/mcp_server/local.rs b/src/openhuman/mcp_server/local.rs index e1acbc7d8b..af63f17e3f 100644 --- a/src/openhuman/mcp_server/local.rs +++ b/src/openhuman/mcp_server/local.rs @@ -14,9 +14,17 @@ use std::net::SocketAddr; +// The in-process HTTP MCP server is axum-only, so everything that starts it is +// gated with `http-server` (#5048). `LocalMcpEndpoint` (an inert addr+token +// record) and a disabled-error `ensure_local_http` stay compiled so the +// always-on Claude-Code driver keeps a stable call surface — with the feature +// off, `ensure_local_http` returns a built-without-http-server error. +#[cfg(feature = "http-server")] use tokio::sync::Mutex; +#[cfg(feature = "http-server")] use uuid::Uuid; +#[cfg(feature = "http-server")] use super::http::{run_http_reporting, HttpServerConfig}; /// Endpoint of the running in-process MCP server: its loopback address and the @@ -27,6 +35,7 @@ pub struct LocalMcpEndpoint { pub token: String, } +#[cfg(feature = "http-server")] struct RunningServer { endpoint: LocalMcpEndpoint, /// Liveness handle. If the server task has exited (bind drop, fatal error), @@ -35,9 +44,11 @@ struct RunningServer { handle: tokio::task::JoinHandle<()>, } +#[cfg(feature = "http-server")] static LOCAL_SERVER: Mutex> = Mutex::const_new(None); /// 256-bit random bearer token (two v4 UUIDs, hex). Loopback-only, per process. +#[cfg(feature = "http-server")] fn mint_token() -> String { format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()) } @@ -47,6 +58,7 @@ fn mint_token() -> String { /// and reused across turns; if the previous instance has exited, it is /// transparently restarted (and a fresh token minted) so callers never receive /// a stale, dead URL. +#[cfg(feature = "http-server")] pub async fn ensure_local_http() -> anyhow::Result { let mut guard = LOCAL_SERVER.lock().await; @@ -82,7 +94,38 @@ pub async fn ensure_local_http() -> anyhow::Result { Ok(endpoint) } -#[cfg(test)] +/// Disabled build: the in-process HTTP MCP server is axum-only and needs the +/// `http-server` feature (#5048). Returns an error so the always-on Claude-Code +/// driver falls back gracefully instead of pointing at a server that was never +/// started. Keeps `ensure_local_http` resolvable under `mcp` in both builds. +#[cfg(not(feature = "http-server"))] +pub async fn ensure_local_http() -> anyhow::Result { + Err(anyhow::anyhow!( + "in-process MCP HTTP server unavailable: built without the http-server feature" + )) +} + +// The real-server tests below are `http-server`-gated; this pins the slim +// build's disabled fallback so both feature branches are covered by the matrix. +#[cfg(all(test, not(feature = "http-server")))] +mod disabled_tests { + use super::*; + + #[tokio::test] + async fn ensure_local_http_reports_unavailable_without_http_server() { + let err = ensure_local_http() + .await + .expect_err("slim build without `http-server` must not start a server"); + assert!( + err.to_string().contains("http-server feature"), + "error must name the missing feature, got: {err}" + ); + } +} + +// Every test here starts the real HTTP server (`ensure_local_http`) or mints a +// token, both gated, so the module gates in lockstep (#5048). +#[cfg(all(test, feature = "http-server"))] mod tests { use super::*; diff --git a/src/openhuman/mcp_server/mod.rs b/src/openhuman/mcp_server/mod.rs index ed2ae49723..944f63b17b 100644 --- a/src/openhuman/mcp_server/mod.rs +++ b/src/openhuman/mcp_server/mod.rs @@ -22,7 +22,12 @@ //! `Value` record consumed by the always-compiled `tool_registry` — is the //! same real type in both builds and cannot drift. -#[cfg(feature = "mcp")] +// The Streamable-HTTP + SSE transport is axum-only, so it needs BOTH `mcp` and +// `http-server` (#5048). The stdio transport (below) works under `mcp` alone; +// `local`/`stdio` gate their own HTTP-serve paths so `openhuman mcp` (stdio) +// and the Claude-Code in-process MCP bridge still degrade gracefully when +// `http-server` is off. +#[cfg(all(feature = "mcp", feature = "http-server"))] mod http; #[cfg(feature = "mcp")] mod local; @@ -43,7 +48,7 @@ mod write_dispatch; // so `McpToolSpec` survives the gate (see the module note above). mod tools; -#[cfg(feature = "mcp")] +#[cfg(all(feature = "mcp", feature = "http-server"))] pub use http::{run_http, run_http_reporting, HttpServerConfig}; #[cfg(feature = "mcp")] pub use local::{ensure_local_http, LocalMcpEndpoint}; diff --git a/src/openhuman/mcp_server/stdio.rs b/src/openhuman/mcp_server/stdio.rs index 73f3cee149..148deb2711 100644 --- a/src/openhuman/mcp_server/stdio.rs +++ b/src/openhuman/mcp_server/stdio.rs @@ -1,9 +1,14 @@ use anyhow::{bail, Result}; +// `SocketAddr` + the `http` transport are only reached by the `--transport http` +// arm, which is axum-only and gated with `http-server` (#5048). The stdio arm +// (the default, used by Claude Desktop / Cursor) works under `mcp` alone. +#[cfg(feature = "http-server")] use std::net::SocketAddr; use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; use crate::core::logging::CliLogDefault; +#[cfg(feature = "http-server")] use super::http::{run_http, HttpServerConfig}; use super::{protocol, session::McpSession}; @@ -84,17 +89,28 @@ pub fn run_stdio_from_cli(args: &[String]) -> Result<()> { rt.block_on(async { run_stdio(tokio::io::stdin(), tokio::io::stdout()).await })?; } McpTransport::Http => { - let bind_addr: SocketAddr = format!("{bind_host}:{port}").parse().map_err(|err| { - anyhow::anyhow!("invalid bind address `{bind_host}:{port}`: {err}") - })?; - log::debug!( - "[mcp_server] starting HTTP/SSE MCP server bind={bind_addr} auth={}", - auth_token.is_some() - ); - rt.block_on(run_http(HttpServerConfig { - bind_addr, - auth_token, - }))?; + #[cfg(feature = "http-server")] + { + let bind_addr: SocketAddr = + format!("{bind_host}:{port}").parse().map_err(|err| { + anyhow::anyhow!("invalid bind address `{bind_host}:{port}`: {err}") + })?; + log::debug!( + "[mcp_server] starting HTTP/SSE MCP server bind={bind_addr} auth={}", + auth_token.is_some() + ); + rt.block_on(run_http(HttpServerConfig { + bind_addr, + auth_token, + }))?; + } + // Built without the axum transport (#5048): the stdio path above + // still works; `--transport http` reports the build fact. + #[cfg(not(feature = "http-server"))] + { + let _ = (&bind_host, port, &auth_token); + bail!("mcp --transport http unavailable: built without the http-server feature"); + } } } Ok(()) diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 72ac30aef8..4fcfb05514 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -57,6 +57,12 @@ pub mod flows; pub mod harness_init; pub mod health; pub mod heartbeat; +// The whole http_host domain is an axum static-directory server, so it is +// exclusive to the `http-server` feature (#5048). Its only outside reference is +// the controller-registration push in `core::all`, itself gated in lockstep, so +// no stub facade is needed — a slim build simply omits the `http_host.*` RPC +// surface (unknown-method over `/rpc`, absent from `/schema`). +#[cfg(feature = "http-server")] pub mod http_host; #[cfg(feature = "media")] pub mod image; diff --git a/src/openhuman/text_input/mod.rs b/src/openhuman/text_input/mod.rs index c35c6cd63d..93d560c4b0 100644 --- a/src/openhuman/text_input/mod.rs +++ b/src/openhuman/text_input/mod.rs @@ -4,7 +4,28 @@ //! Thin orchestration layer consumed by autocomplete, voice control, and other //! text-aware features. All platform work delegates to `accessibility::*`. +// `openhuman text-input run` stands up an axum JSON-RPC dev server, so the +// whole CLI is exclusive to the `http-server` feature (#5048). When it is off, +// an inline stub keeps `text_input::cli::run_text_input_command` resolvable for +// the always-compiled dispatch arm in `core::cli` (mcp precedent) and returns a +// built-without-the-feature error. The axum-free `ops` (read/insert/ghost, +// called from `voice::server`) and controllers stay compiled either way. +#[cfg(feature = "http-server")] pub(crate) mod cli; +#[cfg(not(feature = "http-server"))] +pub(crate) mod cli { + //! Disabled `text-input` CLI facade — the real server needs `http-server`. + use anyhow::Result; + + /// Stub for [`super::cli::run_text_input_command`] when built without the + /// `http-server` feature. Mirrors the real signature so `core::cli`'s + /// dispatch arm compiles unchanged. + pub(crate) fn run_text_input_command(_args: &[String]) -> Result<()> { + Err(anyhow::anyhow!( + "text-input server unavailable: built without the http-server feature" + )) + } +} pub mod ops; mod schemas; mod types; diff --git a/src/openhuman/tools/impl/mod.rs b/src/openhuman/tools/impl/mod.rs index fa92ff96c5..c4bdb48947 100644 --- a/src/openhuman/tools/impl/mod.rs +++ b/src/openhuman/tools/impl/mod.rs @@ -5,17 +5,21 @@ pub mod browser; // (not error-degraded) when off. #[cfg(feature = "desktop-automation")] pub mod computer; +#[cfg(feature = "documents")] pub mod document; pub mod filesystem; pub mod network; +#[cfg(feature = "documents")] pub mod presentation; pub mod system; pub use browser::*; #[cfg(feature = "desktop-automation")] pub use computer::*; +#[cfg(feature = "documents")] pub use document::DocumentTool; pub use filesystem::*; pub use network::*; +#[cfg(feature = "documents")] pub use presentation::PresentationTool; pub use system::*; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 1b9f89430c..a892e90310 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -783,6 +783,7 @@ pub fn all_tools_with_runtime( // backed) as of the #2780-follow-up rust-engine refactor — no // managed Python venv, no first-call install latency. Always // registered. + #[cfg(feature = "documents")] tools.push(Box::new(PresentationTool::new( root_config.workspace_dir.clone(), security.clone(), @@ -792,6 +793,7 @@ pub fn all_tools_with_runtime( // (docx-rs backed) — no managed runtime, no subprocess — emitting a // real `.docx` through the same byte-agnostic artifact pipeline as // the presentation tool. Always registered; same constructor shape. + #[cfg(feature = "documents")] tools.push(Box::new(DocumentTool::new( root_config.workspace_dir.clone(), security.clone(), diff --git a/src/openhuman/tools/ops_tests.rs b/src/openhuman/tools/ops_tests.rs index 414830009c..33f8485298 100644 --- a/src/openhuman/tools/ops_tests.rs +++ b/src/openhuman/tools/ops_tests.rs @@ -294,6 +294,76 @@ fn media_tools_absent_when_feature_off() { ); } +// Compile-time `documents` feature gate (#5048). The office-document agent +// tools (`generate_presentation`, `generate_document`) are present only when +// the `documents` feature is compiled in — leaf gate, no stub facade, so the +// disabled build must drop both from the tool list entirely. +#[cfg(feature = "documents")] +#[test] +fn document_tools_registered_when_feature_on() { + let tmp = TempDir::new().unwrap(); + let security = Arc::new(SecurityPolicy::default()); + let mem = test_memory(&tmp); + let browser = BrowserConfig { + enabled: false, + ..BrowserConfig::default() + }; + let http = crate::openhuman::config::HttpRequestConfig::default(); + let cfg = test_config(&tmp); + let tools = all_tools( + Arc::new(Config::default()), + &security, + AuditLogger::disabled(), + mem, + &browser, + &http, + tmp.path(), + &HashMap::new(), + &cfg, + ); + let names = tool_names(&tools); + assert!( + names.iter().any(|n| n == "generate_presentation"), + "generate_presentation must register with `documents` on; got: {names:?}" + ); + assert!( + names.iter().any(|n| n == "generate_document"), + "generate_document must register with `documents` on; got: {names:?}" + ); +} + +#[cfg(not(feature = "documents"))] +#[test] +fn document_tools_absent_when_feature_off() { + let tmp = TempDir::new().unwrap(); + let security = Arc::new(SecurityPolicy::default()); + let mem = test_memory(&tmp); + let browser = BrowserConfig { + enabled: false, + ..BrowserConfig::default() + }; + let http = crate::openhuman::config::HttpRequestConfig::default(); + let cfg = test_config(&tmp); + let tools = all_tools( + Arc::new(Config::default()), + &security, + AuditLogger::disabled(), + mem, + &browser, + &http, + tmp.path(), + &HashMap::new(), + &cfg, + ); + let names = tool_names(&tools); + assert!( + !names + .iter() + .any(|n| n == "generate_presentation" || n == "generate_document"), + "no document tools may register when the `documents` feature is off; got: {names:?}" + ); +} + #[test] fn all_tools_registers_gitbooks_when_enabled() { let tmp = TempDir::new().unwrap(); diff --git a/src/openhuman/voice/mod.rs b/src/openhuman/voice/mod.rs index 50b3508d5d..7282ee0fea 100644 --- a/src/openhuman/voice/mod.rs +++ b/src/openhuman/voice/mod.rs @@ -72,7 +72,11 @@ pub use crate::openhuman::inference::voice::local_speech; pub use crate::openhuman::inference::voice::local_transcribe; #[cfg(feature = "voice")] pub use crate::openhuman::inference::voice::postprocess; -#[cfg(feature = "voice")] +// `streaming` (the dictation WebSocket handler) is axum-only, so it is compiled +// only when BOTH `voice` and `http-server` are on (#5048). With `http-server` +// off, its sole caller (the gated core HTTP router) is absent too, so nothing +// needs `voice::streaming`. +#[cfg(all(feature = "voice", feature = "http-server"))] pub use crate::openhuman::inference::voice::streaming; #[cfg(feature = "voice")] diff --git a/src/openhuman/voice/stub.rs b/src/openhuman/voice/stub.rs index d9ee2d2e2d..91928449c1 100644 --- a/src/openhuman/voice/stub.rs +++ b/src/openhuman/voice/stub.rs @@ -187,6 +187,11 @@ pub mod always_on { // streaming::handle_dictation_ws (re-exported from inference::voice in real) // --------------------------------------------------------------------------- +// axum-only, and its sole caller (`core::jsonrpc::dictation_ws_handler`) is +// gated the same way, so the stub's dictation-WS surface is exclusive to the +// `http-server` feature too (#5048): voice-OFF + http-server-OFF needs no +// `voice::streaming` at all. +#[cfg(feature = "http-server")] pub mod streaming { use std::sync::Arc; From 06be00c75e60eb27e295bc8a4743b3b681912b5e Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:33:45 +0530 Subject: [PATCH 32/56] fix(tinyplace): wire handle transfer end-to-end (Closes #4929) (#4998) Co-authored-by: github-actions[bot] Co-authored-by: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Co-authored-by: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Co-authored-by: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Co-authored-by: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com> Co-authored-by: Steven Enamakel Co-authored-by: CodeGhost21 <164498022+CodeGhost21@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: sanil-23 Co-authored-by: M3gA-Mind Co-authored-by: oxoxDev --- .../components/TransferHandleModal.test.tsx | 95 ++++ .../components/TransferHandleModal.tsx | 155 ++++++ .../agentworld/pages/ProfilesSection.test.tsx | 70 ++- app/src/agentworld/pages/ProfilesSection.tsx | 34 ++ app/src/lib/agentworld/invokeApiClient.ts | 16 + app/src/lib/i18n/ar.ts | 12 + app/src/lib/i18n/bn.ts | 14 +- app/src/lib/i18n/de.ts | 12 + app/src/lib/i18n/en.ts | 12 + app/src/lib/i18n/es.ts | 12 + app/src/lib/i18n/fr.ts | 12 + app/src/lib/i18n/hi.ts | 12 + app/src/lib/i18n/id.ts | 12 + app/src/lib/i18n/it.ts | 12 + app/src/lib/i18n/ko.ts | 12 + app/src/lib/i18n/pl.ts | 12 + app/src/lib/i18n/pt.ts | 12 + app/src/lib/i18n/ru.ts | 12 + app/src/lib/i18n/zh-CN.ts | 10 + src/openhuman/tinyplace/manifest.rs | 477 ++++++++++++++++++ src/openhuman/tinyplace/schemas.rs | 35 ++ 21 files changed, 1048 insertions(+), 2 deletions(-) create mode 100644 app/src/agentworld/components/TransferHandleModal.test.tsx create mode 100644 app/src/agentworld/components/TransferHandleModal.tsx diff --git a/app/src/agentworld/components/TransferHandleModal.test.tsx b/app/src/agentworld/components/TransferHandleModal.test.tsx new file mode 100644 index 0000000000..be9f250d58 --- /dev/null +++ b/app/src/agentworld/components/TransferHandleModal.test.tsx @@ -0,0 +1,95 @@ +/** + * Tests for TransferHandleModal (GH-4929) — the confirm + execute dialog for a + * Tiny Place handle transfer. A transfer is destructive/irreversible, so these + * assert the wiring to `apiClient.registry.transfer`, that confirm is gated on a + * recipient, and that the flow fails CLOSED (error keeps the dialog open and + * never reports success). + * + * All handles/recipients are generic placeholders, never real identities. + */ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { renderWithProviders } from '../../test/test-utils'; +import { apiClient } from '../AgentWorldShell'; +import TransferHandleModal from './TransferHandleModal'; + +vi.mock('../AgentWorldShell', () => ({ apiClient: { registry: { transfer: vi.fn() } } })); + +const transfer = vi.mocked(apiClient.registry.transfer); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function setup() { + const onClose = vi.fn(); + const onTransferred = vi.fn(); + renderWithProviders( + + ); + return { onClose, onTransferred }; +} + +describe('TransferHandleModal', () => { + test('shows the handle + irreversible warning and gates confirm on a recipient', () => { + setup(); + expect(screen.getByTestId('transfer-handle-modal')).toBeInTheDocument(); + expect(screen.getByText('@alpha')).toBeInTheDocument(); + expect(screen.getByText(/permanent and cannot be undone/i)).toBeInTheDocument(); + // Confirm is disabled until a recipient is entered. + expect(screen.getByTestId('transfer-handle-confirm')).toBeDisabled(); + }); + + test('keeps confirm disabled until the exact handle is re-typed', async () => { + const user = userEvent.setup(); + setup(); + const confirmBtn = screen.getByTestId('transfer-handle-confirm'); + // A recipient alone is not enough for a destructive action. + await user.type(screen.getByPlaceholderText(/Recipient @handle/i), 'bravo'); + expect(confirmBtn).toBeDisabled(); + // A wrong handle keeps it disabled. + await user.type(screen.getByTestId('transfer-handle-confirm-input'), 'wrong'); + expect(confirmBtn).toBeDisabled(); + // The exact handle (case- and @-insensitive) enables it. + await user.clear(screen.getByTestId('transfer-handle-confirm-input')); + await user.type(screen.getByTestId('transfer-handle-confirm-input'), '@ALPHA'); + expect(confirmBtn).toBeEnabled(); + }); + + test('confirming transfers to the resolved recipient, then closes on success', async () => { + const user = userEvent.setup(); + transfer.mockResolvedValueOnce({ identity: { username: 'alpha' } as never }); + const { onClose, onTransferred } = setup(); + + await user.type(screen.getByPlaceholderText(/Recipient @handle/i), '@bravo'); + // The irreversible action is gated behind re-typing the handle. + await user.type(screen.getByTestId('transfer-handle-confirm-input'), '@alpha'); + await user.click(screen.getByTestId('transfer-handle-confirm')); + + // Leading @ is stripped before the RPC; handle passed through verbatim. + await waitFor(() => expect(transfer).toHaveBeenCalledWith('alpha', 'bravo')); + await waitFor(() => expect(onTransferred).toHaveBeenCalledTimes(1)); + expect(onClose).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId('transfer-handle-error')).not.toBeInTheDocument(); + }); + + test('fails closed: on error it shows the message and does not report success', async () => { + const user = userEvent.setup(); + transfer.mockRejectedValueOnce(new Error('recipient handle is not registered on tiny.place')); + const { onClose, onTransferred } = setup(); + + await user.type(screen.getByPlaceholderText(/Recipient @handle/i), 'bravo'); + await user.type(screen.getByTestId('transfer-handle-confirm-input'), 'alpha'); + await user.click(screen.getByTestId('transfer-handle-confirm')); + + await waitFor(() => + expect(screen.getByTestId('transfer-handle-error')).toHaveTextContent(/not registered/i) + ); + // Fail closed: no success callbacks, dialog stays open. + expect(onTransferred).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getByTestId('transfer-handle-modal')).toBeInTheDocument(); + }); +}); diff --git a/app/src/agentworld/components/TransferHandleModal.tsx b/app/src/agentworld/components/TransferHandleModal.tsx new file mode 100644 index 0000000000..98464ca2bb --- /dev/null +++ b/app/src/agentworld/components/TransferHandleModal.tsx @@ -0,0 +1,155 @@ +/** + * TransferHandleModal — confirm + execute a Tiny Place handle transfer (GH-4929). + * + * A handle transfer is DESTRUCTIVE and irreversible for the sender: on success + * the recipient becomes the handle's sole owner. So this modal states that + * plainly, requires an explicit recipient, requires the user to re-type the + * handle to confirm intent, and takes an explicit confirm click — and it fails + * **closed**: on any error it keeps the dialog open with the message and never + * reports success. The core handler resolves the recipient @handle and + * read-back-confirms the new owner before this promise resolves, so a resolved + * transfer means the reassignment actually landed. + */ +import debugFactory from 'debug'; +import { useCallback, useState } from 'react'; + +import Button from '../../components/ui/Button'; +import { ModalShell } from '../../components/ui/ModalShell'; +import { useT } from '../../lib/i18n/I18nContext'; +import { apiClient } from '../AgentWorldShell'; + +// Namespaced already ('agentworld:identity'), so messages carry no prefix. +const debug = debugFactory('agentworld:identity'); + +export interface TransferHandleModalProps { + /** The handle being transferred away (without a leading @). */ + handle: string; + onClose: () => void; + /** Called after a confirmed, read-back-verified transfer. */ + onTransferred: () => void; +} + +/** Normalize a handle for comparison: strip leading @, trim, lowercase. */ +function normalizeHandle(value: string): string { + return value.trim().replace(/^@+/, '').toLowerCase(); +} + +export default function TransferHandleModal({ + handle, + onClose, + onTransferred, +}: TransferHandleModalProps) { + const { t } = useT(); + const [recipient, setRecipient] = useState(''); + const [confirmText, setConfirmText] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleClean = handle.replace(/^@+/, ''); + // Guard the irreversible action: the user must re-type the exact handle. + const confirmMatches = normalizeHandle(confirmText) === normalizeHandle(handleClean); + + const submit = useCallback(async () => { + const target = recipient.trim().replace(/^@+/, ''); + if (!target) { + setError(t('agentWorld.transferHandle.recipientRequired')); + return; + } + // Belt-and-suspenders: the button is disabled without a match, but never + // execute a destructive transfer unless the typed confirmation matches. + if (!confirmMatches) { + setError(t('agentWorld.transferHandle.confirmMismatch')); + return; + } + setSubmitting(true); + setError(null); + // Never log the handle or recipient — both identify a user. + debug('handle transfer requested'); + try { + // Send the normalized handle (not the raw prop) so the invariant is local + // and doesn't rest on every caller pre-cleaning the value (#4998 review). + await apiClient.registry.transfer(handleClean, target); + debug('handle transfer confirmed'); + onTransferred(); + onClose(); + } catch (err) { + // Fail closed: keep the dialog open, show why, report no success. + // Log only the status (no raw error — it can carry backend/SDK detail); + // the raw message still surfaces in the UI via setError. + debug('handle transfer failed'); + setError(String(err)); + setSubmitting(false); + } + }, [recipient, confirmMatches, handleClean, t, onTransferred, onClose]); + + return ( + undefined : onClose}> +
+

@{handleClean}

+

+ {t('agentWorld.transferHandle.warning')} +

+ + { + setRecipient(e.target.value); + setError(null); + }} + disabled={submitting} + placeholder={t('agentWorld.transferHandle.recipientPlaceholder')} + aria-label={t('agentWorld.transferHandle.recipientPlaceholder')} + className="w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-content placeholder-content-faint outline-none focus:border-primary-500" + /> + + {/* Type-to-confirm guard for the irreversible action. */} +
+

+ {t('agentWorld.transferHandle.confirmLabel')} +

+ { + setConfirmText(e.target.value); + setError(null); + }} + disabled={submitting} + placeholder={`@${handleClean}`} + aria-label={t('agentWorld.transferHandle.confirmLabel')} + data-testid="transfer-handle-confirm-input" + className="w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-content placeholder-content-faint outline-none focus:border-primary-500" + /> +
+ + {error && ( +

+ {error} +

+ )} + +
+ + +
+
+
+ ); +} diff --git a/app/src/agentworld/pages/ProfilesSection.test.tsx b/app/src/agentworld/pages/ProfilesSection.test.tsx index ddcea8893f..5469436d8d 100644 --- a/app/src/agentworld/pages/ProfilesSection.test.tsx +++ b/app/src/agentworld/pages/ProfilesSection.test.tsx @@ -19,7 +19,7 @@ vi.mock('../AgentWorldShell', () => ({ apiClient: { directory: { reverse: vi.fn() }, follows: { stats: vi.fn() }, - registry: { export: vi.fn(), assignPrimary: vi.fn() }, + registry: { export: vi.fn(), assignPrimary: vi.fn(), transfer: vi.fn() }, graphql: { user: vi.fn() }, users: { get: vi.fn(), updateProfile: vi.fn() }, }, @@ -362,6 +362,74 @@ function makeProfile(overrides: Partial = {}): GqlProfile { }; } +// GH-4929: a non-primary owned handle offers an enabled Transfer action that +// opens the destructive-confirm modal. #4998 M3: a primary (active) handle now +// renders a DISABLED Transfer control with an explanation (make another handle +// active first) instead of silently omitting it. A handle with no explicit +// primary flag (unknown state) shows no Transfer control at all. +describe('handle transfer action', () => { + test('opens the transfer confirm modal for a non-primary owned handle', async () => { + graphqlUser.mockResolvedValueOnce( + makeProfile({ + displayName: 'Owner', + identities: [ + { ...minimalIdentity, username: 'primaryhandle', cryptoId: SOLANA_ADDR, primary: true }, + { ...minimalIdentity, username: 'giftme', cryptoId: SOLANA_ADDR, primary: false }, + ], + }) + ); + render(); + + // Both handles render a Transfer control (#4998 M3): the non-primary one is + // enabled, the primary one disabled. Pick the enabled one — it opens the modal. + const transferButtons = await screen.findAllByRole('button', { name: 'Transfer' }); + const enabled = transferButtons.find(b => !(b as HTMLButtonElement).disabled); + expect(enabled, 'the non-primary handle must expose an enabled Transfer control').toBeDefined(); + fireEvent.click(enabled!); + + // The destructive-confirm modal opens with its explicit confirm control. + expect(await screen.findByTestId('transfer-handle-modal')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Transfer handle' })).toBeInTheDocument(); + }); + + test('shows a disabled Transfer control with an explanation for a primary handle (#4998 M3)', async () => { + graphqlUser.mockResolvedValueOnce( + makeProfile({ + displayName: 'Owner', + identities: [ + { ...minimalIdentity, username: 'onlyprimary', cryptoId: SOLANA_ADDR, primary: true }, + ], + }) + ); + render(); + + // Rendered, but disabled and explained — not an invisible dead end. + const transferButton = await screen.findByRole('button', { name: 'Transfer' }); + expect(transferButton).toBeDisabled(); + expect(transferButton).toHaveAttribute( + 'title', + 'A primary handle cannot be transferred. Make another handle active first.' + ); + + // Clicking the locked control must NOT open the destructive modal. + fireEvent.click(transferButton); + expect(screen.queryByTestId('transfer-handle-modal')).not.toBeInTheDocument(); + }); + + test('shows no Transfer control when the primary flag is absent (unknown state)', async () => { + graphqlUser.mockResolvedValueOnce( + makeProfile({ + displayName: 'Owner', + identities: [{ ...minimalIdentity, username: 'unknownstate', cryptoId: SOLANA_ADDR }], + }) + ); + render(); + + expect(await screen.findByRole('button', { name: 'Make active' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Transfer' })).not.toBeInTheDocument(); + }); +}); + describe('graphql-enriched profile card', () => { test('renders rich profile from graphql.user when available', async () => { graphqlUser.mockResolvedValueOnce( diff --git a/app/src/agentworld/pages/ProfilesSection.tsx b/app/src/agentworld/pages/ProfilesSection.tsx index 29268e8c97..f86bcce224 100644 --- a/app/src/agentworld/pages/ProfilesSection.tsx +++ b/app/src/agentworld/pages/ProfilesSection.tsx @@ -22,6 +22,7 @@ import { import { useT } from '../../lib/i18n/I18nContext'; import { fetchWalletStatus } from '../../services/walletApi'; import { apiClient } from '../AgentWorldShell'; +import TransferHandleModal from '../components/TransferHandleModal'; const log = debug('agentworld:profile'); @@ -348,6 +349,8 @@ function AgentProfileCard({ data, onSwitched }: { data: ProfileData; onSwitched? // Handle currently being promoted to primary (in-flight), and any error. const [switchingHandle, setSwitchingHandle] = useState(null); const [switchError, setSwitchError] = useState(null); + // The owned handle whose transfer modal is open (without @), or null. + const [transferHandle, setTransferHandle] = useState(null); // ── Extract display fields from either data source ───────────────────────── const isGraphql = data.source === 'graphql'; @@ -568,6 +571,29 @@ function AgentProfileCard({ data, onSwitched }: { data: ProfileData; onSwitched? : 'Make active'} )} + {/* Transfer is destructive/irreversible — the modal confirms + intent and fails closed. A primary (active) handle is + locked from sale/transfer server-side, so it renders + disabled with an explanation of the path out (make another + handle active first) rather than silently vanishing (#4998 + M3). Only an explicit non-primary handle is transferable. */} + {id.primary === false && ( + + )} + {id.primary === true && ( + + )} {id.status} @@ -581,6 +607,14 @@ function AgentProfileCard({ data, onSwitched }: { data: ProfileData; onSwitched?
)} + {transferHandle && ( + setTransferHandle(null)} + onTransferred={() => onSwitched?.()} + /> + )} + {followStats && (
diff --git a/app/src/lib/agentworld/invokeApiClient.ts b/app/src/lib/agentworld/invokeApiClient.ts index 1023e403a8..e9386f5bf1 100644 --- a/app/src/lib/agentworld/invokeApiClient.ts +++ b/app/src/lib/agentworld/invokeApiClient.ts @@ -336,6 +336,12 @@ export interface AssignPrimaryResult { [key: string]: unknown; } +/** Result of a handle transfer: the Identity now owned by the recipient. */ +export interface TransferHandleResult { + identity?: Identity; + [key: string]: unknown; +} + // -- Registry export types ------------------------------------------------ export interface LedgerReference { @@ -1863,6 +1869,16 @@ export function createInvokeApiClient() { */ assignPrimary: (name: string) => call('openhuman.tinyplace_registry_assign_primary', { name }), + /** + * Transfer one of the wallet's handles to another tiny.place identity + * (#4929). DESTRUCTIVE + irreversible: on success the `recipient` handle's + * owner becomes the sole owner of `name`. The recipient is resolved from + * their @handle server-side; an unregistered recipient or an unconfirmed + * read-back fails closed. The owning wallet is proven by the + * signer-attached signature, not by params. + */ + transfer: (name: string, recipient: string) => + call('openhuman.tinyplace_registry_transfer', { name, recipient }), }, directoryIdentities: { /** List identity listings from the directory. */ diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 33b2e136d4..45986f0399 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -498,6 +498,18 @@ const messages: TranslationMap = { 'agentWorld.directory.profile.loadError': 'تعذّر تحميل الملف الشخصي الكامل.', 'agentWorld.identities': 'الهويات', 'agentWorld.profiles': 'الملفات الشخصية', + 'agentWorld.transferHandle.action': 'نقل', + 'agentWorld.transferHandle.title': 'نقل المعرّف', + 'agentWorld.transferHandle.warning': + 'نقل المعرّف نهائي ولا يمكن التراجع عنه. يصبح المستلم مالكه الوحيد.', + 'agentWorld.transferHandle.recipientPlaceholder': 'معرّف@ المستلم', + 'agentWorld.transferHandle.confirm': 'نقل المعرّف', + 'agentWorld.transferHandle.submitting': 'جارٍ النقل…', + 'agentWorld.transferHandle.recipientRequired': 'أدخل معرّف المستلم.', + 'agentWorld.transferHandle.confirmLabel': 'اكتب المعرّف للتأكيد', + 'agentWorld.transferHandle.confirmMismatch': 'المعرّف المكتوب غير مطابق.', + 'agentWorld.transferHandle.primaryLocked': + 'لا يمكن نقل المعرّف الأساسي. اجعل معرّفًا آخر نشطًا أولًا.', 'agentWorld.profile.edit': 'تعديل الملف الشخصي', 'agentWorld.profile.displayName': 'الاسم المعروض', 'agentWorld.profile.bio': 'نبذة', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 917a5e74c9..aa378de5e0 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -487,7 +487,7 @@ const messages: TranslationMap = { 'agentWorld.profileViewer.notFoundTitle': 'প্রোফাইল পাওয়া যায়নি', 'agentWorld.profileViewer.notFoundBody': 'এই হ্যান্ডেলের জন্য এখনও কোনো প্রকাশিত প্রোফাইল নেই।', 'agentWorld.profileViewer.errorTitle': 'প্রোফাইল লোড করা যায়নি', - 'agentWorld.profileViewer.follow': 'অনুসরণ', + 'agentWorld.profileViewer.follow': 'অনুসরণ করুন', 'agentWorld.profileViewer.following': 'অনুসরণ করছেন', 'agentWorld.profileViewer.copyLink': 'লিঙ্ক কপি করুন', 'agentWorld.profileViewer.linkCopied': 'লিঙ্ক কপি হয়েছে', @@ -514,6 +514,18 @@ const messages: TranslationMap = { 'agentWorld.directory.profile.loadError': 'সম্পূর্ণ প্রোফাইল লোড করা যায়নি।', 'agentWorld.identities': 'পরিচয়', 'agentWorld.profiles': 'প্রোফাইল', + 'agentWorld.transferHandle.action': 'স্থানান্তর', + 'agentWorld.transferHandle.title': 'হ্যান্ডেল স্থানান্তর', + 'agentWorld.transferHandle.warning': + 'হ্যান্ডেল স্থানান্তর স্থায়ী এবং এটি ফেরানো যায় না। প্রাপক একমাত্র মালিক হয়ে যাবেন।', + 'agentWorld.transferHandle.recipientPlaceholder': 'প্রাপকের @handle', + 'agentWorld.transferHandle.confirm': 'হ্যান্ডেল স্থানান্তর', + 'agentWorld.transferHandle.submitting': 'স্থানান্তর করা হচ্ছে…', + 'agentWorld.transferHandle.recipientRequired': 'প্রাপকের হ্যান্ডেল লিখুন।', + 'agentWorld.transferHandle.confirmLabel': 'নিশ্চিত করতে হ্যান্ডেলটি টাইপ করুন', + 'agentWorld.transferHandle.confirmMismatch': 'টাইপ করা হ্যান্ডেল মিলছে না।', + 'agentWorld.transferHandle.primaryLocked': + 'প্রাথমিক হ্যান্ডেল স্থানান্তর করা যায় না। প্রথমে অন্য একটি হ্যান্ডেল সক্রিয় করুন।', 'agentWorld.profile.edit': 'প্রোফাইল সম্পাদনা', 'agentWorld.profile.displayName': 'প্রদর্শন নাম', 'agentWorld.profile.bio': 'পরিচিতি', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index aedf03a3c3..a4caa901c3 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -541,6 +541,18 @@ const messages: TranslationMap = { 'agentWorld.directory.profile.loadError': 'Vollständiges Profil konnte nicht geladen werden.', 'agentWorld.identities': 'Identitäten', 'agentWorld.profiles': 'Profile', + 'agentWorld.transferHandle.action': 'Übertragen', + 'agentWorld.transferHandle.title': 'Handle übertragen', + 'agentWorld.transferHandle.warning': + 'Das Übertragen eines Handles ist dauerhaft und kann nicht rückgängig gemacht werden. Der Empfänger wird alleiniger Eigentümer.', + 'agentWorld.transferHandle.recipientPlaceholder': 'Empfänger-@handle', + 'agentWorld.transferHandle.confirm': 'Handle übertragen', + 'agentWorld.transferHandle.submitting': 'Wird übertragen…', + 'agentWorld.transferHandle.recipientRequired': 'Gib den Empfänger-Handle ein.', + 'agentWorld.transferHandle.confirmLabel': 'Zum Bestätigen den Handle eingeben', + 'agentWorld.transferHandle.confirmMismatch': 'Der eingegebene Handle stimmt nicht überein.', + 'agentWorld.transferHandle.primaryLocked': + 'Ein primäres Handle kann nicht übertragen werden. Mache zuerst ein anderes Handle aktiv.', 'agentWorld.profile.edit': 'Profil bearbeiten', 'agentWorld.profile.displayName': 'Anzeigename', 'agentWorld.profile.bio': 'Bio', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 32241d06c3..2f644781ea 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -221,6 +221,18 @@ const en: TranslationMap = { 'agentWorld.directory.profile.loadError': "Couldn't load the full profile.", 'agentWorld.identities': 'Identities', 'agentWorld.profiles': 'Profiles', + 'agentWorld.transferHandle.action': 'Transfer', + 'agentWorld.transferHandle.title': 'Transfer handle', + 'agentWorld.transferHandle.warning': + 'Transferring a handle is permanent and cannot be undone. The recipient becomes its sole owner.', + 'agentWorld.transferHandle.recipientPlaceholder': 'Recipient @handle', + 'agentWorld.transferHandle.confirm': 'Transfer handle', + 'agentWorld.transferHandle.submitting': 'Transferring…', + 'agentWorld.transferHandle.recipientRequired': 'Enter the recipient handle.', + 'agentWorld.transferHandle.confirmLabel': 'Type the handle to confirm', + 'agentWorld.transferHandle.confirmMismatch': "The typed handle doesn't match.", + 'agentWorld.transferHandle.primaryLocked': + 'A primary handle cannot be transferred. Make another handle active first.', 'agentWorld.profile.edit': 'Edit profile', 'agentWorld.profile.displayName': 'Display name', 'agentWorld.profile.bio': 'Bio', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 8da92e7990..c90000eab2 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -525,6 +525,18 @@ const messages: TranslationMap = { 'agentWorld.directory.profile.loadError': 'No se pudo cargar el perfil completo.', 'agentWorld.identities': 'Identidades', 'agentWorld.profiles': 'Perfiles', + 'agentWorld.transferHandle.action': 'Transferir', + 'agentWorld.transferHandle.title': 'Transferir handle', + 'agentWorld.transferHandle.warning': + 'Transferir un handle es permanente y no se puede deshacer. El destinatario se convierte en su único propietario.', + 'agentWorld.transferHandle.recipientPlaceholder': '@handle del destinatario', + 'agentWorld.transferHandle.confirm': 'Transferir handle', + 'agentWorld.transferHandle.submitting': 'Transfiriendo…', + 'agentWorld.transferHandle.recipientRequired': 'Introduce el handle del destinatario.', + 'agentWorld.transferHandle.confirmLabel': 'Escribe el handle para confirmar', + 'agentWorld.transferHandle.confirmMismatch': 'El handle escrito no coincide.', + 'agentWorld.transferHandle.primaryLocked': + 'Un identificador principal no se puede transferir. Activa primero otro identificador.', 'agentWorld.profile.edit': 'Editar perfil', 'agentWorld.profile.displayName': 'Nombre visible', 'agentWorld.profile.bio': 'Biografía', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 8ce9af17f6..7e48babb80 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -534,6 +534,18 @@ const messages: TranslationMap = { 'agentWorld.directory.profile.loadError': 'Impossible de charger le profil complet.', 'agentWorld.identities': 'Identités', 'agentWorld.profiles': 'Profils', + 'agentWorld.transferHandle.action': 'Transférer', + 'agentWorld.transferHandle.title': 'Transférer le handle', + 'agentWorld.transferHandle.warning': + "Le transfert d'un handle est définitif et irréversible. Le destinataire en devient le seul propriétaire.", + 'agentWorld.transferHandle.recipientPlaceholder': '@handle du destinataire', + 'agentWorld.transferHandle.confirm': 'Transférer le handle', + 'agentWorld.transferHandle.submitting': 'Transfert…', + 'agentWorld.transferHandle.recipientRequired': 'Saisissez le handle du destinataire.', + 'agentWorld.transferHandle.confirmLabel': 'Saisissez le handle pour confirmer', + 'agentWorld.transferHandle.confirmMismatch': 'Le handle saisi ne correspond pas.', + 'agentWorld.transferHandle.primaryLocked': + "Un identifiant principal ne peut pas être transféré. Activez d'abord un autre identifiant.", 'agentWorld.profile.edit': 'Modifier le profil', 'agentWorld.profile.displayName': 'Nom affiché', 'agentWorld.profile.bio': 'Bio', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index df6772c6fd..ad4b7b88de 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -514,6 +514,18 @@ const messages: TranslationMap = { 'agentWorld.directory.profile.loadError': 'पूरा प्रोफ़ाइल लोड नहीं हो सका।', 'agentWorld.identities': 'पहचान', 'agentWorld.profiles': 'प्रोफ़ाइल', + 'agentWorld.transferHandle.action': 'स्थानांतरित करें', + 'agentWorld.transferHandle.title': 'हैंडल स्थानांतरित करें', + 'agentWorld.transferHandle.warning': + 'हैंडल स्थानांतरण स्थायी है और इसे पूर्ववत नहीं किया जा सकता। प्राप्तकर्ता इसका एकमात्र स्वामी बन जाता है।', + 'agentWorld.transferHandle.recipientPlaceholder': 'प्राप्तकर्ता का @handle', + 'agentWorld.transferHandle.confirm': 'हैंडल स्थानांतरित करें', + 'agentWorld.transferHandle.submitting': 'स्थानांतरित किया जा रहा है…', + 'agentWorld.transferHandle.recipientRequired': 'प्राप्तकर्ता का हैंडल दर्ज करें।', + 'agentWorld.transferHandle.confirmLabel': 'पुष्टि के लिए हैंडल टाइप करें', + 'agentWorld.transferHandle.confirmMismatch': 'टाइप किया गया हैंडल मेल नहीं खाता।', + 'agentWorld.transferHandle.primaryLocked': + 'प्राथमिक हैंडल स्थानांतरित नहीं किया जा सकता। पहले कोई अन्य हैंडल सक्रिय करें।', 'agentWorld.profile.edit': 'प्रोफ़ाइल संपादित करें', 'agentWorld.profile.displayName': 'प्रदर्शित नाम', 'agentWorld.profile.bio': 'परिचय', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index d16a94f354..c100de5056 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -520,6 +520,18 @@ const messages: TranslationMap = { 'agentWorld.directory.profile.loadError': 'Tidak dapat memuat profil lengkap.', 'agentWorld.identities': 'Identitas', 'agentWorld.profiles': 'Profil', + 'agentWorld.transferHandle.action': 'Pindahkan', + 'agentWorld.transferHandle.title': 'Pindahkan handle', + 'agentWorld.transferHandle.warning': + 'Memindahkan handle bersifat permanen dan tidak dapat dibatalkan. Penerima menjadi satu-satunya pemilik.', + 'agentWorld.transferHandle.recipientPlaceholder': '@handle penerima', + 'agentWorld.transferHandle.confirm': 'Pindahkan handle', + 'agentWorld.transferHandle.submitting': 'Memindahkan…', + 'agentWorld.transferHandle.recipientRequired': 'Masukkan handle penerima.', + 'agentWorld.transferHandle.confirmLabel': 'Ketik handle untuk mengonfirmasi', + 'agentWorld.transferHandle.confirmMismatch': 'Handle yang diketik tidak cocok.', + 'agentWorld.transferHandle.primaryLocked': + 'Handle utama tidak dapat ditransfer. Aktifkan handle lain terlebih dahulu.', 'agentWorld.profile.edit': 'Edit profil', 'agentWorld.profile.displayName': 'Nama tampilan', 'agentWorld.profile.bio': 'Bio', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 9196f50b32..89d4251900 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -528,6 +528,18 @@ const messages: TranslationMap = { 'agentWorld.directory.profile.loadError': 'Impossibile caricare il profilo completo.', 'agentWorld.identities': 'Identità', 'agentWorld.profiles': 'Profili', + 'agentWorld.transferHandle.action': 'Trasferisci', + 'agentWorld.transferHandle.title': 'Trasferisci handle', + 'agentWorld.transferHandle.warning': + "Il trasferimento di un handle è permanente e non può essere annullato. Il destinatario ne diventa l'unico proprietario.", + 'agentWorld.transferHandle.recipientPlaceholder': '@handle del destinatario', + 'agentWorld.transferHandle.confirm': 'Trasferisci handle', + 'agentWorld.transferHandle.submitting': 'Trasferimento…', + 'agentWorld.transferHandle.recipientRequired': "Inserisci l'handle del destinatario.", + 'agentWorld.transferHandle.confirmLabel': "Digita l'handle per confermare", + 'agentWorld.transferHandle.confirmMismatch': "L'handle digitato non corrisponde.", + 'agentWorld.transferHandle.primaryLocked': + 'Un handle primario non può essere trasferito. Rendi prima attivo un altro handle.', 'agentWorld.profile.edit': 'Modifica profilo', 'agentWorld.profile.displayName': 'Nome visualizzato', 'agentWorld.profile.bio': 'Bio', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 7af3e8c14e..b0601b9eb6 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -507,6 +507,18 @@ const messages: TranslationMap = { 'agentWorld.directory.profile.loadError': '전체 프로필을 불러오지 못했습니다.', 'agentWorld.identities': '아이덴티티', 'agentWorld.profiles': '프로필', + 'agentWorld.transferHandle.action': '이전', + 'agentWorld.transferHandle.title': '핸들 이전', + 'agentWorld.transferHandle.warning': + '핸들 이전은 영구적이며 되돌릴 수 없습니다. 수신자가 유일한 소유자가 됩니다.', + 'agentWorld.transferHandle.recipientPlaceholder': '수신자 @handle', + 'agentWorld.transferHandle.confirm': '핸들 이전', + 'agentWorld.transferHandle.submitting': '이전 중…', + 'agentWorld.transferHandle.recipientRequired': '수신자 핸들을 입력하세요.', + 'agentWorld.transferHandle.confirmLabel': '확인하려면 핸들을 입력하세요', + 'agentWorld.transferHandle.confirmMismatch': '입력한 핸들이 일치하지 않습니다.', + 'agentWorld.transferHandle.primaryLocked': + '기본 핸들은 이전할 수 없습니다. 먼저 다른 핸들을 활성화하세요.', 'agentWorld.profile.edit': '프로필 편집', 'agentWorld.profile.displayName': '표시 이름', 'agentWorld.profile.bio': '소개', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index c67e7cea03..737288fdf6 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -527,6 +527,18 @@ const messages: TranslationMap = { 'agentWorld.directory.profile.loadError': 'Nie udało się załadować pełnego profilu.', 'agentWorld.identities': 'Tożsamości', 'agentWorld.profiles': 'Profile', + 'agentWorld.transferHandle.action': 'Przekaż', + 'agentWorld.transferHandle.title': 'Przekaż handle', + 'agentWorld.transferHandle.warning': + "Przekazanie handle'a jest trwałe i nie można go cofnąć. Odbiorca staje się jego jedynym właścicielem.", + 'agentWorld.transferHandle.recipientPlaceholder': '@handle odbiorcy', + 'agentWorld.transferHandle.confirm': 'Przekaż handle', + 'agentWorld.transferHandle.submitting': 'Przekazywanie…', + 'agentWorld.transferHandle.recipientRequired': 'Podaj handle odbiorcy.', + 'agentWorld.transferHandle.confirmLabel': 'Wpisz handle, aby potwierdzić', + 'agentWorld.transferHandle.confirmMismatch': 'Wpisany handle nie pasuje.', + 'agentWorld.transferHandle.primaryLocked': + "Podstawowego handle'a nie można przekazać. Najpierw ustaw inny handle jako aktywny.", 'agentWorld.profile.edit': 'Edytuj profil', 'agentWorld.profile.displayName': 'Wyświetlana nazwa', 'agentWorld.profile.bio': 'Bio', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 7c7ebeeebd..6169621c27 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -520,6 +520,18 @@ const messages: TranslationMap = { 'agentWorld.directory.profile.loadError': 'Não foi possível carregar o perfil completo.', 'agentWorld.identities': 'Identidades', 'agentWorld.profiles': 'Perfis', + 'agentWorld.transferHandle.action': 'Transferir', + 'agentWorld.transferHandle.title': 'Transferir handle', + 'agentWorld.transferHandle.warning': + 'Transferir um handle é permanente e não pode ser desfeito. O destinatário torna-se o seu único proprietário.', + 'agentWorld.transferHandle.recipientPlaceholder': '@handle do destinatário', + 'agentWorld.transferHandle.confirm': 'Transferir handle', + 'agentWorld.transferHandle.submitting': 'A transferir…', + 'agentWorld.transferHandle.recipientRequired': 'Introduza o handle do destinatário.', + 'agentWorld.transferHandle.confirmLabel': 'Digite o handle para confirmar', + 'agentWorld.transferHandle.confirmMismatch': 'O handle digitado não corresponde.', + 'agentWorld.transferHandle.primaryLocked': + 'Um identificador principal não pode ser transferido. Ative outro identificador primeiro.', 'agentWorld.profile.edit': 'Editar perfil', 'agentWorld.profile.displayName': 'Nome de exibição', 'agentWorld.profile.bio': 'Bio', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 9d382dee42..909afae432 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -520,6 +520,18 @@ const messages: TranslationMap = { 'agentWorld.directory.profile.loadError': 'Не удалось загрузить полный профиль.', 'agentWorld.identities': 'Идентичности', 'agentWorld.profiles': 'Профили', + 'agentWorld.transferHandle.action': 'Передать', + 'agentWorld.transferHandle.title': 'Передать хэндл', + 'agentWorld.transferHandle.warning': + 'Передача хэндла необратима и не может быть отменена. Получатель становится его единственным владельцем.', + 'agentWorld.transferHandle.recipientPlaceholder': '@handle получателя', + 'agentWorld.transferHandle.confirm': 'Передать хэндл', + 'agentWorld.transferHandle.submitting': 'Передача…', + 'agentWorld.transferHandle.recipientRequired': 'Введите хэндл получателя.', + 'agentWorld.transferHandle.confirmLabel': 'Введите хэндл для подтверждения', + 'agentWorld.transferHandle.confirmMismatch': 'Введённый хэндл не совпадает.', + 'agentWorld.transferHandle.primaryLocked': + 'Основной хэндл нельзя передать. Сначала сделайте активным другой хэндл.', 'agentWorld.profile.edit': 'Изменить профиль', 'agentWorld.profile.displayName': 'Отображаемое имя', 'agentWorld.profile.bio': 'О себе', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index a7c7a1030d..437620a9fa 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -482,6 +482,16 @@ const messages: TranslationMap = { 'agentWorld.directory.profile.loadError': '无法加载完整资料。', 'agentWorld.identities': '身份', 'agentWorld.profiles': '档案', + 'agentWorld.transferHandle.action': '转移', + 'agentWorld.transferHandle.title': '转移句柄', + 'agentWorld.transferHandle.warning': '句柄转移是永久性的,无法撤销。接收者将成为其唯一所有者。', + 'agentWorld.transferHandle.recipientPlaceholder': '接收者 @handle', + 'agentWorld.transferHandle.confirm': '转移句柄', + 'agentWorld.transferHandle.submitting': '正在转移…', + 'agentWorld.transferHandle.recipientRequired': '请输入接收者的句柄。', + 'agentWorld.transferHandle.confirmLabel': '输入句柄以确认', + 'agentWorld.transferHandle.confirmMismatch': '输入的句柄不匹配。', + 'agentWorld.transferHandle.primaryLocked': '无法转移主用户名。请先将另一个用户名设为活跃。', 'agentWorld.profile.edit': '编辑资料', 'agentWorld.profile.displayName': '显示名称', 'agentWorld.profile.bio': '简介', diff --git a/src/openhuman/tinyplace/manifest.rs b/src/openhuman/tinyplace/manifest.rs index fabc43a6bc..bb57f642d5 100644 --- a/src/openhuman/tinyplace/manifest.rs +++ b/src/openhuman/tinyplace/manifest.rs @@ -2496,6 +2496,249 @@ pub(crate) fn handle_tinyplace_registry_assign_primary( }) } +fn transfer_allowed_by_primary_state(primary: Option) -> bool { + primary == Some(false) +} + +/// Transfer one of the wallet's handles to another tiny.place identity +/// (gift / account move, #4929). DESTRUCTIVE and irreversible for the sender: +/// on success the recipient becomes the sole owner of `name`. +/// +/// The recipient is given as a `recipient` handle (with or without a leading +/// @) — we resolve it to the recipient's `cryptoId` + `publicKey` via +/// `registry.get`, so the caller never has to know raw key material and an +/// unresolvable recipient fails **closed** before anything is signed. The +/// SDK attaches the owning wallet's signature over the transfer payload, so +/// the backend only lets a caller transfer a handle their *own* wallet owns. +/// +/// Ordering (read-only preflight → sign+POST → real read-back): +/// 1. Resolve the recipient and validate it is a live registration whose +/// `available`/`status` don't flag a stale/expired record (M2, #4998). +/// 2. Read the sender's *own* current ownership. If it already reads back as +/// the recipient — a retry after a lost-but-applied POST — return the +/// existing state without re-signing (idempotency, M1, #4998), and reject a +/// self-transfer / unregistered / primary handle before signing. +/// 3. Sign + POST the transfer. +/// 4. **Read-back via `registry.get`**, NOT the POST response body. The POST has +/// already returned 2xx by this point, so a mismatch means "submitted but +/// unconfirmed", never "did not happen" — we never claim the handle wasn't +/// reassigned, because the handler cannot know that (B1, #4998). +pub(crate) fn handle_tinyplace_registry_transfer(params: Map) -> ControllerFuture { + Box::pin(async move { + let name = req_str(¶ms, "name")? + .trim() + .trim_start_matches('@') + .to_string(); + if name.is_empty() { + return Err("missing required param 'name'".to_string()); + } + let recipient = req_str(¶ms, "recipient")? + .trim() + .trim_start_matches('@') + .to_string(); + if recipient.is_empty() { + return Err("missing required param 'recipient'".to_string()); + } + // Cheap reject before any network/signing: a self-transfer burns a + // signature as a no-op and would read back as trivially "confirmed". + if name.eq_ignore_ascii_case(&recipient) { + return Err("cannot transfer a handle to itself".to_string()); + } + // Never log the handle or recipient — both are user-identifying. + log::debug!("{LOG_PREFIX} registry_transfer requested"); + + let client = global_state().client().await?; + + // (1) Resolve the recipient handle to a verified cryptoId + publicKey. + // An unknown/expired/available recipient fails closed here, before we + // sign. We query with the `@`-prefixed form, matching the proven + // availability path (`useHandleAvailability` → registry.get(`@handle`)). + let availability = client + .registry + .get(&format!("@{recipient}")) + .await + .map_err(map_err)?; + let (recipient_crypto_id, recipient_public_key) = + validate_recipient_availability(&availability)?; + log::debug!("{LOG_PREFIX} registry_transfer recipient resolved"); + + // (2) Read the sender's own current ownership BEFORE signing. This + // hoisted, side-effect-free read lets us (a) short-circuit a retry whose + // transfer already landed, and (b) reject a not-registered / primary + // handle with the right diagnosis — separately from the nonexistent case. + let own = client + .registry + .get(&format!("@{name}")) + .await + .map_err(map_err)?; + match preflight_own_handle(&own, &recipient_crypto_id) { + OwnHandlePreflight::AlreadyTransferred => { + // Idempotency (M1): the handle already reads back as the + // recipient — a prior POST landed but its response was lost. Do + // NOT re-sign; report the existing state. + log::debug!("{LOG_PREFIX} registry_transfer already applied; idempotent no-op"); + return to_value(serde_json::json!({ + "identity": own.identity, + "alreadyTransferred": true, + })); + } + OwnHandlePreflight::Reject(message) => { + log::warn!("{LOG_PREFIX} registry_transfer rejected in preflight"); + return Err(message); + } + OwnHandlePreflight::Proceed => {} + } + + // (3) Sign + POST the transfer. + let request = tinyplace::types::IdentityTransferRequest { + crypto_id: recipient_crypto_id.clone(), + public_key: recipient_public_key, + ..Default::default() + }; + client + .registry + .transfer(&name, request) + .await + .map_err(map_err)?; + + // (4) Real read-back: re-query the registry rather than trusting the POST + // response body (`Identity::crypto_id` is `#[serde(default)]`, so an + // enveloped/renamed/partial body deserializes to "" and would falsely + // fail a transfer that DID land — B1). The transfer is already submitted, + // so an unconfirmed read-back is "submitted but unconfirmed", NOT "did + // not happen": we must never tell the user they still own it. + let readback_identity = client + .registry + .get(&format!("@{name}")) + .await + .ok() + .and_then(|r| r.identity); + let confirmed_owner = readback_identity + .as_ref() + .map(|i| i.crypto_id.trim().to_string()) + .unwrap_or_default(); + match classify_transfer_readback(&confirmed_owner, &recipient_crypto_id) { + TransferReadback::Confirmed => { + log::debug!("{LOG_PREFIX} registry_transfer confirmed by read-back"); + // Return the identity from the SAME read-back we confirmed against + // (no redundant second GET — the confirmed owner is already here). + to_value(serde_json::json!({ "identity": readback_identity, "confirmed": true })) + } + TransferReadback::Unconfirmed => { + log::warn!("{LOG_PREFIX} registry_transfer submitted but unconfirmed by read-back"); + Err( + "handle transfer was submitted but could not be confirmed. Do not retry \ + until you have checked the handle's current owner." + .to_string(), + ) + } + } + }) +} + +/// Validate a recipient's availability lookup before a transfer (#4998, M2). +/// +/// Returns the recipient's `(cryptoId, publicKey)` when it is a live +/// registration, or an error when the record is stale/expired/incomplete — so +/// an irreversible transfer never targets a wallet the recipient may no longer +/// control at that `@handle`. Pure, so the policy is unit-testable without a +/// live registry. +fn validate_recipient_availability( + availability: &tinyplace::types::AvailabilityResponse, +) -> Result<(String, String), String> { + // A registered handle reads back as NOT available. `available == true` means + // the name is currently free (never registered, or expired-and-released), + // so any attached `identity` is stale — refuse rather than transfer to it. + if availability.available { + return Err( + "recipient handle is not currently registered; refusing to transfer".to_string(), + ); + } + let identity = availability + .identity + .as_ref() + .ok_or("recipient handle is not registered on tiny.place")?; + // An expired identity carries a stale owner record even before the name is + // released back to `available` — never send an irreversible transfer to it. + if identity.status.trim().eq_ignore_ascii_case("expired") { + return Err("recipient handle has expired; refusing to transfer".to_string()); + } + let crypto_id = identity.crypto_id.trim().to_string(); + let public_key = identity.public_key.trim().to_string(); + if crypto_id.is_empty() || public_key.is_empty() { + return Err( + "recipient identity is missing key material; cannot transfer safely".to_string(), + ); + } + Ok((crypto_id, public_key)) +} + +/// Outcome of the pre-sign check on the sender's own handle (#4998, M1/M2). +#[derive(Debug, PartialEq, Eq)] +enum OwnHandlePreflight { + /// The handle already reads back as owned by the recipient — a retry after a + /// lost-but-applied POST. Return the existing state; do NOT re-sign. + AlreadyTransferred, + /// Safe to proceed to signing + POST. + Proceed, + /// Reject before signing with this message. + Reject(String), +} + +/// Decide whether to proceed with, short-circuit, or reject a transfer based on +/// the sender's own current ownership. Pure, so unit-testable without a client. +fn preflight_own_handle( + own: &tinyplace::types::AvailabilityResponse, + recipient_crypto_id: &str, +) -> OwnHandlePreflight { + let Some(identity) = own.identity.as_ref() else { + // Not registered at all — a distinct diagnosis from the primary case, so + // a nonexistent handle doesn't get "mark it non-primary" (M2 tail). + return OwnHandlePreflight::Reject( + "this handle is not registered on tiny.place".to_string(), + ); + }; + // Idempotency (M1): already owned by the recipient — a prior transfer landed. + if identity.crypto_id.trim() == recipient_crypto_id { + return OwnHandlePreflight::AlreadyTransferred; + } + // A primary (active) handle is locked from transfer server-side; mirror that + // client-side so a direct JSON-RPC caller can't bypass the UI gate. + if !transfer_allowed_by_primary_state(identity.primary) { + return OwnHandlePreflight::Reject( + "cannot transfer this handle while it is your active (primary) handle; make \ + another handle active first" + .to_string(), + ); + } + OwnHandlePreflight::Proceed +} + +/// Outcome of the post-transfer read-back confirmation (#4998, B1). +#[derive(Debug, PartialEq, Eq)] +enum TransferReadback { + /// The registry reads back the recipient as the current owner. + Confirmed, + /// The read-back neither confirms nor refutes reassignment — the POST is + /// already submitted, so this is "submitted but unconfirmed", never a claim + /// that the transfer did not happen. + Unconfirmed, +} + +/// Classify the post-transfer read-back. Only a non-empty owner that equals the +/// recipient confirms; anything else (empty/enveloped body, mismatch, failed +/// read) is treated as unconfirmed. Pure, so unit-testable without a client. +fn classify_transfer_readback( + confirmed_owner: &str, + recipient_crypto_id: &str, +) -> TransferReadback { + if !confirmed_owner.is_empty() && confirmed_owner == recipient_crypto_id { + TransferReadback::Confirmed + } else { + TransferReadback::Unconfirmed + } +} + // ── Users email verification ──────────────────────────────────────────────── pub(crate) fn handle_tinyplace_users_start_email_verification( @@ -5283,6 +5526,240 @@ mod tests { assert!(err.contains("username"), "got: {err}"); } + /// #4929: transfer rejects a missing/blank `name` or `recipient` before any + /// client/network work — the destructive path never runs on bad input. + #[test] + fn transfer_requires_name_and_recipient() { + // Missing name. + let err = block_on(handle_tinyplace_registry_transfer(Map::new())).unwrap_err(); + assert!(err.contains("name"), "got: {err}"); + + // A bare "@" normalizes to an empty name and is rejected before any + // client/network work (the destructive path never runs). + let mut params = Map::new(); + params.insert("name".to_string(), Value::String("@".to_string())); + params.insert("recipient".to_string(), Value::String("bravo".to_string())); + let err = block_on(handle_tinyplace_registry_transfer(params)).unwrap_err(); + assert!(err.contains("name"), "got: {err}"); + + // Name present, recipient missing. + let mut params = Map::new(); + params.insert("name".to_string(), Value::String("alpha".to_string())); + let err = block_on(handle_tinyplace_registry_transfer(params)).unwrap_err(); + assert!(err.contains("recipient"), "got: {err}"); + + // Name present, recipient blank → still rejected. + let mut params = Map::new(); + params.insert("name".to_string(), Value::String("alpha".to_string())); + params.insert("recipient".to_string(), Value::String(" ".to_string())); + let err = block_on(handle_tinyplace_registry_transfer(params)).unwrap_err(); + assert!(err.contains("recipient"), "got: {err}"); + } + + /// #4929: transfer eligibility fails closed when the registry omits the + /// primary flag; only an explicit `false` may reach the destructive path. + #[test] + fn transfer_requires_explicit_non_primary_state() { + assert!(!transfer_allowed_by_primary_state(None)); + assert!(!transfer_allowed_by_primary_state(Some(true))); + assert!(transfer_allowed_by_primary_state(Some(false))); + } + + // ── #4998 destructive-path coverage (B1 read-back, M1 idempotency, M2 + // recipient validation) — the pure policy helpers the async handler + // delegates to, so the irreversible-transfer decisions are unit-tested. ── + + fn test_identity( + crypto_id: &str, + status: &str, + primary: Option, + ) -> tinyplace::types::Identity { + tinyplace::types::Identity { + username: "handle".to_string(), + crypto_id: crypto_id.to_string(), + public_key: if crypto_id.is_empty() { + String::new() + } else { + "pubkey".to_string() + }, + registered_at: String::new(), + expires_at: String::new(), + status: status.to_string(), + registration_tx: None, + payment_methods: None, + primary, + subnames: None, + signature: None, + payment: None, + last_renewal_tx: None, + updated_at: String::new(), + } + } + + fn availability( + available: bool, + identity: Option, + ) -> tinyplace::types::AvailabilityResponse { + tinyplace::types::AvailabilityResponse { + available, + name: "@handle".to_string(), + identity, + lifecycle: None, + } + } + + /// M2: a recipient whose name is currently `available` (free / released) is + /// refused even if a stale `identity` record is attached — never transfer to + /// a wallet the recipient may no longer control at that @handle. + #[test] + fn recipient_available_is_refused() { + let avail = availability( + true, + Some(test_identity("recipientCid", "active", Some(false))), + ); + let err = validate_recipient_availability(&avail).unwrap_err(); + assert!(err.contains("not currently registered"), "got: {err}"); + } + + /// M2: a recipient with no identity record at all fails closed. + #[test] + fn recipient_missing_identity_is_refused() { + let err = validate_recipient_availability(&availability(false, None)).unwrap_err(); + assert!(err.contains("not registered on tiny.place"), "got: {err}"); + } + + /// M2: an EXPIRED recipient identity (stale owner record before release) is + /// refused — case-insensitively. + #[test] + fn recipient_expired_status_is_refused() { + for status in ["expired", "EXPIRED", "Expired"] { + let avail = availability( + false, + Some(test_identity("recipientCid", status, Some(false))), + ); + let err = validate_recipient_availability(&avail).unwrap_err(); + assert!(err.contains("expired"), "status {status} → {err}"); + } + } + + /// M2: a recipient missing key material can't be transferred to safely. + #[test] + fn recipient_missing_key_material_is_refused() { + let avail = availability(false, Some(test_identity("", "active", Some(false)))); + let err = validate_recipient_availability(&avail).unwrap_err(); + assert!(err.contains("missing key material"), "got: {err}"); + } + + /// A live, registered recipient resolves to its (cryptoId, publicKey). + #[test] + fn recipient_live_resolves_key_material() { + let avail = availability( + false, + Some(test_identity("recipientCid", "active", Some(false))), + ); + let (cid, pk) = validate_recipient_availability(&avail).unwrap(); + assert_eq!(cid, "recipientCid"); + assert_eq!(pk, "pubkey"); + } + + /// M1 idempotency: when the sender's own handle ALREADY reads back as the + /// recipient (a retry after a lost-but-applied POST), the preflight + /// short-circuits and must NOT re-sign. + #[test] + fn preflight_already_transferred_short_circuits() { + let own = availability( + false, + Some(test_identity("recipientCid", "active", Some(false))), + ); + assert_eq!( + preflight_own_handle(&own, "recipientCid"), + OwnHandlePreflight::AlreadyTransferred + ); + } + + /// M2 tail: a nonexistent own-handle gets the right diagnosis, NOT the + /// "mark it non-primary" message. + #[test] + fn preflight_unregistered_own_handle_is_distinct() { + match preflight_own_handle(&availability(true, None), "recipientCid") { + OwnHandlePreflight::Reject(msg) => { + assert!(msg.contains("not registered"), "got: {msg}"); + assert!( + !msg.contains("primary"), + "must not misdiagnose as primary: {msg}" + ); + } + other => panic!("expected Reject, got {other:?}"), + } + } + + /// A primary (active) own-handle is rejected before signing. + #[test] + fn preflight_primary_handle_is_rejected() { + let own = availability(false, Some(test_identity("ownerCid", "active", Some(true)))); + match preflight_own_handle(&own, "recipientCid") { + OwnHandlePreflight::Reject(msg) => assert!(msg.contains("active"), "got: {msg}"), + other => panic!("expected Reject, got {other:?}"), + } + // Missing primary flag also fails closed. + let own_none = availability(false, Some(test_identity("ownerCid", "active", None))); + assert!(matches!( + preflight_own_handle(&own_none, "recipientCid"), + OwnHandlePreflight::Reject(_) + )); + } + + /// A non-primary own-handle owned by someone other than the recipient + /// proceeds to signing. + #[test] + fn preflight_non_primary_proceeds() { + let own = availability( + false, + Some(test_identity("ownerCid", "active", Some(false))), + ); + assert_eq!( + preflight_own_handle(&own, "recipientCid"), + OwnHandlePreflight::Proceed + ); + } + + /// B1: the read-back confirms ONLY when the registry returns a non-empty + /// owner equal to the recipient. An empty owner (enveloped/renamed/partial + /// body, or a failed read) is "unconfirmed" — never a claim the transfer + /// didn't happen. + #[test] + fn readback_confirms_only_on_matching_nonempty_owner() { + assert_eq!( + classify_transfer_readback("recipientCid", "recipientCid"), + TransferReadback::Confirmed + ); + // Empty owner (default-deserialized crypto_id "" from an enveloped body). + assert_eq!( + classify_transfer_readback("", "recipientCid"), + TransferReadback::Unconfirmed + ); + // Owner reads back as someone else. + assert_eq!( + classify_transfer_readback("otherCid", "recipientCid"), + TransferReadback::Unconfirmed + ); + // Defensive: an empty recipient never vacuously confirms on an empty read. + assert_eq!( + classify_transfer_readback("", ""), + TransferReadback::Unconfirmed + ); + } + + /// A self-transfer is rejected before any network/signing work. + #[test] + fn transfer_rejects_self_transfer() { + let mut params = Map::new(); + params.insert("name".to_string(), Value::String("@alpha".to_string())); + params.insert("recipient".to_string(), Value::String("alpha".to_string())); + let err = block_on(handle_tinyplace_registry_transfer(params)).unwrap_err(); + assert!(err.contains("itself"), "got: {err}"); + } + /// Buy handlers reject a missing/blank `id` before any client/network work. #[test] fn buy_handlers_require_id() { diff --git a/src/openhuman/tinyplace/schemas.rs b/src/openhuman/tinyplace/schemas.rs index 407ba2e697..fe2a83af0e 100644 --- a/src/openhuman/tinyplace/schemas.rs +++ b/src/openhuman/tinyplace/schemas.rs @@ -151,6 +151,7 @@ use crate::openhuman::tinyplace::manifest::{ handle_tinyplace_registry_export, handle_tinyplace_registry_get, handle_tinyplace_registry_register, + handle_tinyplace_registry_transfer, handle_tinyplace_search_unified, handle_tinyplace_signal_decrypt_message, handle_tinyplace_signal_get_bundle, @@ -1467,6 +1468,35 @@ fn schema_registry_export() -> ControllerSchema { } } +fn schema_registry_transfer() -> ControllerSchema { + ControllerSchema { + namespace: "tinyplace", + function: "registry_transfer", + description: + "Transfer one of the wallet's handles to another tiny.place identity (gift / account \ + move). DESTRUCTIVE and irreversible: on success the recipient becomes the handle's \ + sole owner. The recipient is resolved from their @handle; an unregistered recipient \ + or an unconfirmed read-back fails closed. The owning wallet is proven by the \ + signer-attached signature, so a caller can only transfer a handle their own wallet \ + owns.", + inputs: vec![ + required_string( + "name", + "The handle to transfer away (with or without a leading @).", + ), + required_string( + "recipient", + "The recipient's registered @handle (with or without a leading @); resolved to \ + their cryptoId + publicKey server-side.", + ), + ], + outputs: vec![json_output( + "identity", + "The transferred Identity, now owned by the recipient (read-back confirmed).", + )], + } +} + // ── Feedback schemas ──────────────────────────────────────────────────────── fn schema_feedback_list() -> ControllerSchema { @@ -2727,6 +2757,7 @@ pub fn all_tinyplace_controller_schemas() -> Vec { schema_registry_get(), schema_registry_register(), schema_registry_assign_primary(), + schema_registry_transfer(), schema_marketplace_buy_product(), schema_marketplace_buy_identity(), schema_marketplace_bid(), @@ -2980,6 +3011,10 @@ pub fn all_tinyplace_registered_controllers() -> Vec { schema: schema_registry_assign_primary(), handler: handle_tinyplace_registry_assign_primary, }, + RegisteredController { + schema: schema_registry_transfer(), + handler: handle_tinyplace_registry_transfer, + }, RegisteredController { schema: schema_marketplace_buy_product(), handler: handle_tinyplace_marketplace_buy_product, From f18414ff33d59fdf09a5417677e7cd0e027b1dfe Mon Sep 17 00:00:00 2001 From: mysma-9403 <64923976+mysma-9403@users.noreply.github.com> Date: Wed, 22 Jul 2026 04:04:32 +0200 Subject: [PATCH 33/56] fix(voice): handle U16 sample format in the fallback capture path (#4537) --- src/openhuman/voice/audio_capture.rs | 45 +++++++++++++++++----- src/openhuman/voice/audio_capture_tests.rs | 19 +++++++++ 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/openhuman/voice/audio_capture.rs b/src/openhuman/voice/audio_capture.rs index c80dfe2a26..e028366b99 100644 --- a/src/openhuman/voice/audio_capture.rs +++ b/src/openhuman/voice/audio_capture.rs @@ -368,8 +368,7 @@ fn record_on_thread( .build_input_stream( &stream_config, move |data: &[i16], _: &cpal::InputCallbackInfo| { - let floats: Vec = - data.iter().map(|&s| s as f32 / 32768.0).collect(); + let floats = i16_to_f32(data); let mono = to_mono(&floats, source_channels); update_peak_rms(&rms_tracker, &mono); let gated = gate.lock().process(&mono); @@ -389,10 +388,7 @@ fn record_on_thread( .build_input_stream( &stream_config, move |data: &[u16], _: &cpal::InputCallbackInfo| { - let floats: Vec = data - .iter() - .map(|&s| (s as f32 - 32768.0) / 32768.0) - .collect(); + let floats = u16_to_f32(data); let mono = to_mono(&floats, source_channels); update_peak_rms(&rms_tracker, &mono); let gated = gate.lock().process(&mono); @@ -446,8 +442,7 @@ fn record_on_thread( .build_input_stream( &sc, move |data: &[i16], _: &cpal::InputCallbackInfo| { - let floats: Vec = - data.iter().map(|&s| s as f32 / 32768.0).collect(); + let floats = i16_to_f32(data); let mono = to_mono(&floats, ch); update_peak_rms(&rt, &mono); let gated = gate.lock().process(&mono); @@ -459,7 +454,23 @@ fn record_on_thread( None, ) .map_err(|e| format!("fallback i16 stream failed: {e}")), - _ => Err(format!("unsupported fallback format: {fmt:?}")), + SampleFormat::U16 => device + .build_input_stream( + &sc, + move |data: &[u16], _: &cpal::InputCallbackInfo| { + let floats = u16_to_f32(data); + let mono = to_mono(&floats, ch); + update_peak_rms(&rt, &mono); + let gated = gate.lock().process(&mono); + if !gated.is_empty() { + sw.lock().extend_from_slice(&gated); + } + }, + |err| error!("{LOG_PREFIX} audio stream error: {err}"), + None, + ) + .map_err(|e| format!("fallback u16 stream failed: {e}")), + other => Err(format!("unsupported fallback format: {other:?}")), }; match fallback_stream { Ok(s) => (s, sr, ch), @@ -519,6 +530,22 @@ pub fn list_input_devices() -> Result, String> { Ok(names) } +/// Convert interleaved signed `i16` PCM samples to normalised `f32` in +/// `[-1.0, 1.0)` (`s / 32768`). Extracted so the preferred and fallback stream +/// paths share one conversion instead of duplicating it inline. +pub(crate) fn i16_to_f32(data: &[i16]) -> Vec { + data.iter().map(|&s| s as f32 / 32768.0).collect() +} + +/// Convert interleaved unsigned `u16` PCM samples (mid-scale 32768) to +/// normalised `f32` in `[-1.0, 1.0)` (`(s - 32768) / 32768`). Shared by the +/// preferred and fallback stream paths. +pub(crate) fn u16_to_f32(data: &[u16]) -> Vec { + data.iter() + .map(|&s| (s as f32 - 32768.0) / 32768.0) + .collect() +} + /// Convert interleaved multi-channel samples to mono by averaging channels. pub(crate) fn to_mono(samples: &[f32], channels: usize) -> Vec { if channels <= 1 { diff --git a/src/openhuman/voice/audio_capture_tests.rs b/src/openhuman/voice/audio_capture_tests.rs index bfcc15fce3..b6a4d3f2aa 100644 --- a/src/openhuman/voice/audio_capture_tests.rs +++ b/src/openhuman/voice/audio_capture_tests.rs @@ -1,6 +1,25 @@ use super::*; use cpal::{SampleFormat, SampleRate, SupportedBufferSize, SupportedStreamConfigRange}; +#[test] +fn i16_to_f32_normalises_to_unit_range() { + assert_eq!(i16_to_f32(&[0]), vec![0.0]); + assert_eq!(i16_to_f32(&[16384]), vec![0.5]); + assert_eq!(i16_to_f32(&[-32768]), vec![-1.0]); + // Interleaved frames are converted element-wise, order preserved. + assert_eq!(i16_to_f32(&[0, 16384, -16384]), vec![0.0, 0.5, -0.5]); +} + +#[test] +fn u16_to_f32_centers_on_midscale() { + // Unsigned PCM is offset-binary: 32768 is silence (0.0), the endpoints map + // to the extremes. This is the conversion the fallback path was missing. + assert_eq!(u16_to_f32(&[32768]), vec![0.0]); + assert_eq!(u16_to_f32(&[49152]), vec![0.5]); + assert_eq!(u16_to_f32(&[0]), vec![-1.0]); + assert_eq!(u16_to_f32(&[32768, 49152, 16384]), vec![0.0, 0.5, -0.5]); +} + #[test] fn to_mono_passthrough_single_channel() { let input = vec![0.1, 0.2, 0.3]; From c5fea23027d332d7f5c2b8ec7af0ae09ecc4b8ad Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 22 Jul 2026 04:05:23 +0200 Subject: [PATCH 34/56] fix(mcp): hide raw registry errors (#4716) Co-authored-by: Sami Rusani <14844597+samrusani@users.noreply.github.com> Co-authored-by: Steven Enamakel --- .../channels/mcp/InstallDialog.test.tsx | 46 +++++++- .../components/channels/mcp/InstallDialog.tsx | 7 +- .../channels/mcp/McpCatalogBrowser.test.tsx | 10 +- .../channels/mcp/McpCatalogBrowser.tsx | 3 +- .../channels/mcp/McpServersTab.test.tsx | 8 +- .../components/channels/mcp/McpServersTab.tsx | 11 +- .../channels/mcp/mcpRegistryErrorMessage.ts | 11 ++ app/src/lib/i18n/ar.ts | 6 + app/src/lib/i18n/bn.ts | 6 + app/src/lib/i18n/de.ts | 6 + app/src/lib/i18n/en.ts | 6 + app/src/lib/i18n/es.ts | 6 + app/src/lib/i18n/fr.ts | 6 + app/src/lib/i18n/hi.ts | 6 + app/src/lib/i18n/id.ts | 6 + app/src/lib/i18n/it.ts | 6 + app/src/lib/i18n/ko.ts | 6 + app/src/lib/i18n/pl.ts | 6 + app/src/lib/i18n/pt.ts | 6 + app/src/lib/i18n/ru.ts | 6 + app/src/lib/i18n/zh-CN.ts | 6 + app/src/services/api/mcpClientsApi.test.ts | 111 ++++++++++++++++++ app/src/services/api/mcpClientsApi.ts | 56 ++++++--- app/src/services/api/mcpRegistryErrors.ts | 92 +++++++++++++++ 24 files changed, 406 insertions(+), 33 deletions(-) create mode 100644 app/src/components/channels/mcp/mcpRegistryErrorMessage.ts create mode 100644 app/src/services/api/mcpRegistryErrors.ts diff --git a/app/src/components/channels/mcp/InstallDialog.test.tsx b/app/src/components/channels/mcp/InstallDialog.test.tsx index 1998ad107b..30c3eb9563 100644 --- a/app/src/components/channels/mcp/InstallDialog.test.tsx +++ b/app/src/components/channels/mcp/InstallDialog.test.tsx @@ -1,6 +1,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { McpRegistryUserError } from '../../../services/api/mcpRegistryErrors'; import InstallDialog from './InstallDialog'; const mockRegistryGet = vi.fn(); @@ -98,6 +99,22 @@ describe('InstallDialog', () => { expect(screen.getByText('SECRET_TOKEN')).toBeInTheDocument(); }); + it('shows friendly guidance instead of raw registry 404 JSON when detail load fails', async () => { + mockRegistryGet.mockRejectedValue( + new Error( + 'MCP official registry GET unreal-mcp returned HTTP 404 Not Found: {"title":"Not Found","status":404,"detail":"Server not found"}' + ) + ); + render( {}} onCancel={() => {}} />); + + await waitFor(() => screen.getByText(/Server not found in registry/)); + + expect(screen.getByText(/browse available MCP servers/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Browse catalog' })).toBeInTheDocument(); + expect(screen.queryByText(/"title":"Not Found"/)).not.toBeInTheDocument(); + expect(screen.queryByText(/HTTP 404/)).not.toBeInTheDocument(); + }); + it('renders env key inputs after clicking configure', async () => { mockRegistryGet.mockResolvedValue(DETAIL); render( @@ -232,7 +249,34 @@ describe('InstallDialog', () => { fireEvent.click(screen.getByRole('button', { name: 'Install' })); }); - await waitFor(() => screen.getByText('Server error')); + await waitFor(() => screen.getByText('Install failed')); + }); + + it('shows friendly guidance instead of raw registry JSON when install re-fetch fails', async () => { + mockRegistryGet.mockResolvedValue(DETAIL); + mockInstall.mockRejectedValue( + new McpRegistryUserError( + 'not_found', + 'Failed to fetch registry detail: MCP official registry GET unreal-mcp returned HTTP 404 Not Found: {"title":"Not Found","status":404,"detail":"Server not found"}' + ) + ); + + render( + {}} onCancel={() => {}} /> + ); + + await goToConfigureStep(); + fireEvent.change(screen.getByLabelText('API_KEY'), { target: { value: 'key' } }); + fireEvent.change(screen.getByLabelText('SECRET_TOKEN'), { target: { value: 'secret' } }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Install' })); + }); + + await waitFor(() => screen.getByText(/Server not found in registry/)); + + expect(screen.queryByText(/"title":"Not Found"/)).not.toBeInTheDocument(); + expect(screen.queryByText(/HTTP 404/)).not.toBeInTheDocument(); }); it('calls onCancel when Cancel is clicked on detail step', async () => { diff --git a/app/src/components/channels/mcp/InstallDialog.tsx b/app/src/components/channels/mcp/InstallDialog.tsx index 8be4f74744..9bfd1559b9 100644 --- a/app/src/components/channels/mcp/InstallDialog.tsx +++ b/app/src/components/channels/mcp/InstallDialog.tsx @@ -16,6 +16,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; import { mcpClientsApi } from '../../../services/api/mcpClientsApi'; import Button from '../../ui/Button'; +import { mcpRegistryErrorMessage } from './mcpRegistryErrorMessage'; import { deriveAuthor } from './McpServerCard'; import type { InstalledServer, SmitheryServerDetail } from './types'; @@ -74,7 +75,7 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta }) .catch(err => { if (latestQualifiedNameRef.current !== requestedName) return; - const msg = err instanceof Error ? err.message : t('mcp.install.failedDetail'); + const msg = mcpRegistryErrorMessage(err, t, 'mcp.install.failedDetail'); log('detail error: %s', msg); setDetailError(msg); }) @@ -144,7 +145,7 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta } onSuccess(server); } catch (err) { - const msg = err instanceof Error ? err.message : t('mcp.install.failedInstall'); + const msg = mcpRegistryErrorMessage(err, t, 'mcp.install.failedInstall'); log('install error: %s', msg); setInstallError(msg); } finally { @@ -182,7 +183,7 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta size="sm" onClick={onCancel} className="text-content-muted hover:underline"> - {t('mcp.install.back')} + {t('mcp.installed.browseCatalog')}
); diff --git a/app/src/components/channels/mcp/McpCatalogBrowser.test.tsx b/app/src/components/channels/mcp/McpCatalogBrowser.test.tsx index b1ec18a65a..26b349ce87 100644 --- a/app/src/components/channels/mcp/McpCatalogBrowser.test.tsx +++ b/app/src/components/channels/mcp/McpCatalogBrowser.test.tsx @@ -143,8 +143,10 @@ describe('McpCatalogBrowser', () => { expect(screen.queryByRole('button', { name: 'Install' })).not.toBeInTheDocument(); }); - it('shows error state when search fails', async () => { - mockRegistrySearch.mockRejectedValue(new Error('Network error')); + it('shows friendly guidance when search fails', async () => { + mockRegistrySearch.mockRejectedValue( + new Error('MCP official registry returned HTTP 500: {"detail":"upstream down"}') + ); render( {}} />); await act(async () => { @@ -152,6 +154,8 @@ describe('McpCatalogBrowser', () => { }); vi.useRealTimers(); - await waitFor(() => screen.getByText('Network error')); + await waitFor(() => screen.getByText(/The MCP registry is unavailable right now/)); + expect(screen.getByText(/browse available MCP servers/)).toBeInTheDocument(); + expect(screen.queryByText(/"detail":"upstream down"/)).not.toBeInTheDocument(); }); }); diff --git a/app/src/components/channels/mcp/McpCatalogBrowser.tsx b/app/src/components/channels/mcp/McpCatalogBrowser.tsx index 9b669ff872..a8722919b6 100644 --- a/app/src/components/channels/mcp/McpCatalogBrowser.tsx +++ b/app/src/components/channels/mcp/McpCatalogBrowser.tsx @@ -8,6 +8,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; import { mcpClientsApi } from '../../../services/api/mcpClientsApi'; import Button from '../../ui/Button'; +import { mcpRegistryErrorMessage } from './mcpRegistryErrorMessage'; import McpServerCard from './McpServerCard'; import type { SmitheryServer } from './types'; @@ -57,7 +58,7 @@ const McpCatalogBrowser = ({ onSelectInstall }: McpCatalogBrowserProps) => { log('loaded %d servers (append=%s)', incoming.length, append); } catch (err) { if (seq !== requestSeqRef.current) return; - const msg = err instanceof Error ? err.message : t('mcp.catalog.loadFailed'); + const msg = mcpRegistryErrorMessage(err, t, 'mcp.catalog.loadFailed'); log('catalog fetch error: %s', msg); setError(msg); } finally { diff --git a/app/src/components/channels/mcp/McpServersTab.test.tsx b/app/src/components/channels/mcp/McpServersTab.test.tsx index 55dac4aa61..4d59e7595d 100644 --- a/app/src/components/channels/mcp/McpServersTab.test.tsx +++ b/app/src/components/channels/mcp/McpServersTab.test.tsx @@ -520,7 +520,9 @@ describe('McpServersTab', () => { mockInstalledList.mockResolvedValue([]); mockStatus.mockResolvedValue([]); // First catalog fetch fails → error state; retry succeeds → rows render. - mockRegistrySearch.mockRejectedValueOnce(new Error('registry down')); + mockRegistrySearch.mockRejectedValueOnce( + new Error('MCP official registry returned HTTP 500: {"detail":"registry down"}') + ); mockRegistrySearch.mockResolvedValue({ servers: [{ qualified_name: 'a/srv', display_name: 'Recovered Srv', is_deployed: false }], page: 1, @@ -532,7 +534,9 @@ describe('McpServersTab', () => { // Error surfaces instead of a silent empty state. const errorBox = await screen.findByTestId('mcp-catalog-error'); - expect(errorBox).toHaveTextContent('Failed to load catalog'); + expect(errorBox).toHaveTextContent('The MCP registry is unavailable right now'); + expect(errorBox).toHaveTextContent('browse available MCP servers'); + expect(errorBox).not.toHaveTextContent('"detail":"registry down"'); expect(screen.queryByTestId('mcp-catalog-empty')).not.toBeInTheDocument(); // Retry re-fetches and renders the recovered catalog. diff --git a/app/src/components/channels/mcp/McpServersTab.tsx b/app/src/components/channels/mcp/McpServersTab.tsx index ca4e36596b..428560dc9a 100644 --- a/app/src/components/channels/mcp/McpServersTab.tsx +++ b/app/src/components/channels/mcp/McpServersTab.tsx @@ -17,6 +17,7 @@ import InstallDialog from './InstallDialog'; import InstalledServerDetail from './InstalledServerDetail'; import McpConnectionHealthToolbar from './McpConnectionHealthToolbar'; import McpInventoryPanel from './McpInventoryPanel'; +import { mcpRegistryErrorMessage } from './mcpRegistryErrorMessage'; import { deriveAuthor } from './McpServerCard'; import type { ConnStatus, InstalledServer, ServerStatus, SmitheryServer } from './types'; @@ -272,7 +273,7 @@ const McpServersTab = () => { const [catalogTotalPages, setCatalogTotalPages] = useState(1); // Set when a registry fetch fails so the Registry view shows an error state // (with retry) instead of silently falling back to an empty/stale catalog. - const [catalogError, setCatalogError] = useState(false); + const [catalogError, setCatalogError] = useState(null); const pollTimerRef = useRef | null>(null); const debounceRef = useRef | null>(null); @@ -317,18 +318,18 @@ const McpServersTab = () => { ); setCatalogPage(result.page); setCatalogTotalPages(result.total_pages); - setCatalogError(false); + setCatalogError(null); } catch (err) { if (seq !== requestSeqRef.current) return; log('catalog fetch error: %o', err); // A fresh (non-append) fetch that fails leaves no usable rows — surface // the error. A failed "load more" keeps the rows already shown. - if (!append) setCatalogError(true); + if (!append) setCatalogError(mcpRegistryErrorMessage(err, t, 'mcp.catalog.loadFailed')); } finally { if (seq === requestSeqRef.current) setCatalogLoading(false); } }, - [] + [t] ); useEffect(() => { @@ -739,7 +740,7 @@ const McpServersTab = () => {
-

{t('mcp.catalog.loadFailed')}

+

{catalogError}

+
+ ) : ( + <> +

+ {proposal.name || t('chat.flowProposal.title')} +

+

+ {t('chat.flowProposal.subtitle')} +

-

- - {t('chat.flowProposal.triggerLabel')}: - {' '} - {proposal.summary.trigger} -

+

+ + {t('chat.flowProposal.triggerLabel')}: + {' '} + {proposal.summary.trigger} +

-
-

- {t('chat.flowProposal.stepsLabel')} -

- {proposal.summary.steps.length > 0 ? ( -
    - {proposal.summary.steps.map((step, i) => { - const kindI18nKey = stepKindI18nKey(step.kind); - const kindLabel = kindI18nKey - ? t(kindI18nKey) - : humanizeUnknownStepKind(step.kind); - return ( -
  1. - - {kindLabel} - - {step.name} -
  2. - ); - })} -
- ) : ( -

{t('chat.flowProposal.noSteps')}

- )} -
+
+

+ {t('chat.flowProposal.stepsLabel')} +

+ {proposal.summary.steps.length > 0 ? ( +
    + {proposal.summary.steps.map((step, i) => { + const kindI18nKey = stepKindI18nKey(step.kind); + const kindLabel = kindI18nKey + ? t(kindI18nKey) + : humanizeUnknownStepKind(step.kind); + return ( +
  1. + + {kindLabel} + + {step.name} +
  2. + ); + })} +
+ ) : ( +

+ {t('chat.flowProposal.noSteps')} +

+ )} +
- {proposal.requireApproval && ( -

- {t('chat.flowProposal.requireApprovalHint')} -

- )} + {proposal.requireApproval && ( +

+ {t('chat.flowProposal.requireApprovalHint')} +

+ )} - {errorMsg &&

⚠ {errorMsg}

} + {errorMsg &&

⚠ {errorMsg}

} -
- - - -
+
+ + + +
+ + )}
diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index c525953353..ba38060f7b 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -3433,6 +3433,8 @@ const messages: TranslationMap = { 'chat.flowProposal.error': 'تعذّر حفظ سير العمل. حاول مرة أخرى.', 'chat.flowProposal.enableError': 'تم حفظ سير العمل، لكن تعذّر تفعيله. حاول مرة أخرى، أو فعّله من صفحة سير العمل.', + 'chat.flowProposal.savedConfirmation': 'تم الحفظ', + 'chat.flowProposal.viewWorkflow': 'عرض سير العمل', 'chat.flowProposal.stepKind.agent': 'وكيل', 'chat.flowProposal.stepKind.toolCall': 'إجراء', 'chat.flowProposal.stepKind.httpRequest': 'طلب ويب', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 53bec88ce0..34b8f826cf 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -3512,6 +3512,8 @@ const messages: TranslationMap = { 'chat.flowProposal.error': 'ওয়ার্কফ্লো সংরক্ষণ করা যায়নি। আবার চেষ্টা করুন।', 'chat.flowProposal.enableError': 'ওয়ার্কফ্লো সংরক্ষিত হয়েছে, কিন্তু সক্ষম করা যায়নি। আবার চেষ্টা করুন, বা Workflows পৃষ্ঠা থেকে সক্ষম করুন।', + 'chat.flowProposal.savedConfirmation': 'সংরক্ষিত হয়েছে', + 'chat.flowProposal.viewWorkflow': 'ওয়ার্কফ্লো দেখুন', 'chat.flowProposal.stepKind.agent': 'এজেন্ট', 'chat.flowProposal.stepKind.toolCall': 'কার্যক্রম', 'chat.flowProposal.stepKind.httpRequest': 'ওয়েব অনুরোধ', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 3d08122ebb..12bc207d44 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -3614,6 +3614,8 @@ const messages: TranslationMap = { 'Der Workflow konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.', 'chat.flowProposal.enableError': 'Workflow gespeichert, konnte aber nicht aktiviert werden. Versuchen Sie es erneut oder aktivieren Sie ihn auf der Workflows-Seite.', + 'chat.flowProposal.savedConfirmation': 'Gespeichert', + 'chat.flowProposal.viewWorkflow': 'Workflow ansehen', 'chat.flowProposal.stepKind.agent': 'Agent', 'chat.flowProposal.stepKind.toolCall': 'Aktion', 'chat.flowProposal.stepKind.httpRequest': 'Webanfrage', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 1369bcf3d0..60ab358182 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -3917,6 +3917,11 @@ const en: TranslationMap = { 'chat.flowProposal.error': 'Could not save the workflow. Please try again.', 'chat.flowProposal.enableError': 'Workflow saved, but could not enable it. Try again, or enable it from the Workflows page.', + // Terminal success state (issue B36): shown once the flow is saved and + // enabled, in place of the editable proposal, with a link into the + // persisted flow's own canvas. + 'chat.flowProposal.savedConfirmation': 'Saved', + 'chat.flowProposal.viewWorkflow': 'View workflow', // Plain-language labels for each `tinyflows` node kind, shown as the badge // next to each step in the proposal card's step list. Keep names short: // they render as small pill badges. diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 85b75a3016..21178778d1 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -3576,6 +3576,8 @@ const messages: TranslationMap = { 'chat.flowProposal.error': 'No se pudo guardar el flujo de trabajo. Inténtalo de nuevo.', 'chat.flowProposal.enableError': 'Flujo de trabajo guardado, pero no se pudo activar. Inténtalo de nuevo o actívalo desde la página de Workflows.', + 'chat.flowProposal.savedConfirmation': 'Guardado', + 'chat.flowProposal.viewWorkflow': 'Ver flujo de trabajo', 'chat.flowProposal.stepKind.agent': 'Agente', 'chat.flowProposal.stepKind.toolCall': 'Acción', 'chat.flowProposal.stepKind.httpRequest': 'Solicitud web', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index a9ffe08f41..f22b74fa9a 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -3599,6 +3599,8 @@ const messages: TranslationMap = { 'chat.flowProposal.error': "Impossible d'enregistrer le workflow. Veuillez réessayer.", 'chat.flowProposal.enableError': "Workflow enregistré, mais impossible de l'activer. Réessayez, ou activez-le depuis la page Workflows.", + 'chat.flowProposal.savedConfirmation': 'Enregistré', + 'chat.flowProposal.viewWorkflow': 'Voir le workflow', 'chat.flowProposal.stepKind.agent': 'Agent', 'chat.flowProposal.stepKind.toolCall': 'Action', 'chat.flowProposal.stepKind.httpRequest': 'Requête web', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index ae6ae6c5bd..16492c1e89 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -3511,6 +3511,8 @@ const messages: TranslationMap = { 'chat.flowProposal.error': 'वर्कफ़्लो सहेजा नहीं जा सका। कृपया फिर से प्रयास करें।', 'chat.flowProposal.enableError': 'वर्कफ़्लो सहेजा गया, लेकिन सक्षम नहीं किया जा सका। फिर से प्रयास करें, या Workflows पेज से इसे सक्षम करें।', + 'chat.flowProposal.savedConfirmation': 'सहेजा गया', + 'chat.flowProposal.viewWorkflow': 'वर्कफ़्लो देखें', 'chat.flowProposal.stepKind.agent': 'एजेंट', 'chat.flowProposal.stepKind.toolCall': 'कार्रवाई', 'chat.flowProposal.stepKind.httpRequest': 'वेब अनुरोध', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 99b05238e7..f8d20853da 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -3528,6 +3528,8 @@ const messages: TranslationMap = { 'chat.flowProposal.error': 'Alur kerja tidak dapat disimpan. Silakan coba lagi.', 'chat.flowProposal.enableError': 'Alur kerja disimpan, tetapi tidak dapat diaktifkan. Coba lagi, atau aktifkan dari halaman Workflows.', + 'chat.flowProposal.savedConfirmation': 'Tersimpan', + 'chat.flowProposal.viewWorkflow': 'Lihat alur kerja', 'chat.flowProposal.stepKind.agent': 'Agen', 'chat.flowProposal.stepKind.toolCall': 'Tindakan', 'chat.flowProposal.stepKind.httpRequest': 'Permintaan web', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 3e953b892a..401cb4278d 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -3575,6 +3575,8 @@ const messages: TranslationMap = { 'chat.flowProposal.error': 'Impossibile salvare il workflow. Riprova.', 'chat.flowProposal.enableError': 'Workflow salvato, ma non è stato possibile attivarlo. Riprova, oppure attivalo dalla pagina Workflows.', + 'chat.flowProposal.savedConfirmation': 'Salvato', + 'chat.flowProposal.viewWorkflow': 'Visualizza workflow', 'chat.flowProposal.stepKind.agent': 'Agente', 'chat.flowProposal.stepKind.toolCall': 'Azione', 'chat.flowProposal.stepKind.httpRequest': 'Richiesta web', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index ec43167301..381678a15b 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -3476,6 +3476,8 @@ const messages: TranslationMap = { 'chat.flowProposal.error': '워크플로를 저장할 수 없습니다. 다시 시도하세요.', 'chat.flowProposal.enableError': '워크플로는 저장되었지만 활성화할 수 없습니다. 다시 시도하거나 Workflows 페이지에서 활성화하세요.', + 'chat.flowProposal.savedConfirmation': '저장됨', + 'chat.flowProposal.viewWorkflow': '워크플로 보기', 'chat.flowProposal.stepKind.agent': '에이전트', 'chat.flowProposal.stepKind.toolCall': '작업', 'chat.flowProposal.stepKind.httpRequest': '웹 요청', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index caa78f3ea8..a97d118985 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -3556,6 +3556,8 @@ const messages: TranslationMap = { 'chat.flowProposal.error': 'Nie udało się zapisać przepływu pracy. Spróbuj ponownie.', 'chat.flowProposal.enableError': 'Przepływ pracy zapisany, ale nie udało się go włączyć. Spróbuj ponownie lub włącz go na stronie Workflows.', + 'chat.flowProposal.savedConfirmation': 'Zapisano', + 'chat.flowProposal.viewWorkflow': 'Zobacz przepływ pracy', 'chat.flowProposal.stepKind.agent': 'Agent', 'chat.flowProposal.stepKind.toolCall': 'Akcja', 'chat.flowProposal.stepKind.httpRequest': 'Żądanie sieciowe', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index fe5ea7ef2b..2b7455f3aa 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -3568,6 +3568,8 @@ const messages: TranslationMap = { 'chat.flowProposal.error': 'Não foi possível salvar o fluxo de trabalho. Tente novamente.', 'chat.flowProposal.enableError': 'Fluxo de trabalho salvo, mas não foi possível ativá-lo. Tente novamente ou ative-o na página Workflows.', + 'chat.flowProposal.savedConfirmation': 'Salvo', + 'chat.flowProposal.viewWorkflow': 'Ver fluxo de trabalho', 'chat.flowProposal.stepKind.agent': 'Agente', 'chat.flowProposal.stepKind.toolCall': 'Ação', 'chat.flowProposal.stepKind.httpRequest': 'Pedido web', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 43324a7a4b..d564a15f85 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -3540,6 +3540,8 @@ const messages: TranslationMap = { 'chat.flowProposal.error': 'Не удалось сохранить рабочий процесс. Попробуйте еще раз.', 'chat.flowProposal.enableError': 'Рабочий процесс сохранён, но не удалось его включить. Попробуйте ещё раз или включите его на странице Workflows.', + 'chat.flowProposal.savedConfirmation': 'Сохранено', + 'chat.flowProposal.viewWorkflow': 'Просмотреть рабочий процесс', 'chat.flowProposal.stepKind.agent': 'Агент', 'chat.flowProposal.stepKind.toolCall': 'Действие', 'chat.flowProposal.stepKind.httpRequest': 'Веб-запрос', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index acdc4cda0d..bc1f33ad9c 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -3329,6 +3329,8 @@ const messages: TranslationMap = { 'chat.flowProposal.dismiss': '忽略', 'chat.flowProposal.error': '无法保存该工作流。请重试。', 'chat.flowProposal.enableError': '工作流已保存,但无法启用。请重试,或在“工作流”页面手动启用。', + 'chat.flowProposal.savedConfirmation': '已保存', + 'chat.flowProposal.viewWorkflow': '查看工作流', 'chat.flowProposal.stepKind.agent': '智能体', 'chat.flowProposal.stepKind.toolCall': '操作', 'chat.flowProposal.stepKind.httpRequest': '网络请求', diff --git a/app/src/store/chatRuntimeSlice.ts b/app/src/store/chatRuntimeSlice.ts index 05c61d8b8f..1282607523 100644 --- a/app/src/store/chatRuntimeSlice.ts +++ b/app/src/store/chatRuntimeSlice.ts @@ -546,6 +546,18 @@ export interface WorkflowProposal { * message `consumed: true` so the card does not resurrect on reload. */ sourceMessageId?: string; + /** + * Id of the flow once `WorkflowProposalCard`'s "Save & enable" has fully + * persisted AND enabled it (issue B36). Mirrored into Redux (rather than + * living only in the card's component state) because the card + * deliberately stays mounted showing a "saved" confirmation after success + * instead of dispatching `clearWorkflowProposalForThread` right away — so + * a thread/route change can remount the card before the user clicks + * "View workflow". Without this, the remount would reset local state to + * `null`, fall back to the pre-save editable view, and a second "Save & + * enable" click would call `createFlow` again and duplicate the flow. + */ + completedFlowId?: string; } /** @@ -1748,6 +1760,23 @@ const chatRuntimeSlice = createSlice({ clearWorkflowProposalForThread: (state, action: PayloadAction<{ threadId: string }>) => { delete state.pendingWorkflowProposalsByThread[action.payload.threadId]; }, + /** + * Record that a pending workflow proposal's flow finished saving AND + * enabling (issue B36), so `WorkflowProposalCard`'s terminal "saved" + * state survives a remount (thread switch, route change) while the + * proposal is still sitting in `pendingWorkflowProposalsByThread` — see + * `WorkflowProposal.completedFlowId`. No-op if the proposal was already + * cleared (e.g. a race with `clearWorkflowProposalForThread`). + */ + markWorkflowProposalCompleted: ( + state, + action: PayloadAction<{ threadId: string; flowId: string }> + ) => { + const proposal = state.pendingWorkflowProposalsByThread[action.payload.threadId]; + if (proposal) { + proposal.completedFlowId = action.payload.flowId; + } + }, /** * Mark a producer-tool call as in-flight so the `ArtifactCard` can * render a spinner before any ready/failed event arrives. Caller @@ -2286,6 +2315,7 @@ export const { clearPendingPlanReviewForThread, setWorkflowProposalForThread, clearWorkflowProposalForThread, + markWorkflowProposalCompleted, upsertArtifactInProgressForThread, upsertArtifactReadyForThread, upsertArtifactFailedForThread, From 58494e105c91493c84b29f5afc3e627152a4341d Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:24:11 +0530 Subject: [PATCH 39/56] fix(flows): shorter approval TTL for copilot live-run parks (#5112) --- src/openhuman/approval/gate.rs | 233 +++++++++++++++++++++++++++++++-- src/openhuman/approval/mod.rs | 3 +- src/openhuman/flows/ops.rs | 18 ++- 3 files changed, 239 insertions(+), 15 deletions(-) diff --git a/src/openhuman/approval/gate.rs b/src/openhuman/approval/gate.rs index c227231176..b78349ea21 100644 --- a/src/openhuman/approval/gate.rs +++ b/src/openhuman/approval/gate.rs @@ -69,6 +69,19 @@ const DEFAULT_APPROVAL_TTL: Duration = Duration::from_secs(60 * 10); /// minutes — if nobody approves within two, deny and move on. const IN_CALL_APPROVAL_TTL: Duration = Duration::from_secs(120); +/// Shorter park window for approvals raised by the Flow Canvas copilot's +/// live-run path — `flows_build` streaming into the copilot pane calling +/// `run_flow` / `resume_flow_run` (PR #5090). A stale ten-minute park on a +/// copilot pane the user may have already navigated away from is a long time +/// to leave a live Slack/Gmail/HTTP node waiting; if nobody approves within +/// three minutes, deny and let the authoring turn continue (the user can +/// still re-trigger the run from the Runs rail). Mirrors +/// [`IN_CALL_APPROVAL_TTL`]'s clamp, scoped by [`APPROVAL_COPILOT_STREAM_CONTEXT`] +/// instead of [`APPROVAL_IN_CALL_CONTEXT`]. Deliberately NOT applied to +/// main-chat `WebChat` parks — only `flows::ops::flows_build`'s streaming +/// branch scopes the task-local below. +const COPILOT_APPROVAL_TTL: Duration = Duration::from_secs(180); + /// Per-turn chat context for routing a parked approval's yes/no reply back to /// the originating thread. The web channel scopes this task-local around the /// agent run (`web_chat`); because the `run_turn` handler, the @@ -106,6 +119,19 @@ tokio::task_local! { pub static APPROVAL_IN_CALL_CONTEXT: InCallApprovalContext; } +tokio::task_local! { + /// Marks a park as originating from the Flow Canvas copilot's streaming + /// `flows_build` path — scoped by `flows::ops::flows_build` around the + /// streaming `agent.run_single(&prompt)` call, alongside the existing + /// `AgentTurnOrigin::WebChat` + [`APPROVAL_CHAT_CONTEXT`] double-scope that + /// path already uses. Presence alone is the signal (no fields needed): when + /// set, the park window is clamped to [`COPILOT_APPROVAL_TTL`] instead of + /// the gate's own (possibly env-overridden) TTL. Absent for every other + /// caller — in particular, plain main-chat `WebChat` turns do not scope + /// this, so they keep the full [`DEFAULT_APPROVAL_TTL`]. + pub static APPROVAL_COPILOT_STREAM_CONTEXT: (); +} + /// Per-run flow context (flow-approval-surface, PR2 of the tinyflows /// approval-surfacing design). `flows::ops::flows_run` / `flows_resume` /// scope this around the engine invocation, alongside the existing @@ -344,6 +370,28 @@ impl ApprovalGate { self.ttl } + /// Resolve the actual park duration from the gate's own `effective_ttl` + /// plus whichever origin-specific clamp is active for this call + /// (`in_call` → [`IN_CALL_APPROVAL_TTL`], `copilot_stream` → + /// [`COPILOT_APPROVAL_TTL`]). A clamp only ever *shortens* the park — it + /// can never extend `effective_ttl` past what the gate itself allows + /// (e.g. a debug env override). Both flags are expected to be mutually + /// exclusive in production (a live-meeting turn and a `flows_build` + /// copilot stream are different call sites), but if both were ever true + /// the tighter of the two clamps wins, since each `min` only narrows. + /// Split out (rather than inlined at the call site) so it is unit + /// testable without needing to actually park a future. + fn resolve_park_ttl(effective_ttl: Duration, in_call: bool, copilot_stream: bool) -> Duration { + let mut ttl = effective_ttl; + if in_call { + ttl = ttl.min(IN_CALL_APPROVAL_TTL); + } + if copilot_stream { + ttl = ttl.min(COPILOT_APPROVAL_TTL); + } + ttl + } + /// Whether `tool_name` is on the user's "Always allow" list. Prefers the /// process-global live policy (so a grant made this session is seen /// immediately) and falls back to the gate's boot-time config snapshot. @@ -614,6 +662,11 @@ impl ApprovalGate { // channel alongside the thread card (issue #3513). let in_call_ctx = APPROVAL_IN_CALL_CONTEXT.try_with(|c| c.clone()).ok(); + // Copilot-streaming context — set by `flows::ops::flows_build` around + // the streaming `run_single` call. Presence alone clamps the park + // window to `COPILOT_APPROVAL_TTL`; see that task-local's doc. + let copilot_stream = APPROVAL_COPILOT_STREAM_CONTEXT.try_with(|_| ()).is_ok(); + // Branch by origin. Web chat parks for an in-app approval; external // channel persists an audit row and TTL-denies (no routable approval // surface yet); trusted automation (cron, internal-only subconscious) @@ -772,8 +825,18 @@ impl ApprovalGate { let request_id = uuid::Uuid::new_v4().to_string(); let now = chrono::Utc::now(); - let expires_at = - Some(now + chrono::Duration::from_std(self.effective_ttl()).unwrap_or_default()); + // Resolve the clamped park TTL up front so the persisted `expires_at` + // and the actual wait below (see `resolve_park_ttl` further down) + // use the same value — see `Self::resolve_park_ttl` and the + // COPILOT_APPROVAL_TTL / IN_CALL_APPROVAL_TTL clamps. Computing this + // only after persisting the pending row let a copilot-streaming park + // advertise the old 10-minute `expires_at` while only actually + // waiting 180s, so a core restart or an `expire_stale` sweep mid-park + // could leave the row "actionable" for the wrong window (CodeRabbit + // + Codex review on PR #5112). + let effective_ttl = + Self::resolve_park_ttl(self.effective_ttl(), in_call_ctx.is_some(), copilot_stream); + let expires_at = Some(now + chrono::Duration::from_std(effective_ttl).unwrap_or_default()); // Correlation context (flow-approval-surface, PR2): a Workflow-origin // park carries the flow id on the origin itself, but not the run id — @@ -917,15 +980,21 @@ impl ApprovalGate { "[approval::gate] tool call parked, waiting for decision" ); - // Live meetings get a clamped park window — see IN_CALL_APPROVAL_TTL. - // `effective_ttl()` applies the debug-only env override; the in-call - // clamp is applied on top so a longer override can't extend a live - // meeting's park window past IN_CALL_APPROVAL_TTL. - let effective_ttl = if in_call_ctx.is_some() { - IN_CALL_APPROVAL_TTL.min(self.effective_ttl()) - } else { - self.effective_ttl() - }; + // Live meetings and copilot-streaming flows_build runs each get a + // clamped park window — see IN_CALL_APPROVAL_TTL / COPILOT_APPROVAL_TTL + // and `Self::resolve_park_ttl`. `effective_ttl` was resolved above + // (before `expires_at` was built) so the persisted expiry and this + // wait use the identical clamped duration; `effective_ttl()` applies + // the debug-only env override, and the clamp is applied on top so a + // longer override can't extend either park past its clamp. + if copilot_stream { + tracing::debug!( + tool = tool_name, + ttl_secs = COPILOT_APPROVAL_TTL.as_secs(), + "[approval::gate] flows_build copilot-streaming park — clamping park window to \ + COPILOT_APPROVAL_TTL" + ); + } // Optional caller-supplied park bound (issue #4756). A caller // (`composio_connect`) can cap how long the gate parks so a turn @@ -2448,6 +2517,148 @@ mod tests { ); } + /// Tests for `resolve_park_ttl` — the pure clamp-selection helper behind + /// the copilot-streaming TTL shortening (fix/flows-copilot-approval-ttl). + /// Exercised directly (rather than by actually parking + waiting out a + /// multi-minute TTL) so the assertions stay fast and deterministic. + mod resolve_park_ttl_tests { + use super::*; + + #[test] + fn default_park_keeps_the_full_ttl() { + let default_ttl = DEFAULT_APPROVAL_TTL; + assert_eq!( + ApprovalGate::resolve_park_ttl(default_ttl, false, false), + default_ttl, + "a plain park (no in-call, no copilot stream) must not be clamped" + ); + } + + #[test] + fn copilot_stream_shortens_a_default_ten_minute_park() { + let default_ttl = DEFAULT_APPROVAL_TTL; + assert_eq!( + ApprovalGate::resolve_park_ttl(default_ttl, false, true), + COPILOT_APPROVAL_TTL, + "a flows_build copilot-streaming park must clamp to COPILOT_APPROVAL_TTL" + ); + assert!( + COPILOT_APPROVAL_TTL < DEFAULT_APPROVAL_TTL, + "the copilot clamp must actually be shorter than the default TTL" + ); + } + + #[test] + fn in_call_clamp_is_unaffected_by_the_copilot_flag() { + let default_ttl = DEFAULT_APPROVAL_TTL; + assert_eq!( + ApprovalGate::resolve_park_ttl(default_ttl, true, false), + IN_CALL_APPROVAL_TTL, + "an in-call meeting park must keep clamping to IN_CALL_APPROVAL_TTL, unchanged \ + by this fix" + ); + } + + #[test] + fn a_clamp_never_extends_a_shorter_boot_time_ttl() { + // Mirrors production's env-override guard: a clamp may only + // narrow, never widen, the gate's own effective TTL (e.g. a + // debug-only `OPENHUMAN_APPROVAL_TTL_SECS=60` override that is + // already shorter than either clamp). + let short_ttl = Duration::from_secs(60); + assert_eq!( + ApprovalGate::resolve_park_ttl(short_ttl, false, true), + short_ttl, + "copilot clamp must not extend a boot-time TTL that is already shorter" + ); + assert_eq!( + ApprovalGate::resolve_park_ttl(short_ttl, true, false), + short_ttl, + "in-call clamp must not extend a boot-time TTL that is already shorter" + ); + } + + #[test] + fn both_flags_active_takes_the_tighter_clamp() { + // Not expected in production (different call sites), but the + // helper must not panic or pick the wrong side if it ever + // happens — the tighter of the two clamps should win either way. + let default_ttl = DEFAULT_APPROVAL_TTL; + let tighter = IN_CALL_APPROVAL_TTL.min(COPILOT_APPROVAL_TTL); + assert_eq!( + ApprovalGate::resolve_park_ttl(default_ttl, true, true), + tighter + ); + } + } + + /// Integration regression test for the streaming-to-gate contract + /// (CodeRabbit review on PR #5112): `resolve_park_ttl` is covered directly + /// above, but that alone doesn't prove `intercept_audited_inner` actually + /// persists the clamped TTL when the copilot-streaming context is scoped. + /// Builds a gate with the full `DEFAULT_APPROVAL_TTL` boot TTL (unlike + /// `test_gate()`'s 2s, which is already shorter than either clamp and + /// would make this assertion vacuous), scopes + /// `APPROVAL_COPILOT_STREAM_CONTEXT` alongside the chat context + WebChat + /// origin the way `flows::ops::flows_build` does in production, and + /// inspects the persisted `expires_at` on the pending row. + #[tokio::test] + async fn copilot_streaming_park_persists_the_clamped_expiry() { + let dir = TempDir::new().unwrap(); + let config = Config { + workspace_dir: dir.path().to_path_buf(), + ..Config::default() + }; + let session = format!("session-{}", uuid::Uuid::new_v4()); + let gate = ApprovalGate::new(config, session, DEFAULT_APPROVAL_TTL); + let gate = Arc::new(gate); + + let before = chrono::Utc::now(); + let g = gate.clone(); + let handle = tokio::spawn(async move { + turn_origin::with_origin( + web_origin(), + APPROVAL_CHAT_CONTEXT.scope( + chat_ctx(), + APPROVAL_COPILOT_STREAM_CONTEXT.scope( + (), + g.intercept("composio", "send slack", serde_json::json!({})), + ), + ), + ) + .await + }); + + let pending = loop { + if let Some(p) = gate.list_pending().unwrap().into_iter().next() { + break p; + } + tokio::task::yield_now().await; + }; + + let expires_at = pending + .expires_at + .expect("a parked approval always sets expires_at"); + let ttl_persisted = expires_at - before; + assert!( + ttl_persisted + <= chrono::Duration::from_std(COPILOT_APPROVAL_TTL).unwrap() + + chrono::Duration::seconds(5), + "copilot-streaming park must persist an expires_at clamped to COPILOT_APPROVAL_TTL \ + (180s), not the gate's full {:?} boot TTL — got a {ttl_persisted} window", + DEFAULT_APPROVAL_TTL + ); + assert!( + ttl_persisted < chrono::Duration::from_std(DEFAULT_APPROVAL_TTL).unwrap(), + "sanity: the persisted expiry must be shorter than the unclamped default TTL" + ); + + gate.decide(&pending.request_id, ApprovalDecision::ApproveOnce) + .unwrap(); + let outcome = handle.await.unwrap(); + assert!(matches!(outcome, GateOutcome::Allow)); + } + #[test] fn parse_approval_reply_maps_yes_no_and_rejects_other() { for y in ["yes", "Y", " OK ", "approve", "Allow", "okay"] { diff --git a/src/openhuman/approval/mod.rs b/src/openhuman/approval/mod.rs index b575f525fb..45bec5f015 100644 --- a/src/openhuman/approval/mod.rs +++ b/src/openhuman/approval/mod.rs @@ -22,7 +22,8 @@ pub mod types; pub use gate::{ parse_approval_reply, ApprovalChatContext, ApprovalGate, FlowRunContext, InCallApprovalContext, - APPROVAL_CHAT_CONTEXT, APPROVAL_FLOW_RUN_CONTEXT, APPROVAL_IN_CALL_CONTEXT, + APPROVAL_CHAT_CONTEXT, APPROVAL_COPILOT_STREAM_CONTEXT, APPROVAL_FLOW_RUN_CONTEXT, + APPROVAL_IN_CALL_CONTEXT, }; pub use redact::{redact_args, summarize_action}; pub use schemas::all_controller_schemas as all_approval_controller_schemas; diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index d65b46c72f..f83327f8e5 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -11,7 +11,8 @@ use tinyflows::model::{NodeKind, TriggerKind, WorkflowGraph}; use crate::openhuman::agent::turn_origin::{with_origin, AgentTurnOrigin, TrustedAutomationSource}; use crate::openhuman::approval::{ - ApprovalChatContext, FlowRunContext, APPROVAL_CHAT_CONTEXT, APPROVAL_FLOW_RUN_CONTEXT, + ApprovalChatContext, FlowRunContext, APPROVAL_CHAT_CONTEXT, APPROVAL_COPILOT_STREAM_CONTEXT, + APPROVAL_FLOW_RUN_CONTEXT, }; use crate::openhuman::config::Config; use crate::openhuman::flows::bus; @@ -4451,11 +4452,22 @@ pub async fn flows_build( request_id = %target.request_id, "[flows] flows_build: streaming copilot turn — WebChat origin + \ APPROVAL_CHAT_CONTEXT scoped, live-run tools park for approval instead \ - of auto-allowing" + of auto-allowing (shortened to COPILOT_APPROVAL_TTL via \ + APPROVAL_COPILOT_STREAM_CONTEXT)" ); + // `APPROVAL_COPILOT_STREAM_CONTEXT` scopes alongside the existing + // chat context so any `run_flow`/`resume_flow_run` park raised by + // this turn is clamped to the shorter `COPILOT_APPROVAL_TTL` + // instead of the gate's full ten-minute default — a stale park on + // a copilot pane the user may have already navigated away from + // shouldn't idle that long. Main-chat turns never scope this, so + // they are unaffected. let run = with_origin( origin, - APPROVAL_CHAT_CONTEXT.scope(chat_ctx, agent.run_single(&prompt)), + APPROVAL_CHAT_CONTEXT.scope( + chat_ctx, + APPROVAL_COPILOT_STREAM_CONTEXT.scope((), agent.run_single(&prompt)), + ), ); let run = tokio::time::timeout(std::time::Duration::from_secs(FLOW_BUILD_TIMEOUT_SECS), run); From 9b726165e125a4ec03b9a3a9983ab0f3fbdd6c90 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:43:10 +0530 Subject: [PATCH 40/56] =?UTF-8?q?feat(flows):=20FlowRunFinished=20event=20?= =?UTF-8?q?=E2=80=94=20fully=20event-driven=20runs=20rail=20(completes=20B?= =?UTF-8?q?35)=20(#5115)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/flows/FlowRunsDrawer.test.tsx | 2 +- app/src/components/flows/FlowRunsDrawer.tsx | 5 + app/src/components/flows/FlowRunsSidebar.tsx | 8 ++ .../__tests__/useFlowRunFinished.test.ts | 135 ++++++++++++++++++ .../hooks/__tests__/useFlowRunPoller.test.ts | 100 ++++++++++++- .../__tests__/useFlowRunsLiveRefresh.test.ts | 6 +- app/src/hooks/useFlowRunFinished.ts | 122 ++++++++++++++++ app/src/hooks/useFlowRunPoller.ts | 61 +++++++- app/src/hooks/useFlowRunsLiveRefresh.ts | 25 ++-- app/src/pages/WorkflowRunsPage.test.tsx | 6 +- app/src/pages/WorkflowRunsPage.tsx | 6 + src/core/event_bus/events.rs | 17 +++ src/core/socketio.rs | 25 ++++ src/openhuman/flows/ops.rs | 71 ++++++++- src/openhuman/flows/ops_tests.rs | 122 ++++++++++++++++ 15 files changed, 680 insertions(+), 31 deletions(-) create mode 100644 app/src/hooks/__tests__/useFlowRunFinished.test.ts create mode 100644 app/src/hooks/useFlowRunFinished.ts diff --git a/app/src/components/flows/FlowRunsDrawer.test.tsx b/app/src/components/flows/FlowRunsDrawer.test.tsx index d762ff6806..74c05d63f1 100644 --- a/app/src/components/flows/FlowRunsDrawer.test.tsx +++ b/app/src/components/flows/FlowRunsDrawer.test.tsx @@ -294,7 +294,7 @@ describe('FlowRunsDrawer', () => { // Trigger the live-refresh poll fallback — issues the second, hanging // listFlowRuns('flow-a') call. await act(async () => { - vi.advanceTimersByTime(5_000); + vi.advanceTimersByTime(30_000); }); expect(flowACalls).toBe(2); diff --git a/app/src/components/flows/FlowRunsDrawer.tsx b/app/src/components/flows/FlowRunsDrawer.tsx index aacb82d363..17b95a63a5 100644 --- a/app/src/components/flows/FlowRunsDrawer.tsx +++ b/app/src/components/flows/FlowRunsDrawer.tsx @@ -28,6 +28,7 @@ import debug from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useEscapeKey } from '../../hooks/useEscapeKey'; +import { useFlowRunFinished } from '../../hooks/useFlowRunFinished'; import { useFlowRunsLiveRefresh } from '../../hooks/useFlowRunsLiveRefresh'; import { useFlowRunStarted } from '../../hooks/useFlowRunStarted'; import { @@ -167,6 +168,10 @@ export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Pr // can't reach, so the very first run shows up as "Running" instantly // instead of waiting for a manual refresh (issue B35). useFlowRunStarted(() => void refetch(), flowId); + // Terminal companion to the above (issue B35 follow-up) — flips a run to + // Completed/Failed the instant it settles instead of waiting on + // `useFlowRunsLiveRefresh`'s debounced/backstop refetch to notice. + useFlowRunFinished(() => void refetch(), flowId); const pendingRunIds = useRunsPendingApprovalSet(runs); useEscapeKey( diff --git a/app/src/components/flows/FlowRunsSidebar.tsx b/app/src/components/flows/FlowRunsSidebar.tsx index ac3782634b..3645f54fe5 100644 --- a/app/src/components/flows/FlowRunsSidebar.tsx +++ b/app/src/components/flows/FlowRunsSidebar.tsx @@ -14,6 +14,7 @@ import createDebug from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { useFlowRunFinished } from '../../hooks/useFlowRunFinished'; import { useFlowRunsLiveRefresh } from '../../hooks/useFlowRunsLiveRefresh'; import { useFlowRunStarted } from '../../hooks/useFlowRunStarted'; import { @@ -125,6 +126,13 @@ export default function FlowRunsSidebar({ flowId }: FlowRunsSidebarProps) { log('run-started: refetch flow=%s', flowId); void load(); }, flowId); + // Terminal companion to the above (issue B35 follow-up) — flips a run to + // Completed/Failed the instant it settles instead of waiting on + // `useFlowRunsLiveRefresh`'s debounced/backstop refetch to notice. + useFlowRunFinished(event => { + log('run-finished: refetch flow=%s run=%s status=%s', flowId, event.run_id, event.status); + void load(); + }, flowId); const pendingRunIds = useRunsPendingApprovalSet(runs); return ( diff --git a/app/src/hooks/__tests__/useFlowRunFinished.test.ts b/app/src/hooks/__tests__/useFlowRunFinished.test.ts new file mode 100644 index 0000000000..aa0497f441 --- /dev/null +++ b/app/src/hooks/__tests__/useFlowRunFinished.test.ts @@ -0,0 +1,135 @@ +/** + * useFlowRunFinished — unit tests (issue B35 follow-up). + * + * Verifies: subscribes to both socket event aliases unconditionally on mount, + * invokes `onFinish` for a matching payload, filters by `flowId` when + * provided, passes every event through when `flowId` is omitted, drops + * invalid payloads, dedupes the colon/underscore alias replay, and tears down + * both subscriptions on unmount. + */ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useFlowRunFinished } from '../useFlowRunFinished'; + +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_finished' | 'flow_run_finished', payload: unknown) { + act(() => { + for (const cb of handlers.get(event) ?? []) cb(payload); + }); +} + +describe('useFlowRunFinished', () => { + beforeEach(() => { + handlers.clear(); + on.mockClear(); + off.mockClear(); + }); + + it('subscribes to both event aliases unconditionally on mount', () => { + renderHook(() => useFlowRunFinished(vi.fn())); + + expect(on).toHaveBeenCalledWith('flow:run_finished', expect.any(Function)); + expect(on).toHaveBeenCalledWith('flow_run_finished', expect.any(Function)); + }); + + it('invokes onFinish for a matching payload on either alias', () => { + const onFinish = vi.fn(); + renderHook(() => useFlowRunFinished(onFinish)); + + emit('flow:run_finished', { flow_id: 'flow-1', run_id: 'run-1', status: 'completed' }); + expect(onFinish).toHaveBeenCalledWith({ + flow_id: 'flow-1', + run_id: 'run-1', + status: 'completed', + }); + + emit('flow_run_finished', { flow_id: 'flow-2', run_id: 'run-2', status: 'failed' }); + expect(onFinish).toHaveBeenCalledWith({ flow_id: 'flow-2', run_id: 'run-2', status: 'failed' }); + expect(onFinish).toHaveBeenCalledTimes(2); + }); + + it('dedupes the colon and underscore aliases of the same run so onFinish fires once', () => { + // The core bridge re-emits one `FlowRunFinished` event under both socket + // aliases with identical payloads — assert the hook collapses them. + const onFinish = vi.fn(); + renderHook(() => useFlowRunFinished(onFinish)); + + const payload = { flow_id: 'flow-1', run_id: 'run-1', status: 'completed' }; + emit('flow:run_finished', payload); + emit('flow_run_finished', payload); + + expect(onFinish).toHaveBeenCalledTimes(1); + expect(onFinish).toHaveBeenCalledWith({ + flow_id: 'flow-1', + run_id: 'run-1', + status: 'completed', + }); + + // A genuinely different run still gets through. + emit('flow:run_finished', { flow_id: 'flow-1', run_id: 'run-2', status: 'failed' }); + expect(onFinish).toHaveBeenCalledTimes(2); + }); + + it('filters to the given flowId when provided', () => { + const onFinish = vi.fn(); + renderHook(() => useFlowRunFinished(onFinish, 'flow-1')); + + emit('flow:run_finished', { flow_id: 'flow-2', run_id: 'run-1', status: 'completed' }); + expect(onFinish).not.toHaveBeenCalled(); + + emit('flow:run_finished', { flow_id: 'flow-1', run_id: 'run-2', status: 'completed' }); + expect(onFinish).toHaveBeenCalledWith({ + flow_id: 'flow-1', + run_id: 'run-2', + status: 'completed', + }); + expect(onFinish).toHaveBeenCalledTimes(1); + }); + + it('passes every event through when flowId is omitted', () => { + const onFinish = vi.fn(); + renderHook(() => useFlowRunFinished(onFinish)); + + emit('flow:run_finished', { flow_id: 'flow-1', run_id: 'run-1', status: 'completed' }); + emit('flow:run_finished', { flow_id: 'flow-2', run_id: 'run-2', status: 'failed' }); + + expect(onFinish).toHaveBeenCalledTimes(2); + }); + + it('drops invalid payloads without invoking onFinish', () => { + const onFinish = vi.fn(); + renderHook(() => useFlowRunFinished(onFinish)); + + emit('flow:run_finished', null); + emit('flow:run_finished', {}); + emit('flow:run_finished', { flow_id: 123, run_id: 'run-1', status: 'completed' }); + emit('flow:run_finished', { flow_id: 'flow-1', run_id: 456, status: 'completed' }); + emit('flow:run_finished', { flow_id: 'flow-1', run_id: 'run-1', status: 42 }); + + expect(onFinish).not.toHaveBeenCalled(); + }); + + it('cleans up both subscriptions on unmount', () => { + const { unmount } = renderHook(() => useFlowRunFinished(vi.fn())); + + unmount(); + + expect(off).toHaveBeenCalledWith('flow:run_finished', expect.any(Function)); + expect(off).toHaveBeenCalledWith('flow_run_finished', expect.any(Function)); + }); +}); diff --git a/app/src/hooks/__tests__/useFlowRunPoller.test.ts b/app/src/hooks/__tests__/useFlowRunPoller.test.ts index d1fa33dffe..a25efcca15 100644 --- a/app/src/hooks/__tests__/useFlowRunPoller.test.ts +++ b/app/src/hooks/__tests__/useFlowRunPoller.test.ts @@ -1,10 +1,11 @@ /** * useFlowRunPoller (issue B3b) — poll-until-terminal contract. * - * Asserts: initial loading→resolved, 2s poll cadence while `running` / + * Asserts: initial loading→resolved, 3s poll cadence while `running` / * `pending_approval`, stop on `completed`/`failed`, stop when `runId` goes - * `null`, error surfaced (and no further poll) on rejection, and effect - * cleanup on unmount. + * `null`, error surfaced (and no further poll) on rejection, effect cleanup + * on unmount, and (issue B35 follow-up) an immediate out-of-cadence tick + * forced by a matching `FlowRunFinished` socket event. */ import { act, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -15,6 +16,30 @@ import { useFlowRunPoller } from '../useFlowRunPoller'; const getFlowRun = vi.hoisted(() => vi.fn()); vi.mock('../../services/api/flowsApi', () => ({ getFlowRun })); +const handlers = vi.hoisted(() => new Map void>>()); +const socketOn = 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 socketOff = vi.hoisted(() => + vi.fn((event: string, cb: (data: unknown) => void) => { + handlers.get(event)?.delete(cb); + }) +); +vi.mock('../../services/socketService', () => ({ + socketService: { on: socketOn, off: socketOff }, +})); + +function emitFinished( + event: 'flow:run_finished' | 'flow_run_finished', + payload: { flow_id: string; run_id: string; status: string } +) { + for (const cb of handlers.get(event) ?? []) cb(payload); +} + function makeRun(overrides: Partial = {}): FlowRun { return { id: 'thread-1', @@ -31,6 +56,7 @@ function makeRun(overrides: Partial = {}): FlowRun { describe('useFlowRunPoller', () => { beforeEach(() => { vi.clearAllMocks(); + handlers.clear(); vi.useFakeTimers(); }); @@ -54,7 +80,7 @@ describe('useFlowRunPoller', () => { expect(result.current.error).toBeNull(); }); - it('polls every 2s while the run is running', async () => { + it('polls every 3s while the run is running', async () => { getFlowRun.mockResolvedValue(makeRun({ status: 'running' })); renderHook(() => useFlowRunPoller('thread-1')); @@ -64,12 +90,12 @@ describe('useFlowRunPoller', () => { expect(getFlowRun).toHaveBeenCalledTimes(1); await act(async () => { - await vi.advanceTimersByTimeAsync(2000); + await vi.advanceTimersByTimeAsync(3000); }); expect(getFlowRun).toHaveBeenCalledTimes(2); await act(async () => { - await vi.advanceTimersByTimeAsync(2000); + await vi.advanceTimersByTimeAsync(3000); }); expect(getFlowRun).toHaveBeenCalledTimes(3); }); @@ -85,7 +111,7 @@ describe('useFlowRunPoller', () => { expect(getFlowRun).toHaveBeenCalledTimes(1); await act(async () => { - await vi.advanceTimersByTimeAsync(2000); + await vi.advanceTimersByTimeAsync(3000); }); expect(getFlowRun).toHaveBeenCalledTimes(2); }); @@ -230,4 +256,64 @@ describe('useFlowRunPoller', () => { expect(result.current.run).toBeNull(); expect(getFlowRun).not.toHaveBeenCalled(); }); + + // ── FlowRunFinished-forced immediate tick (issue B35 follow-up) ───────── + + it('forces an immediate out-of-cadence tick and stops polling when a matching FlowRunFinished event arrives', async () => { + getFlowRun.mockResolvedValueOnce(makeRun({ status: 'running' })); + const { result } = renderHook(() => useFlowRunPoller('thread-1')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(getFlowRun).toHaveBeenCalledTimes(1); + expect(result.current.run?.status).toBe('running'); + + getFlowRun.mockResolvedValueOnce( + makeRun({ status: 'completed', finished_at: '2026-01-01T00:01:00Z' }) + ); + + // Fires well before the next scheduled (3s) poll tick would. + await act(async () => { + emitFinished('flow:run_finished', { + flow_id: 'flow-1', + run_id: 'thread-1', + status: 'completed', + }); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(getFlowRun).toHaveBeenCalledTimes(2); + expect(result.current.run?.status).toBe('completed'); + + // The scheduled poll from the first tick must have been cancelled by the + // forced tick — no further calls once terminal, even well past 3s. + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(getFlowRun).toHaveBeenCalledTimes(2); + }); + + it('ignores a FlowRunFinished event for a different run', async () => { + getFlowRun.mockResolvedValue(makeRun({ status: 'running' })); + renderHook(() => useFlowRunPoller('thread-1')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(getFlowRun).toHaveBeenCalledTimes(1); + + await act(async () => { + emitFinished('flow_run_finished', { + flow_id: 'flow-2', + run_id: 'thread-2', + status: 'completed', + }); + await vi.advanceTimersByTimeAsync(0); + }); + + // No forced tick for an unrelated run — the regular 3s cadence still + // governs. + expect(getFlowRun).toHaveBeenCalledTimes(1); + }); }); diff --git a/app/src/hooks/__tests__/useFlowRunsLiveRefresh.test.ts b/app/src/hooks/__tests__/useFlowRunsLiveRefresh.test.ts index 2ba90066a3..ab2ea1fb80 100644 --- a/app/src/hooks/__tests__/useFlowRunsLiveRefresh.test.ts +++ b/app/src/hooks/__tests__/useFlowRunsLiveRefresh.test.ts @@ -80,10 +80,10 @@ describe('useFlowRunsLiveRefresh', () => { expect(on).toHaveBeenCalledWith('flow:run_progress', expect.any(Function)); expect(on).toHaveBeenCalledWith('flow_run_progress', expect.any(Function)); - vi.advanceTimersByTime(5_000); + vi.advanceTimersByTime(30_000); expect(refetch).toHaveBeenCalledTimes(1); - vi.advanceTimersByTime(5_000); + vi.advanceTimersByTime(30_000); expect(refetch).toHaveBeenCalledTimes(2); }); @@ -92,7 +92,7 @@ describe('useFlowRunsLiveRefresh', () => { 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 + // first 30s 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); diff --git a/app/src/hooks/useFlowRunFinished.ts b/app/src/hooks/useFlowRunFinished.ts new file mode 100644 index 0000000000..738a2dbc09 --- /dev/null +++ b/app/src/hooks/useFlowRunFinished.ts @@ -0,0 +1,122 @@ +/** + * useFlowRunFinished (issue B35 follow-up — runs-rail live refresh) + * ------------------------------------------------------------------- + * + * Terminal companion to {@link useFlowRunStarted}: subscribes to the core's + * run-finish feed so an open Workflows sidebar/drawer flips a run to + * Completed/Failed the instant it settles, instead of waiting on a poll to + * notice the terminal `flow_runs` row. + * + * The backend publishes `DomainEvent::FlowRunFinished` right after + * `flows::ops::finish_flow_run_row` persists the settled row; the core socket + * bridge (`src/core/socketio.rs`) re-emits it as both `flow:run_finished` and + * `flow_run_finished` (colon + underscore aliases) with the payload + * `{ flow_id, run_id, status }`. + * + * Like `useFlowRunStarted`, this hook subscribes unconditionally (not gated + * on an already-active run). Pass `flowId` to filter to a single flow + * (canvas/sidebar/drawer), or omit it to receive every run finish (the + * flow-agnostic runs page). + * + * Because the core bridge re-emits the same `FlowRunFinished` event under + * both aliases, this hook subscribes to both sockets above but de-dupes by + * `${flow_id}:${run_id}` (unique per run) so `onFinish` fires exactly once + * per run — a bounded set of recently-delivered keys (oldest evicted first) + * is kept per hook instance, sized well past any plausible in-flight burst. + */ +import debug from 'debug'; +import { useCallback, useEffect, useRef } from 'react'; + +import { socketService } from '../services/socketService'; + +const log = debug('flows:run-finished'); + +/** Socket event aliases the core bridge emits (colon + underscore forms). */ +const EVENT_COLON = 'flow:run_finished'; +const EVENT_UNDERSCORE = 'flow_run_finished'; + +/** Bound on the recently-delivered dedup set — oldest key evicted past this. */ +const DEDUP_CACHE_SIZE = 50; + +/** Payload of a `flow:run_finished` socket event (`DomainEvent::FlowRunFinished`). */ +export interface FlowRunFinishedEvent { + flow_id: string; + run_id: string; + status: string; +} + +function parsePayload(data: unknown): FlowRunFinishedEvent | null { + if (!data || typeof data !== 'object') return null; + const obj = data as Record; + if ( + typeof obj.flow_id !== 'string' || + typeof obj.run_id !== 'string' || + typeof obj.status !== 'string' + ) + return null; + return { flow_id: obj.flow_id, run_id: obj.run_id, status: obj.status }; +} + +/** + * Invokes `onFinish` whenever a run finishes. When `flowId` is provided, + * only finishes for that flow are delivered; otherwise every finish is. + */ +export function useFlowRunFinished( + onFinish: (event: FlowRunFinishedEvent) => void, + flowId?: string | null +): void { + // Recently-delivered `${flow_id}:${run_id}` keys, so the colon and + // underscore aliases of the same event only invoke `onFinish` once. A `Set` + // preserves insertion order, so eviction just drops its first entry. + const deliveredRef = useRef>(new Set()); + + const handle = useCallback( + (data: unknown) => { + const payload = parsePayload(data); + if (!payload) { + // Never log the raw payload — it may carry PII/secrets. Safe metadata + // only: the runtime type and, if it's an object, its key names. + const keys = data && typeof data === 'object' ? Object.keys(data as object) : []; + log('run-finished: dropped — invalid payload (type=%s keys=%o)', typeof data, keys); + return; + } + if (flowId && payload.flow_id !== flowId) return; + + const key = `${payload.flow_id}:${payload.run_id}`; + const delivered = deliveredRef.current; + if (delivered.has(key)) { + log( + 'run-finished: dedup skip (alias replay) flow=%s run=%s', + payload.flow_id, + payload.run_id + ); + return; + } + delivered.add(key); + if (delivered.size > DEDUP_CACHE_SIZE) { + const oldest = delivered.values().next().value; + if (oldest !== undefined) delivered.delete(oldest); + } + + log( + 'run-finished: flow=%s run=%s status=%s', + payload.flow_id, + payload.run_id, + payload.status + ); + onFinish(payload); + }, + [onFinish, flowId] + ); + + useEffect(() => { + socketService.on(EVENT_COLON, handle); + socketService.on(EVENT_UNDERSCORE, handle); + return () => { + socketService.off(EVENT_COLON, handle); + socketService.off(EVENT_UNDERSCORE, handle); + }; + }, [handle]); +} + +export default useFlowRunFinished; diff --git a/app/src/hooks/useFlowRunPoller.ts b/app/src/hooks/useFlowRunPoller.ts index 085476f276..efad8df8d1 100644 --- a/app/src/hooks/useFlowRunPoller.ts +++ b/app/src/hooks/useFlowRunPoller.ts @@ -3,14 +3,27 @@ * ---------------------------- * * Poll-until-terminal loop for a single durable `tinyflows` run, feeding the - * {@link FlowRunInspectorDrawer}. The flows engine emits no socket events for - * run progress (same situation as the `workflow_run_*` orchestration surface), - * so this mirrors the setTimeout-chained poll loop in + * {@link FlowRunInspectorDrawer}. This is NOT purely a terminal-transition + * signal — unlike `useFlowRunsLiveRefresh` (list-level, now backstopped by + * `DomainEvent::FlowRunFinished` via `useFlowRunFinished`), each tick here + * also refreshes the run's evolving per-step output (`FlowRunStep.output`) + * for the drawer's step timeline, and no event carries that content — so + * this hook still needs an actual poll loop while a run is in flight, not + * just a long backstop. It mirrors the setTimeout-chained poll loop in * `components/intelligence/IntelligenceOrchestrationTab.tsx` (~lines 112-143): * schedule the next poll only after the current one resolves and the run is * still non-terminal, guard against races with `cancelled`/`inFlight`, and * never let an unmounted component call `setState`. * + * Now that `DomainEvent::FlowRunFinished` exists (issue B35 follow-up, + * bridged as `flow:run_finished` / `flow_run_finished`), this hook also + * subscribes via `useFlowRunFinished` and forces an immediate out-of-cadence + * tick (cancelling any pending scheduled poll) the moment a finish event for + * `runId` arrives, rather than waiting up to {@link POLL_INTERVAL_MS} for the + * next scheduled tick to notice the terminal row. The interval itself is + * relaxed from 2s to 3s accordingly — it's now a freshness cadence for + * in-flight step output, not the primary way termination is detected. + * * `pending_approval` is explicitly NOT terminal — a paused run still needs * live status so the drawer reflects an approval elsewhere resolving it. * `completed_with_warnings` (run honesty, PR2) and `cancelled` are terminal, @@ -20,11 +33,18 @@ import debug from 'debug'; import { useEffect, useRef, useState } from 'react'; import { type FlowRun, type FlowRunStatus, getFlowRun } from '../services/api/flowsApi'; +import { useFlowRunFinished } from './useFlowRunFinished'; const log = debug('flows:poller'); -/** How often to poll a non-terminal run for progress. */ -const POLL_INTERVAL_MS = 2000; +/** + * How often to poll a non-terminal run for progress. Relaxed from 2s to 3s + * now that a `FlowRunFinished` event can force an immediate out-of-cadence + * tick the moment a run actually settles (see module doc above) — this + * interval only governs the freshness of in-flight step output between + * terminal events. + */ +const POLL_INTERVAL_MS = 3000; const TERMINAL = new Set([ 'completed', @@ -66,6 +86,24 @@ export function useFlowRunPoller(runId: string | null): UseFlowRunPollerResult { }; }, []); + // Set by the runId effect below to an out-of-cadence "tick now" callback, + // so the `useFlowRunFinished` subscription (which lives outside that + // effect, since it must stay mounted across `runId` changes) can force an + // immediate refetch instead of waiting for the next scheduled poll. Reset + // to `null` whenever the effect tears down so a finish event that arrives + // after `runId` changes (or on unmount) can't reach into a torn-down tick. + const forceTickRef = useRef<(() => void) | null>(null); + + useFlowRunFinished(event => { + if (event.run_id !== runId) return; + log( + 'finished event received for runId=%s status=%s — forcing immediate tick', + runId, + event.status + ); + forceTickRef.current?.(); + }); + useEffect(() => { // Reset view state for the new target — avoids painting the previous // runId's data/error under a different runId while the first fetch for @@ -111,10 +149,23 @@ export function useFlowRunPoller(runId: string | null): UseFlowRunPollerResult { } }; + // Let the `useFlowRunFinished` subscription above force an immediate + // out-of-cadence tick for this `runId`: cancel any pending scheduled + // poll first so the forced tick and the regular cadence never race into + // a double in-flight fetch. + forceTickRef.current = () => { + if (pollHandle !== undefined) { + window.clearTimeout(pollHandle); + pollHandle = undefined; + } + void tick(); + }; + void tick(); return () => { cancelled = true; if (pollHandle !== undefined) window.clearTimeout(pollHandle); + forceTickRef.current = null; }; }, [runId]); diff --git a/app/src/hooks/useFlowRunsLiveRefresh.ts b/app/src/hooks/useFlowRunsLiveRefresh.ts index ee96d16687..068e18f73e 100644 --- a/app/src/hooks/useFlowRunsLiveRefresh.ts +++ b/app/src/hooks/useFlowRunsLiveRefresh.ts @@ -7,13 +7,15 @@ * 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. + * mirrors). The terminal transition (`running` -> `completed`/`failed`/etc.) + * now has its own event too — `DomainEvent::FlowRunFinished`, bridged as + * `flow:run_finished` / `flow_run_finished` — and every caller of this hook + * (`FlowRunsSidebar`, `FlowRunsDrawer`, `WorkflowRunsPage`) separately wires + * `useFlowRunFinished` alongside it for that immediate refetch (issue B35 + * follow-up). This hook's own poll is therefore no longer the primary way a + * terminal transition gets noticed — it's now a long-interval backstop for + * the (rare) case a broadcast event is dropped under lag or a socket + * reconnect gap, not a tight polling loop. * * This is deliberately dumb about *which* run changed — callers pass the * list they already fetched and a `refetch` that reloads it; this hook just @@ -43,8 +45,13 @@ const TERMINAL_STATUSES = new Set([ /** 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; +/** + * Poll fallback cadence. Now a long safety net (not the primary terminal- + * transition signal — `useFlowRunFinished` is, see module doc above): only + * matters if a broadcast event was dropped under lag or during a socket + * reconnect gap. + */ +const POLL_INTERVAL_MS = 30_000; /** * Subscribes to live run-progress events (debounced) and polls on a fallback diff --git a/app/src/pages/WorkflowRunsPage.test.tsx b/app/src/pages/WorkflowRunsPage.test.tsx index 99ffd273a4..36a74aea6a 100644 --- a/app/src/pages/WorkflowRunsPage.test.tsx +++ b/app/src/pages/WorkflowRunsPage.test.tsx @@ -142,9 +142,9 @@ describe('WorkflowRunsPage', () => { expect(listAllFlowRuns).toHaveBeenCalledTimes(1); expect(listFlows).toHaveBeenCalledTimes(1); - // The 5s poll fallback fires `refetchRuns` while the one run is still 'running'. + // The 30s poll backstop fires `refetchRuns` while the one run is still 'running'. await act(async () => { - vi.advanceTimersByTime(5_000); + vi.advanceTimersByTime(30_000); await Promise.resolve(); }); @@ -169,7 +169,7 @@ describe('WorkflowRunsPage', () => { expect(screen.getByTestId('workflow-runs-list')).toBeInTheDocument(); await act(async () => { - vi.advanceTimersByTime(5_000); + vi.advanceTimersByTime(30_000); await Promise.resolve(); }); diff --git a/app/src/pages/WorkflowRunsPage.tsx b/app/src/pages/WorkflowRunsPage.tsx index dbdf260fbb..e4cfb48435 100644 --- a/app/src/pages/WorkflowRunsPage.tsx +++ b/app/src/pages/WorkflowRunsPage.tsx @@ -12,6 +12,7 @@ import { useNavigate } from 'react-router-dom'; import PanelPage from '../components/layout/PanelPage'; import { CenteredLoadingState, ErrorBanner } from '../components/ui/LoadingState'; +import { useFlowRunFinished } from '../hooks/useFlowRunFinished'; import { useFlowRunsLiveRefresh } from '../hooks/useFlowRunsLiveRefresh'; import { useFlowRunStarted } from '../hooks/useFlowRunStarted'; import { @@ -102,6 +103,11 @@ export default function WorkflowRunsPage() { // instantly instead of waiting for a manual refresh (issue B35). No // `flowId` filter — this is the flow-agnostic "all runs" page. useFlowRunStarted(() => void refetchRuns()); + // Terminal companion to the above (issue B35 follow-up) — flips a run to + // Completed/Failed the instant it settles instead of waiting on + // `useFlowRunsLiveRefresh`'s debounced/backstop refetch to notice. No + // `flowId` filter, same rationale as `useFlowRunStarted` above. + useFlowRunFinished(() => void refetchRuns()); const pendingRunIds = useRunsPendingApprovalSet(runs); const statusLabel = (status: FlowRunStatus) => diff --git a/src/core/event_bus/events.rs b/src/core/event_bus/events.rs index fc3d490084..a6e106e72f 100644 --- a/src/core/event_bus/events.rs +++ b/src/core/event_bus/events.rs @@ -478,6 +478,21 @@ pub enum DomainEvent { run_id: String, }, + /// The terminal companion to [`FlowRunStarted`] (issue B35 follow-up, runs + /// rail live finish). Published from `flows::ops::finish_flow_run_row` + /// right after `store::finish_flow_run` persists the terminal + /// `flow_runs` row, so the UI can flip a run to Completed/Failed live + /// instead of relying on a poll to notice the settled row. + FlowRunFinished { + /// The affected flow's id. + flow_id: String, + /// The run's stable identifier (== the tinyflows checkpointer thread id). + run_id: String, + /// Terminal status: `"completed"` | `"failed"` | + /// `"completed_with_warnings"` | `"cancelled"`. + status: String, + }, + /// A saved flow's definition changed (created / updated / deleted / /// enable-toggled). Bridged to a `flow:changed` socket event so an open /// Workflows list or canvas refetches instead of silently showing stale @@ -1413,6 +1428,7 @@ impl DomainEvent { | Self::FlowScheduleTick { .. } | Self::FlowRunProgress { .. } | Self::FlowRunStarted { .. } + | Self::FlowRunFinished { .. } | Self::FlowChanged { .. } => "cron", Self::WorkflowLoaded { .. } @@ -1578,6 +1594,7 @@ impl DomainEvent { Self::FlowScheduleTick { .. } => "FlowScheduleTick", Self::FlowRunProgress { .. } => "FlowRunProgress", Self::FlowRunStarted { .. } => "FlowRunStarted", + Self::FlowRunFinished { .. } => "FlowRunFinished", Self::FlowChanged { .. } => "FlowChanged", Self::WorkflowLoaded { .. } => "WorkflowLoaded", Self::WorkflowStopped { .. } => "WorkflowStopped", diff --git a/src/core/socketio.rs b/src/core/socketio.rs index 663a36a5de..605cfeafd6 100644 --- a/src/core/socketio.rs +++ b/src/core/socketio.rs @@ -1128,6 +1128,31 @@ pub fn spawn_web_channel_bridge(io: SocketIo) { let _ = io_memory_sync.emit("flow:run_started", &payload); let _ = io_memory_sync.emit("flow_run_started", &payload); } + // The terminal companion to `FlowRunStarted` above (issue B35 + // follow-up). Published once `flows::ops::finish_flow_run_row` + // persists the settled `flow_runs` row, so an open Workflows + // canvas/sidebar can flip a run to Completed/Failed live + // instead of relying on a poll to notice. Best-effort, same + // rationale as the other `flow:*` bridges. + crate::core::event_bus::DomainEvent::FlowRunFinished { + flow_id, + run_id, + status, + } => { + let payload = serde_json::json!({ + "flow_id": flow_id, + "run_id": run_id, + "status": status, + }); + log::debug!( + "[socketio] broadcast flow_run_finished flow_id={} run_id={} status={}", + flow_id, + run_id, + status + ); + let _ = io_memory_sync.emit("flow:run_finished", &payload); + let _ = io_memory_sync.emit("flow_run_finished", &payload); + } // A saved flow's definition changed (create/update/delete/ // enable). Broadcast so an open Workflows list/canvas refetches // — most importantly, so an agent `save_workflow` becomes diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index f83327f8e5..e6edc40747 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -3155,7 +3155,15 @@ pub async fn flows_run( ); } let observed = current_persisted_steps(config, &thread_id); - finish_flow_run_row(config, &thread_id, "failed", &observed, &[], Some(error)); + finish_flow_run_row( + config, + &thread_id, + flow_id, + "failed", + &observed, + &[], + Some(error), + ); }; let origin = workflow_origin(flow_id, flow.require_approval); @@ -3210,7 +3218,15 @@ pub async fn flows_run( tracing::warn!(target: "flows", flow_id = %flow_id, error = %e, "[flows] flows_run: failed to record cancelled run"); } let observed = current_persisted_steps(config, &thread_id); - finish_flow_run_row(config, &thread_id, "cancelled", &observed, &[], Some("run cancelled")); + finish_flow_run_row( + config, + &thread_id, + flow_id, + "cancelled", + &observed, + &[], + Some("run cancelled"), + ); drop_checkpoint(config, &thread_id).await; return Ok(RpcOutcome::single_log( json!({ @@ -3245,6 +3261,7 @@ pub async fn flows_run( finish_flow_run_row( config, &thread_id, + flow_id, status, &settled, &outcome.pending_approvals, @@ -3438,6 +3455,7 @@ pub async fn flows_resume( finish_flow_run_row( config, thread_id, + flow_id, "failed", &observed, &[], @@ -3450,7 +3468,15 @@ pub async fn flows_resume( let msg = format!("flow resume timed out after {FLOW_RUN_TIMEOUT_SECS}s"); let _ = store::record_run(config, flow_id, "failed"); let observed = current_persisted_steps(config, thread_id); - finish_flow_run_row(config, thread_id, "failed", &observed, &[], Some(&msg)); + finish_flow_run_row( + config, + thread_id, + flow_id, + "failed", + &observed, + &[], + Some(&msg), + ); tracing::warn!(target: "flows", flow_id = %flow_id, %thread_id, timeout_secs = FLOW_RUN_TIMEOUT_SECS, "[flows] flows_resume: run timed out"); return Err(msg); } @@ -3463,6 +3489,7 @@ pub async fn flows_resume( finish_flow_run_row( config, thread_id, + flow_id, status, &settled, &outcome.pending_approvals, @@ -3660,6 +3687,7 @@ pub async fn flows_cancel_run(config: &Config, run_id: &str) -> Result +/// resume lifecycle and asserts exactly one `FlowRunFinished` is observed, +/// carrying the final `"completed"` status, not `"pending_approval"`. +#[tokio::test] +async fn flows_run_finished_event_skips_pending_approval_and_fires_once_on_resume() { + use crate::core::event_bus::{ + init_global, subscribe_global, DomainEvent, EventHandler, DEFAULT_CAPACITY, + }; + use async_trait::async_trait; + use std::sync::Mutex as StdMutex; + + #[derive(Default)] + struct Collector { + events: Arc>>, + } + + #[async_trait] + impl EventHandler for Collector { + fn name(&self) -> &str { + "test::flows::ops::flow_run_finished_pending_approval_collector" + } + fn domains(&self) -> Option<&[&str]> { + Some(&["cron"]) + } + async fn handle(&self, event: &DomainEvent) { + if let DomainEvent::FlowRunFinished { + flow_id, + run_id, + status, + } = event + { + self.events + .lock() + .unwrap() + .push((flow_id.clone(), run_id.clone(), status.clone())); + } + } + } + + init_global(DEFAULT_CAPACITY); + let events: Arc>> = Arc::new(StdMutex::new(Vec::new())); + let collector = Arc::new(Collector { + events: Arc::clone(&events), + }); + let _handle = subscribe_global(collector).expect("bus subscriber installed"); + + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let created = flows_create( + &config, + "b35-finished-skips-pause".to_string(), + approval_gated_graph(), + false, + ) + .await + .unwrap(); + + let run = flows_run( + &config, + &created.value.id, + json!({ "x": 1 }), + FlowRunTrigger::Rpc, + ) + .await + .unwrap(); + let thread_id = run.value["thread_id"].as_str().unwrap().to_string(); + let pending: Vec = + serde_json::from_value(run.value["pending_approvals"].clone()).unwrap(); + assert_eq!(pending, vec!["gate".to_string()]); + + // Give the bus a moment to deliver anything it's going to deliver, then + // assert the pause produced no FlowRunFinished for this run at all. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + { + let guard = events.lock().unwrap(); + assert!( + !guard.iter().any(|(_, rid, _)| *rid == thread_id), + "a run parked at an approval gate must not publish FlowRunFinished: {guard:?}" + ); + } + + let resumed = flows_resume(&config, &created.value.id, &thread_id, pending, vec![]) + .await + .unwrap(); + assert_eq!(resumed.value["pending_approvals"], json!([])); + + // The bus is process-global and shared with concurrently-running tests, + // so filter for our own run id rather than asserting on total count. + let mut matched: Vec<(String, String, String)> = Vec::new(); + for _ in 0..20 { + { + let guard = events.lock().unwrap(); + matched = guard + .iter() + .filter(|(_, rid, _)| *rid == thread_id) + .cloned() + .collect(); + if !matched.is_empty() { + break; + } + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + assert_eq!( + matched.len(), + 1, + "expected exactly one FlowRunFinished for this run (the post-resume settle, \ + none for the pause): {matched:?}" + ); + let (flow_id, run_id, status) = matched.into_iter().next().unwrap(); + assert_eq!(flow_id, created.value.id); + assert_eq!(run_id, thread_id); + assert_eq!(status, "completed"); +} + // ── Live run observation (issue G2) ─────────────────────────────────────── use crate::openhuman::tinyflows::observability::FlowRunObserver; From 41bad05895c04094faa62828b9791c202cbd1c96 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:16:10 +0530 Subject: [PATCH 41/56] feat(flows): teach the workflow builder to pick specialist agents via agent_ref (B37) (#5120) --- .../agents/workflow_builder/builder_prompt.rs | 114 ++++++++++++++++++ .../flows/agents/workflow_builder/prompt.md | 35 +++++- src/openhuman/flows/builder_tools.rs | 10 +- 3 files changed, 154 insertions(+), 5 deletions(-) diff --git a/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs b/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs index 6a744fda9f..36fc9fe51d 100644 --- a/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs +++ b/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs @@ -639,6 +639,120 @@ mod tests { } } + /// B37 (Gap 1): the standing prompt must actually teach the builder to + /// reach for a specialist `agent_ref` — ground the id via + /// `list_agent_profiles`, understand that `agent_ref` runs a real agent + /// turn with its own tool loop (not just a persona-flavored completion), + /// and see concrete examples of when a plain agent node isn't enough. + #[test] + fn standing_prompt_teaches_specialist_agent_ref_selection() { + const STANDING_PROMPT: &str = include_str!("prompt.md"); + + for rule in [ + "list_agent_profiles", + "Picking a specialist via `agent_ref`", + "code_executor", + "researcher", + ] { + assert!( + STANDING_PROMPT.contains(rule), + "standing prompt must teach specialist selection via `{rule}` — the \ + builder needs to know it can ground a real agent_ref with \ + list_agent_profiles instead of hallucinating one" + ); + } + } + + /// The runtime already gives an `agent_ref` step the selected specialist's + /// full persona/model/tool loop/iteration cap (`run_via_harness` in + /// `tinyflows/caps.rs`) — the prompt must say so, not describe it as a + /// future capability. + #[test] + fn standing_prompt_links_agent_ref_to_the_full_tool_loop() { + const STANDING_PROMPT: &str = include_str!("prompt.md"); + + assert!( + STANDING_PROMPT.contains("specialist") + && (STANDING_PROMPT.contains("tool loop") + || STANDING_PROMPT.contains("full persona")), + "standing prompt must link agent_ref to the specialist's full tool loop \ + (the harness path), not just a persona/model swap" + ); + } + + /// Regression guard: the old `list_agent_profiles` description (and any + /// prompt copy that echoed it) claimed the per-agent tool loop was "a + /// follow-up" and that a step "still gets tools from the node's own + /// inline `tools` list for now". That's false — `run_via_harness` already + /// gives an `agent_ref` step its selected specialist's real tool loop — + /// and the stale wording actively discouraged using `agent_ref` at all. + #[test] + fn standing_prompt_has_no_stale_agent_ref_followup_language() { + const STANDING_PROMPT: &str = include_str!("prompt.md"); + + for banned in [ + "is a follow-up", + "for now", + "still gets tools from the node's own", + ] { + assert!( + !STANDING_PROMPT.contains(banned), + "standing prompt must not carry the stale agent_ref-tool-loop \ + phrasing `{banned}` — the harness path already gives agent_ref \ + its full tool loop" + ); + } + } + + /// `list_agent_profiles`'s own tool description used to discourage + /// `agent_ref` with stale "follow-up"/"for now" wording (issue B37, Gap + /// 1) — pin that it now correctly describes the harness's full tool + /// loop instead. + #[test] + fn list_agent_profiles_tool_description_has_no_stale_followup_language() { + use crate::openhuman::flows::builder_tools::ListAgentProfilesTool; + use crate::openhuman::tools::traits::Tool; + + let description = ListAgentProfilesTool::new().description().to_string(); + + for banned in ["is a follow-up", "for now"] { + assert!( + !description.contains(banned), + "list_agent_profiles description must not carry the stale \ + phrasing `{banned}` — an agent_ref step already gets the \ + selected specialist's full tool loop" + ); + } + assert!( + description.contains("tool loop"), + "list_agent_profiles description must describe agent_ref as running \ + the specialist's full tool loop" + ); + } + + /// Guard against over-fragmentation: the minimal-graph rule (don't chain + /// agents doing the same kind of work) must survive alongside the new + /// specialist guidance (do pick a specialist when the step needs tools + /// the plain agent lacks) — neither should crowd the other out. + #[test] + fn standing_prompt_keeps_minimal_graph_warning_alongside_specialist_guidance() { + const STANDING_PROMPT: &str = include_str!("prompt.md"); + + assert!( + STANDING_PROMPT.contains("minimal viable graph"), + "standing prompt must still warn to prefer the minimal viable graph" + ); + assert!( + STANDING_PROMPT.contains("3–6 nodes") || STANDING_PROMPT.contains("3-6 nodes"), + "standing prompt must still carry the 3-6 node sizing guidance" + ); + assert!( + STANDING_PROMPT.contains("SAME kind of work"), + "standing prompt must still warn against chaining agents doing the \ + same kind of work, even after adding specialist-selection guidance" + ); + } + #[test] fn repair_includes_run_id_error_and_failing_nodes() { let mut r = req(BuildMode::Repair); diff --git a/src/openhuman/flows/agents/workflow_builder/prompt.md b/src/openhuman/flows/agents/workflow_builder/prompt.md index 68350a80e3..3dd5028101 100644 --- a/src/openhuman/flows/agents/workflow_builder/prompt.md +++ b/src/openhuman/flows/agents/workflow_builder/prompt.md @@ -141,6 +141,11 @@ rather than a general context recall), use `memory_hybrid_search` in its makes the graph savable at all. - `list_flows` / `get_flow` → reuse or clone an existing flow instead of duplicating one. + - `list_agent_profiles` → the real specialist agent ids (`researcher`, + `code_executor`, …) an `agent` node can set as `config.agent_ref`. The + agent analogue of `search_tool_catalog`: never guess/hallucinate an id — + look it up. See "Picking a specialist via `agent_ref`" below for when and + how to use it. - **Missing the integration the workflow needs?** See "Connecting integrations" below — you can help the user link it before you build, rather than dead-ending. @@ -325,6 +330,24 @@ A `WorkflowGraph` is `{ name?, nodes: [...], edges: [...] }`. (recall a preference) or should remember a result/state across runs, wire an `agent` node that uses memory instead of hardcoding context memory already holds. Use sparingly — only when the workflow truly needs it. + + **Picking a specialist via `agent_ref`.** A plain `agent` node (no + `agent_ref`) only has the default LLM plus whatever it's given in + `input_context`/`prompt` — it cannot run code, browse the web, or reach + any domain-specific tool. If a step genuinely needs to DO something — + execute code, search the web, touch a domain the workflow author didn't + already wire as a `tool_call` — set `config.agent_ref` to the specialist + that owns those tools instead of hoping the plain agent can wing it. + Setting `agent_ref` runs that step as a REAL agent turn: the selected + agent's full persona, model, tool loop, and iteration cap, not just a + differently-worded completion. **WHEN**: the step needs code/file + execution, web research, or any tool a specialist owns that the plain + agent doesn't have. **HOW**: call `list_agent_profiles`, pick the `id` + whose `tools`/`description` match the step's need, and set it verbatim on + `config.agent_ref` — never hallucinate an id, exactly like grounding a + `tool_call` slug via `search_tool_catalog`. Examples: "generate an HTML + report from this data" → `code_executor`; "research our competitors" → + `researcher`. 3. **`tool_call`** — an action. Two flavours by `config.slug`: - **Composio app action** — `config.slug` = a real action slug (from `search_tool_catalog`, e.g. `GMAIL_SEND_EMAIL`) + `config.connection_ref` @@ -587,7 +610,17 @@ failure at runtime. Rules of thumb: `agent` nodes when one could handle both tasks in its prompt (e.g. "extract the key fields AND compose a brief" in one node, rather than "extract" → "compose" as two nodes). Chain agents only when they need - genuinely different models, schemas, or `agent_ref` profiles. + genuinely different models, schemas, or `agent_ref` profiles. **Don't + chain multiple agents doing the SAME kind of work** just to spread it + across steps — that's the over-fragmentation this rule warns against. + +- **DO pick a specialist when the step needs tools the plain agent lacks.** + The minimal-graph rule is about node COUNT, not about under-provisioning a + step — a step that needs to run code, search the web, or touch a + specialist's tools literally cannot do that job as a plain `agent` node, + so setting `config.agent_ref` there isn't added complexity, it's the + difference between the step working and silently no-op'ing. See "Picking + a specialist via `agent_ref`" above. - **Target: 3–6 nodes for a simple automation.** A schedule-trigger → source-tool → agent-summarize → destination-tool flow is 4 nodes. diff --git a/src/openhuman/flows/builder_tools.rs b/src/openhuman/flows/builder_tools.rs index 7f23a06386..5f2aa45e45 100644 --- a/src/openhuman/flows/builder_tools.rs +++ b/src/openhuman/flows/builder_tools.rs @@ -2264,10 +2264,12 @@ impl Tool for ListAgentProfilesTool { field (e.g. researcher, code_executor, crypto_agent). Read-only. Returns \ a JSON array of { id, name, description, model, tools, tags }. Use this to \ pick a real agent_ref — a coding step should reference the coding agent, a \ - research step the researcher — instead of guessing an id. Note: an \ - agent_ref applies that agent's persona/model to the step; its private \ - tool loop is a follow-up, so a step still gets tools from the node's own \ - inline `tools` list for now." + research step the researcher — instead of guessing an id. Note: setting \ + agent_ref runs the step as a REAL agent turn (its own `run_single`), with \ + the selected specialist's full persona, model, tool loop, and iteration \ + cap — not just a persona-flavored completion. A plain `agent` node with \ + no agent_ref only gets the default LLM plus its own inline `tools` list; \ + it cannot run code, search the web, or use any specialist's tools." } fn parameters_schema(&self) -> Value { From 9e312b65ae6b6593c8d3353911053a64f3c79590 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:46:34 +0300 Subject: [PATCH 42/56] chore(deps): update vendored runtime crates (#5081) --- Cargo.lock | 20 +- Cargo.toml | 8 +- app/src-tauri/Cargo.lock | 20 +- src/openhuman/flows/builder_tools.rs | 55 +- src/openhuman/flows/builder_tools_tests.rs | 210 ++++- src/openhuman/flows/ops.rs | 498 +++++++++- src/openhuman/flows/ops_tests.rs | 855 ++++++++++++++++++ src/openhuman/flows/tools.rs | 118 +-- src/openhuman/flows/tools_tests.rs | 72 ++ src/openhuman/tinyagents/middleware.rs | 14 +- src/openhuman/tinyflows/caps.rs | 59 +- tests/json_rpc_e2e.rs | 35 +- ...osio_credentials_state_raw_coverage_e2e.rs | 10 +- vendor/tinyagents | 2 +- vendor/tinychannels | 2 +- vendor/tinyflows | 2 +- vendor/tinyjuice | 2 +- 17 files changed, 1821 insertions(+), 161 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ea9bc745a9..92b1b3bcad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -765,9 +765,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -3910,9 +3910,9 @@ dependencies = [ [[package]] name = "mail-parser" -version = "0.11.3" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2420e9ce11c2b0583ca97ddff7ab2398c8a613154e9b72e3bafdbf767f1d7" +checksum = "47785d444be4d32c1709171c6219a90f667c0ad0ffe68b4b179e794f31f4f9e8" dependencies = [ "hashify", ] @@ -4746,7 +4746,7 @@ dependencies = [ "tar", "tempfile", "thiserror 2.0.18", - "tinyagents 1.9.0", + "tinyagents 2.1.0", "tinychannels", "tinycortex", "tinyflows", @@ -7334,15 +7334,13 @@ dependencies = [ [[package]] name = "tinyagents" -version = "1.9.0" +version = "2.0.0" dependencies = [ "async-trait", "bytes", "chrono", "futures", "reqwest 0.12.28", - "rhai", - "rusqlite", "serde", "serde_json", "sha2 0.11.0", @@ -7353,13 +7351,15 @@ dependencies = [ [[package]] name = "tinyagents" -version = "2.0.0" +version = "2.1.0" dependencies = [ "async-trait", "bytes", "chrono", "futures", "reqwest 0.12.28", + "rhai", + "rusqlite", "serde", "serde_json", "sha2 0.11.0", @@ -7453,7 +7453,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", - "tinyagents 1.9.0", + "tinyagents 2.1.0", "tracing", ] diff --git a/Cargo.toml b/Cargo.toml index 3025735973..837f5f41cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,9 +72,9 @@ crate-type = ["rlib"] tinyplace = "2.0" # tinyflows — host-agnostic workflow engine (typed node graph → validate → compile → # run on tinyagents). Powers the "Workflows" feature via the seam in -# `src/openhuman/tinyflows/` + the `flows::` domain. Pulls tinyagents 1.7 transitively -# (same version openhuman already uses — no conflict). Published on crates.io -# and patched below to the vendored submodule. +# `src/openhuman/tinyflows/` + the `flows::` domain. Pulls tinyagents 2.1 transitively +# (same version OpenHuman uses directly, preserving one trait identity). Published +# on crates.io and patched below to the vendored submodule. # # `mock` feature: enables `tinyflows::caps::mock::mock_capabilities()` — the # deterministic in-memory capability bundle the flows `dry_run_workflow` agent @@ -104,7 +104,7 @@ tinyjuice = { version = "0.2.1", default-features = false } # NOT enabled here — the default-ON `flows` feature turns it on via # `tinyagents/repl` (#4797), so a slim build without `flows` sheds `rhai`. # The crate itself can never be dropped: 26+ domains consume tinyagents. -tinyagents = { version = "1.7", features = ["sqlite"] } +tinyagents = { version = "2.1", features = ["sqlite"] } # TinyCortex — Rust core for the memory engine (store/chunks/tree/retrieval/ # queue/ingest/score + long tail), vendored as a git submodule and patched # below to `vendor/tinycortex`. OpenHuman's memory subsystem migrates onto this diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 8d13a39f8c..2609cdc1cc 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -912,9 +912,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -4603,9 +4603,9 @@ dependencies = [ [[package]] name = "mail-parser" -version = "0.11.3" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2420e9ce11c2b0583ca97ddff7ab2398c8a613154e9b72e3bafdbf767f1d7" +checksum = "47785d444be4d32c1709171c6219a90f667c0ad0ffe68b4b179e794f31f4f9e8" dependencies = [ "hashify", ] @@ -5603,7 +5603,7 @@ dependencies = [ "tar", "tempfile", "thiserror 2.0.18", - "tinyagents 1.9.0", + "tinyagents 2.1.0", "tinychannels", "tinycortex", "tinyflows", @@ -8970,15 +8970,13 @@ dependencies = [ [[package]] name = "tinyagents" -version = "1.9.0" +version = "2.0.0" dependencies = [ "async-trait", "bytes", "chrono", "futures", "reqwest 0.12.28", - "rhai", - "rusqlite", "serde", "serde_json", "sha2 0.11.0", @@ -8989,13 +8987,15 @@ dependencies = [ [[package]] name = "tinyagents" -version = "2.0.0" +version = "2.1.0" dependencies = [ "async-trait", "bytes", "chrono", "futures", "reqwest 0.12.28", + "rhai", + "rusqlite", "serde", "serde_json", "sha2 0.11.0", @@ -9084,7 +9084,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", - "tinyagents 1.9.0", + "tinyagents 2.1.0", "tracing", ] diff --git a/src/openhuman/flows/builder_tools.rs b/src/openhuman/flows/builder_tools.rs index 5f2aa45e45..170c3c0bc0 100644 --- a/src/openhuman/flows/builder_tools.rs +++ b/src/openhuman/flows/builder_tools.rs @@ -547,25 +547,28 @@ impl Tool for EditWorkflowTool { } }; - // Write the applied edit back to the draft (the durable working copy), - // so it survives across turns/reloads even if validation/gates below - // still flag something to fix next. - if let Some(ref draft_id) = write_back_draft { - let edited_json = serde_json::to_value(&edited)?; - if let Err(e) = ops::flows_draft_update( - &self.config, - draft_id, - Some(name.clone()), - Some(edited_json), - None, - ) { - tracing::warn!(target: "flows", %draft_id, error = %e, "[flows] edit_workflow: could not write edit back to draft"); + let write_edit_to_draft = || -> anyhow::Result<()> { + if let Some(ref draft_id) = write_back_draft { + let edited_json = serde_json::to_value(&edited)?; + if let Err(e) = ops::flows_draft_update( + &self.config, + draft_id, + Some(name.clone()), + Some(edited_json), + None, + ) { + tracing::warn!(target: "flows", %draft_id, error = %e, "[flows] edit_workflow: could not write edit back to draft"); + } } - } + Ok(()) + }; // Structural validation of the RESULT — surface every problem at once. let structural = tinyflows::validate::validate_all(&edited); if !structural.is_empty() { + // Preserve the longstanding working-copy contract: an applied edit + // survives for the next repair turn even when structurally invalid. + write_edit_to_draft()?; let messages: Vec = structural.iter().map(ToString::to_string).collect(); tracing::debug!( target: "flows", @@ -579,6 +582,30 @@ impl Tool for EditWorkflowTool { ))); } + // Engine-incompatible topologies are different from ordinary builder + // follow-up errors: persisting one would leave a draft that no current + // save/run path can accept. Reject it before advancing the durable + // working copy, while preserving the established write-back behavior + // for later binding/connection/contract gates. + let compatibility = ops::config_aware_engine_compatibility_errors(&self.config, &edited); + if !compatibility.is_empty() { + tracing::debug!( + target: "flows", + %name, + error_count = compatibility.len(), + "[flows] edit_workflow: the edited graph is engine-incompatible" + ); + return Ok(ToolResult::error(format!( + "The edited graph is incompatible with the current engine:\n\n{}\n\nFix the ops and call edit_workflow again.", + compatibility.join("\n\n") + ))); + } + + // Write the accepted structural edit back to the draft (the durable + // working copy), so it survives across turns/reloads even if a later + // binding/connection/contract gate flags something to fix next. + write_edit_to_draft()?; + // 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. diff --git a/src/openhuman/flows/builder_tools_tests.rs b/src/openhuman/flows/builder_tools_tests.rs index 2c3ee04e7e..af765479a3 100644 --- a/src/openhuman/flows/builder_tools_tests.rs +++ b/src/openhuman/flows/builder_tools_tests.rs @@ -2159,18 +2159,224 @@ async fn edit_workflow_accepts_node_id_aliases_end_to_end() { #[tokio::test] async fn edit_workflow_rejects_a_result_that_is_structurally_invalid() { + use crate::openhuman::flows::DraftOrigin; let tmp = TempDir::new().unwrap(); - let tool = EditWorkflowTool::new(test_config(&tmp)); + let config = test_config(&tmp); + let draft = ops::flows_draft_create( + &config, + None, + "Structural repair".to_string(), + valid_graph(), + DraftOrigin::Chat, + ) + .unwrap() + .value; + let tool = EditWorkflowTool::new(config.clone()); // Removing the only trigger leaves the graph structurally invalid. let result = tool .execute(json!({ - "graph": valid_graph(), + "draft_id": draft.id, "ops": [ { "op": "remove_node", "id": "t" } ] })) .await .unwrap(); assert!(result.is_error); assert!(result.output().contains("trigger"), "{}", result.output()); + let reloaded = ops::flows_draft_get(&config, &draft.id).unwrap().value; + assert!( + reloaded.graph["nodes"] + .as_array() + .unwrap() + .iter() + .all(|node| node["id"] != "t"), + "structurally invalid applied edits remain available for the repair turn" + ); +} + +#[tokio::test] +async fn edit_workflow_rejects_an_engine_incompatible_result() { + use crate::openhuman::flows::DraftOrigin; + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let safe_graph = json!({ + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Trigger" }, + { "id": "outer", "kind": "condition", "name": "Outer", "config": { "field": "outer" } }, + { "id": "inner", "kind": "condition", "name": "Inner", "config": { "field": "inner" } }, + { "id": "outer_else", "kind": "output_parser", "name": "Outer else" }, + { "id": "inner_else", "kind": "output_parser", "name": "Inner else" }, + { "id": "a", "kind": "output_parser", "name": "A" }, + { "id": "c", "kind": "output_parser", "name": "C" }, + { "id": "m", "kind": "merge", "name": "Merge" } + ], + "edges": [ + { "from_node": "t", "from_port": "main", "to_node": "outer" }, + { "from_node": "t", "from_port": "main", "to_node": "c" }, + { "from_node": "outer", "from_port": "true", "to_node": "inner" }, + { "from_node": "outer", "from_port": "false", "to_node": "outer_else" }, + { "from_node": "inner", "from_port": "true", "to_node": "a" }, + { "from_node": "inner", "from_port": "false", "to_node": "inner_else" }, + { "from_node": "a", "from_port": "main", "to_node": "m" } + ] + }); + let draft = ops::flows_draft_create( + &config, + None, + "Safe draft".to_string(), + safe_graph.clone(), + DraftOrigin::Chat, + ) + .unwrap() + .value; + let tool = EditWorkflowTool::new(config.clone()); + let result = tool + .execute(json!({ + "draft_id": draft.id, + "ops": [ + { "op": "add_edge", "edge": { "from_node": "c", "from_port": "main", "to_node": "m" } } + ] + })) + .await + .unwrap(); + + assert!(result.is_error, "{}", result.output()); + assert!( + result + .output() + .contains("unsupported_nested_conditional_fan_in"), + "{}", + result.output() + ); + let reloaded = ops::flows_draft_get(&config, &draft.id).unwrap().value; + assert_eq!( + reloaded.graph, safe_graph, + "a rejected edit must not advance the durable draft" + ); +} + +#[tokio::test] +async fn edit_workflow_does_not_persist_an_incompatible_saved_child_reference() { + use crate::openhuman::flows::DraftOrigin; + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let legacy_child = json!({ + "nodes": [ + { "id": "start", "kind": "trigger", "name": "Trigger" }, + { "id": "outer", "kind": "condition", "name": "Outer", "config": { "field": "outer" } }, + { "id": "inner", "kind": "condition", "name": "Inner", "config": { "field": "inner" } }, + { "id": "outer_else", "kind": "output_parser", "name": "Outer else" }, + { "id": "inner_else", "kind": "output_parser", "name": "Inner else" }, + { "id": "a", "kind": "output_parser", "name": "A" }, + { "id": "c", "kind": "output_parser", "name": "C" }, + { "id": "m", "kind": "merge", "name": "Merge" } + ], + "edges": [ + { "from_node": "start", "to_node": "outer" }, + { "from_node": "start", "to_node": "c" }, + { "from_node": "outer", "from_port": "true", "to_node": "inner" }, + { "from_node": "outer", "from_port": "false", "to_node": "outer_else" }, + { "from_node": "inner", "from_port": "true", "to_node": "a" }, + { "from_node": "inner", "from_port": "false", "to_node": "inner_else" }, + { "from_node": "a", "to_node": "m" }, + { "from_node": "c", "to_node": "m" } + ] + }); + let child_graph = ops::migrate_and_deserialize_graph(legacy_child).unwrap(); + tinyflows::validate::validate(&child_graph).unwrap(); + let child = crate::openhuman::flows::store::create_flow( + &config, + "Legacy unsafe child".to_string(), + child_graph, + false, + false, + ) + .unwrap(); + let safe_graph = json!({ + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Trigger" }, + { + "id": "child", + "kind": "sub_workflow", + "name": "Child", + "config": { "workflow_id": "=inputs.workflow_id" } + } + ], + "edges": [{ "from_node": "t", "to_node": "child" }] + }); + let draft = ops::flows_draft_create( + &config, + None, + "Safe draft".to_string(), + safe_graph.clone(), + DraftOrigin::Chat, + ) + .unwrap() + .value; + + let result = EditWorkflowTool::new(config.clone()) + .execute(json!({ + "draft_id": draft.id, + "ops": [{ + "op": "update_node_config", + "id": "child", + "config": { "workflow_id": child.id } + }] + })) + .await + .unwrap(); + + assert!(result.is_error, "{}", result.output()); + assert!( + result + .output() + .contains("unsupported_nested_conditional_fan_in"), + "{}", + result.output() + ); + let reloaded = ops::flows_draft_get(&config, &draft.id).unwrap().value; + assert_eq!( + reloaded.graph, safe_graph, + "a rejected saved-child edit must not advance the durable draft" + ); +} + +#[tokio::test] +async fn edit_workflow_preserves_non_engine_gate_edits_in_the_draft() { + use crate::openhuman::flows::DraftOrigin; + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let draft = ops::flows_draft_create( + &config, + None, + "Binding follow-up".to_string(), + unresolvable_binding_graph(), + DraftOrigin::Chat, + ) + .unwrap() + .value; + let tool = EditWorkflowTool::new(config.clone()); + let result = tool + .execute(json!({ + "draft_id": draft.id, + "ops": [ + { "op": "set_node_name", "id": "summarize", "name": "Renamed before binding fix" } + ] + })) + .await + .unwrap(); + + assert!( + result.is_error, + "binding gate should still reject the proposal" + ); + let reloaded = ops::flows_draft_get(&config, &draft.id).unwrap().value; + let renamed = reloaded.graph["nodes"] + .as_array() + .unwrap() + .iter() + .find(|node| node["id"] == "summarize") + .unwrap(); + assert_eq!(renamed["name"], "Renamed before binding fix"); } #[tokio::test] diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index e6edc40747..c33c7c75d4 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -3,6 +3,7 @@ //! `schemas.rs`'s `handle_*` RPC/CLI handlers, mirroring //! `src/openhuman/cron/ops.rs`. +use std::collections::HashSet; use std::sync::Arc; use chrono::Utc; @@ -40,6 +41,11 @@ const FLOW_RUN_TIMEOUT_SECS: u64 = 600; /// this is a dedicated flows-side TTL, not a reuse of the approval store's. const FLOW_PARKED_TTL_SECS: i64 = 600; +/// Stable host-validation code for a topology that the currently vendored +/// TinyFlows/TinyAgents barrier-relief implementation cannot execute safely. +const UNSUPPORTED_NESTED_CONDITIONAL_FAN_IN: &str = "unsupported_nested_conditional_fan_in"; +const UNSUPPORTED_MAIN_PORT_CONDITIONAL_FAN_IN: &str = "unsupported_main_port_conditional_fan_in"; + // ───────────────────────────────────────────────────────────────────────────── // Phase 2 — autonomy-tier gating of acting flow nodes // ───────────────────────────────────────────────────────────────────────────── @@ -93,9 +99,305 @@ const FLOW_PARKED_TTL_SECS: i64 = 600; pub(crate) fn validate_and_migrate_graph(graph_json: Value) -> Result { let graph = migrate_and_deserialize_graph(graph_json)?; tinyflows::validate::validate(&graph).map_err(|e| e.to_string())?; + ensure_engine_compatible(&graph)?; Ok(graph) } +/// Detects fan-in predecessors controlled by more than one branching decision. +/// +/// TinyFlows lowers every fan-in edge as a waiting edge and registers a +/// barrier relief for conditional predecessors. The current lowering chooses +/// only the first upstream brancher, while TinyAgents cannot prove reachability +/// through a second brancher. Depending on node declaration order, that can +/// either relieve the barrier before the real predecessor runs (silently +/// dropping its data) or leave the fan-in unfired. Fail closed until the +/// vendored engine models nested decisions directly. +/// +/// This intentionally mirrors TinyFlows' topology classification rather than +/// limiting the check to `merge` nodes: any node with multiple incoming edges +/// is lowered as a fan-in barrier. A predecessor reachable from the trigger by +/// `main`-only edges is unconditional and needs no relief, so it is safe. +pub(crate) fn engine_compatibility_errors( + graph: &WorkflowGraph, +) -> Vec { + let mut errors = Vec::new(); + collect_engine_compatibility_errors(graph, 0, &mut errors); + errors +} + +fn collect_engine_compatibility_errors( + graph: &WorkflowGraph, + depth: u64, + errors: &mut Vec, +) { + errors.extend(graph_engine_compatibility_errors(graph)); + if depth >= tinyflows::engine::MAX_SUB_WORKFLOW_DEPTH { + return; + } + + for node in &graph.nodes { + if node.kind != NodeKind::SubWorkflow { + continue; + } + let Some(inline) = node.config.get("workflow") else { + continue; + }; + let Ok(child) = serde_json::from_value::(inline.clone()) else { + // TinyFlows reports malformed inline children as capability errors; + // this gate is specifically for otherwise-deserializable unsafe + // topologies. + continue; + }; + let first_child_error = errors.len(); + collect_engine_compatibility_errors(&child, depth + 1, errors); + for error in &mut errors[first_child_error..] { + error.message = format!("Inline sub_workflow node '{}': {}", node.id, error.message); + } + } +} + +fn graph_engine_compatibility_errors( + graph: &WorkflowGraph, +) -> Vec { + let Some(trigger) = graph.trigger() else { + return Vec::new(); + }; + let mut errors = Vec::new(); + + for fan_in in &graph.nodes { + let incoming: Vec<&str> = graph + .edges + .iter() + .filter(|edge| edge.to_node == fan_in.id) + .map(|edge| edge.from_node.as_str()) + .collect(); + if incoming.len() <= 1 { + continue; + } + + for predecessor in incoming { + // Reaching a router itself unconditionally does not make the edge + // it selects into the fan-in unconditional. Let router + // predecessors reach the port-aware analysis below. + if !is_branching_node(graph, predecessor) + && reaches_on_main_edges(graph, &trigger.id, predecessor, &fan_in.id) + { + continue; + } + + let mut controlling_branchers = 0usize; + let mut controlled_via_main_port = false; + for candidate in &graph.nodes { + let is_router = matches!(candidate.kind, NodeKind::Condition | NodeKind::Switch); + let ports: HashSet<&str> = graph + .edges + .iter() + .filter(|edge| edge.from_node == candidate.id) + .map(|edge| edge.from_port.as_str()) + .collect(); + if ports.len() < 2 && !is_router { + continue; + } + // When the router is itself the incoming predecessor, its + // branch edge must be tested against the fan-in (asking whether + // that edge reaches the router again can never succeed). + let controlled_target = if candidate.id == predecessor { + fan_in.id.as_str() + } else { + predecessor + }; + let reaches_from_port = |port: &str| { + reaches_via_port(graph, &candidate.id, port, controlled_target, &fan_in.id) + }; + let any_port_reaches = ports.iter().any(|port| reaches_from_port(port)); + // A router with one wired output still has unwired runtime + // choices that emit no successor, so that sole edge cannot + // prove unconditional reachability. Router reconvergence is + // only deterministic when every runtime choice is wired: + // both condition outcomes, or a switch fallback. Generic + // multi-port nodes retain their existing all-port behavior. + let routing_choices_are_exhaustive = match candidate.kind { + NodeKind::Condition => ports.contains("true") && ports.contains("false"), + NodeKind::Switch => ports.contains("default"), + _ => true, + }; + let can_prove_all_routing_choices = if is_router { + routing_choices_are_exhaustive + } else { + ports.len() >= 2 + }; + let every_port_deterministically_reaches = can_prove_all_routing_choices + && ports.iter().all(|port| { + reaches_deterministically_via_port( + graph, + &candidate.id, + port, + controlled_target, + &fan_in.id, + ) + }); + // A multi-port node only controls this predecessor when the + // predecessor is reachable from it but not guaranteed by a + // deterministic path on every routing choice. This matches + // TinyAgents' relief proof, which stops at another router. + if any_port_reaches && !every_port_deterministically_reaches { + controlling_branchers += 1; + controlled_via_main_port |= ports.contains("main") && reaches_from_port("main"); + } + } + + let (code, routing_kind) = if controlled_via_main_port { + ( + UNSUPPORTED_MAIN_PORT_CONDITIONAL_FAN_IN, + "a conditional branch labelled 'main'", + ) + } else if controlling_branchers >= 2 { + ( + UNSUPPORTED_NESTED_CONDITIONAL_FAN_IN, + "nested conditional routing", + ) + } else { + continue; + }; + errors.push(crate::openhuman::flows::FlowValidationError { + code: code.to_string(), + message: format!( + "Fan-in node '{}' has predecessor '{}' behind {routing_kind}; \ + this topology is temporarily unsupported because it can silently lose \ + merged data. Flatten the conditional branch or join it before this fan-in.", + fan_in.id, predecessor + ), + node_id: Some(fan_in.id.clone()), + field: None, + }); + } + } + + errors +} + +fn ensure_engine_compatible(graph: &WorkflowGraph) -> Result<(), String> { + match engine_compatibility_errors(graph).into_iter().next() { + Some(error) => Err(format!("{}: {}", error.code, error.message)), + None => Ok(()), + } +} + +/// Host-aware compatibility check, including saved descendants that graph-only +/// validation cannot inspect. Authoring boundaries use it before persistence; +/// execution boundaries use it before compiling a root run/resume or returning +/// a resolver graph, so an unsafe descendant cannot run after earlier effects. +fn ensure_config_aware_engine_compatible( + config: &Config, + graph: &WorkflowGraph, +) -> Result<(), String> { + match config_aware_engine_compatibility_errors(config, graph) + .into_iter() + .next() + { + Some(error) => Err(error), + None => Ok(()), + } +} + +fn reaches_on_main_edges(graph: &WorkflowGraph, from: &str, to: &str, stop: &str) -> bool { + if from == to { + return true; + } + let mut stack: Vec<&str> = if is_branching_node(graph, from) { + Vec::new() + } else { + graph + .edges + .iter() + .filter(|edge| edge.from_node == from && edge.from_port == "main") + .map(|edge| edge.to_node.as_str()) + .collect() + }; + let mut seen = HashSet::new(); + while let Some(node) = stack.pop() { + if node == to { + return true; + } + if node == stop || !seen.insert(node) { + continue; + } + // Port labels are arbitrary. A node with multiple distinct output + // ports is runtime-selective even when one label happens to be `main`, + // so nothing beyond it is unconditionally reachable. + if is_branching_node(graph, node) { + continue; + } + stack.extend( + graph + .edges + .iter() + .filter(|edge| edge.from_node == node && edge.from_port == "main") + .map(|edge| edge.to_node.as_str()), + ); + } + false +} + +fn is_branching_node(graph: &WorkflowGraph, node_id: &str) -> bool { + graph.nodes.iter().any(|node| { + node.id == node_id && matches!(node.kind, NodeKind::Condition | NodeKind::Switch) + }) || graph + .edges + .iter() + .filter(|edge| edge.from_node == node_id) + .map(|edge| edge.from_port.as_str()) + .collect::>() + .len() + >= 2 +} + +fn reaches_via_port( + graph: &WorkflowGraph, + brancher: &str, + port: &str, + target: &str, + stop: &str, +) -> bool { + let mut stack: Vec<&str> = graph + .edges + .iter() + .filter(|edge| edge.from_node == brancher && edge.from_port == port) + .map(|edge| edge.to_node.as_str()) + .collect(); + let mut seen = HashSet::new(); + while let Some(node) = stack.pop() { + if node == target { + return true; + } + if node == stop || !seen.insert(node) { + continue; + } + stack.extend( + graph + .edges + .iter() + .filter(|edge| edge.from_node == node) + .map(|edge| edge.to_node.as_str()), + ); + } + false +} + +fn reaches_deterministically_via_port( + graph: &WorkflowGraph, + brancher: &str, + port: &str, + target: &str, + stop: &str, +) -> bool { + graph + .edges + .iter() + .filter(|edge| edge.from_node == brancher && edge.from_port == port) + .any(|edge| reaches_on_main_edges(graph, &edge.to_node, target, stop)) +} + /// Runs a raw graph JSON value through migration + deserialization **without** /// the structural `validate` step. Splits the two so a caller that wants /// *every* structural error (via `tinyflows::validate::validate_all`) can run @@ -123,10 +425,10 @@ pub(crate) fn to_flow_validation_error( } } -/// The single canonical definition of the builder hard-gate stack: the three +/// The single canonical definition of the builder hard-gate stack: the four /// author-time gates that reject (not warn) a graph an agent must not propose -/// or persist — binding-resolvability, tool-contract, and required-arg -/// resolvability, in increasing cost order. +/// or persist — engine compatibility, binding-resolvability, tool-contract, +/// and required-arg resolvability, in increasing cost order. /// /// Returns an empty `Vec` when the graph passes; otherwise the first failing /// gate's node-level error messages (short-circuiting, so an expensive later @@ -140,6 +442,10 @@ pub(crate) fn to_flow_validation_error( /// `validate_and_migrate_graph` / `validate_all` first) — these gates check /// resolvability/contracts on a compilable graph. pub(crate) async fn run_builder_gates(config: &Config, graph: &WorkflowGraph) -> Vec { + let compatibility_errors = config_aware_engine_compatibility_errors(config, graph); + if !compatibility_errors.is_empty() { + return compatibility_errors; + } // Cheap, sync: a binding guaranteed to resolve null / wrong at runtime. let binding_errors = validate_binding_resolvability(graph); if !binding_errors.is_empty() { @@ -165,6 +471,106 @@ pub(crate) async fn run_builder_gates(config: &Config, graph: &WorkflowGraph) -> validate_required_arg_resolvability(graph).await } +/// Checks literal `workflow_id` children reachable from an authoring candidate. +/// +/// Pure graph validation can recurse through inline children, but resolving a +/// saved child requires the host store. Keep that lookup in the config-aware +/// builder gate so strict RPC and agent-authored proposals/saves cannot bless a +/// parent that is already known to fail at execution. Dynamic `=` expressions, +/// missing ids, and store failures retain their existing runtime diagnostics; +/// this gate only rejects a saved graph whose topology is demonstrably unsafe. +fn referenced_workflow_compatibility_errors(config: &Config, graph: &WorkflowGraph) -> Vec { + let mut pending = vec![(graph.clone(), 0_u64, Vec::::new())]; + // Record the shallowest visit, not just whether an id was seen. The same + // child can be referenced by multiple branches; a deep DFS visit must not + // suppress a later shallower visit that has more depth budget remaining. + let mut visited_depths = std::collections::HashMap::::new(); + + while let Some((current, depth, path)) = pending.pop() { + if depth >= tinyflows::engine::MAX_SUB_WORKFLOW_DEPTH { + continue; + } + + for node in ¤t.nodes { + if node.kind != NodeKind::SubWorkflow { + continue; + } + + let mut child_path = path.clone(); + child_path.push(node.id.clone()); + + let inline = node.config.get("workflow"); + let configured_workflow_id = node + .config + .get("workflow_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|id| !id.is_empty()); + // Structural validation requires exactly one source and runs before + // this helper. Retain that precedence defensively if a future caller + // passes an invalid graph directly: do not inspect either source as + // though TinyFlows could choose between them at runtime. + if inline.is_some() && configured_workflow_id.is_some() { + continue; + } + + if let Some(inline) = inline { + if let Ok(child) = serde_json::from_value::(inline.clone()) { + pending.push((child, depth + 1, child_path.clone())); + } + continue; + } + + let Some(workflow_id) = configured_workflow_id.filter(|id| !id.starts_with('=')) else { + continue; + }; + let child_depth = depth + 1; + if visited_depths + .get(workflow_id) + .is_some_and(|seen_depth| *seen_depth <= child_depth) + { + continue; + } + visited_depths.insert(workflow_id.to_string(), child_depth); + + let Ok(Some(child)) = load_flow_graph(config, workflow_id) else { + continue; + }; + if let Some(error) = engine_compatibility_errors(&child).into_iter().next() { + return vec![format!( + "Sub_workflow path '{}' references workflow_id '{}' with an unsupported \ + engine topology: {}: {}", + child_path.join(" -> "), + workflow_id, + error.code, + error.message + )]; + } + pending.push((child, child_depth, child_path)); + } + } + + Vec::new() +} + +/// Returns the complete engine-topology gate for a graph in its host context. +/// The graph-only half covers inline descendants; the config-aware half follows +/// literal saved-workflow references. Authoring and execution boundaries share +/// this helper so neither can accept a graph the other must reject. +pub(crate) fn config_aware_engine_compatibility_errors( + config: &Config, + graph: &WorkflowGraph, +) -> Vec { + let direct = engine_compatibility_errors(graph); + if !direct.is_empty() { + return direct + .into_iter() + .map(|error| format!("{}: {}", error.code, error.message)) + .collect(); + } + referenced_workflow_compatibility_errors(config, graph) +} + /// Strict-mode gate for the create/update RPC path (audit F3): validates /// `graph_json` structurally (surfacing every error at once) and then runs the /// same [`run_builder_gates`] the agent tools enforce, returning `Err` with a @@ -198,8 +604,9 @@ pub(crate) async fn strict_gate(config: &Config, graph_json: &Value) -> Result<( /// `graph` and, if it passes, builds the `workflow_proposal` payload the /// propose/revise/edit tools all return. /// -/// The single home for the gate sequence (binding-resolvability → -/// tool-contract → required-arg resolvability) plus summary/warning assembly, +/// The single home for the gate sequence (engine compatibility → +/// binding-resolvability → tool-contract → required-arg resolvability) plus +/// summary/warning assembly, /// so `revise_workflow` and `edit_workflow` cannot drift. `retry_tool` names /// the tool in the "fix … and call `` again" guidance so each caller's /// error text points the agent back at the right tool. @@ -2053,6 +2460,28 @@ pub fn flows_validate(graph_json: Value) -> RpcOutcome Result, String> { let graph = validate_and_migrate_graph(graph_json)?; + ensure_config_aware_engine_compatible(config, &graph)?; // Rule 1: automatic triggers create DISABLED — the user must arm them // explicitly. @@ -2310,6 +2740,21 @@ pub fn load_flow_graph(config: &Config, id: &str) -> Result Result, String> { + let graph = load_flow_graph(config, id)?; + if let Some(graph) = graph.as_ref() { + ensure_config_aware_engine_compatible(config, graph) + .map_err(|error| format!("workflow_id '{id}' is engine-incompatible: {error}"))?; + } + Ok(graph) +} + /// Lists every saved flow. pub async fn flows_list(config: &Config) -> Result>, String> { let flows = store::list_flows(config).map_err(|e| e.to_string())?; @@ -2632,13 +3077,16 @@ pub async fn flows_update( let new_require_approval = require_approval.unwrap_or(existing.require_approval); let graph_changed = graph_json.is_some(); let graph = match graph_json { - Some(raw) => validate_and_migrate_graph(raw)?, + Some(raw) => { + let graph = validate_and_migrate_graph(raw)?; + ensure_config_aware_engine_compatible(config, &graph)?; + graph + } None => { tinyflows::validate::validate(&existing.graph).map_err(|e| e.to_string())?; existing.graph.clone() } }; - // 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. @@ -3105,6 +3553,19 @@ pub async fn flows_run( // `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. + // + // Author-time validation cannot protect definitions persisted by an older + // OpenHuman build. Re-check immediately before compilation so an upgrade + // fails explicitly instead of silently committing incomplete merge data. + if let Err(error) = ensure_config_aware_engine_compatible(config, &flow.graph) { + tracing::warn!( + target: "flows", + flow_id = %flow_id, + %error, + "[flows] flows_run: rejected — unsupported engine topology" + ); + return Err(error); + } let compiled = tinyflows::compiler::compile(&flow.graph).map_err(|e| e.to_string())?; let config_arc = Arc::new(config.clone()); @@ -3385,6 +3846,29 @@ pub async fn flows_resume( )); } + // A pending checkpoint may have been created before this compatibility + // gate shipped, so resume is an independent authoritative boundary. + if let Err(error) = ensure_config_aware_engine_compatible(config, &flow.graph) { + if let Err(rec_err) = store::record_run(config, flow_id, "failed") { + tracing::warn!( + target: "flows", + flow_id = %flow_id, + %thread_id, + error = %rec_err, + "[flows] flows_resume: failed to record compatibility rejection" + ); + } + let observed = current_persisted_steps(config, thread_id); + finish_flow_run_row(config, thread_id, "failed", &observed, &[], Some(&error)); + tracing::warn!( + target: "flows", + flow_id = %flow_id, + %thread_id, + %error, + "[flows] flows_resume: rejected — unsupported engine topology" + ); + return Err(error); + } let compiled = tinyflows::compiler::compile(&flow.graph).map_err(|e| e.to_string())?; let config_arc = Arc::new(config.clone()); let caps = diff --git a/src/openhuman/flows/ops_tests.rs b/src/openhuman/flows/ops_tests.rs index 54163d5025..9eccff8e01 100644 --- a/src/openhuman/flows/ops_tests.rs +++ b/src/openhuman/flows/ops_tests.rs @@ -24,6 +24,639 @@ fn trigger_only_graph() -> Value { }) } +fn nested_conditional_fan_in_graph() -> Value { + json!({ + "name": "nested-conditional-fan-in", + "nodes": [ + { "id": "start", "kind": "trigger", "name": "Trigger" }, + { "id": "outer", "kind": "condition", "name": "Outer", "config": { "field": "outer" } }, + { "id": "inner", "kind": "condition", "name": "Inner", "config": { "field": "inner" } }, + { "id": "outer_else", "kind": "output_parser", "name": "Outer else" }, + { "id": "inner_else", "kind": "output_parser", "name": "Inner else" }, + { "id": "a", "kind": "output_parser", "name": "A" }, + { "id": "c", "kind": "output_parser", "name": "C" }, + { "id": "m", "kind": "merge", "name": "Merge" } + ], + "edges": [ + { "from_node": "start", "from_port": "main", "to_node": "outer" }, + { "from_node": "start", "from_port": "main", "to_node": "c" }, + { "from_node": "outer", "from_port": "true", "to_node": "inner" }, + { "from_node": "outer", "from_port": "false", "to_node": "outer_else" }, + { "from_node": "inner", "from_port": "true", "to_node": "a" }, + { "from_node": "inner", "from_port": "false", "to_node": "inner_else" }, + { "from_node": "a", "from_port": "main", "to_node": "m" }, + { "from_node": "c", "from_port": "main", "to_node": "m" } + ] + }) +} + +fn main_port_conditional_fan_in_graph() -> Value { + json!({ + "name": "main-port-conditional-fan-in", + "nodes": [ + { "id": "start", "kind": "trigger", "name": "Trigger" }, + { "id": "route", "kind": "switch", "name": "Route", "config": { "field": "kind" } }, + { "id": "a", "kind": "output_parser", "name": "A" }, + { "id": "other", "kind": "output_parser", "name": "Other" }, + { "id": "c", "kind": "output_parser", "name": "C" }, + { "id": "m", "kind": "merge", "name": "Merge" } + ], + "edges": [ + { "from_node": "start", "from_port": "main", "to_node": "route" }, + { "from_node": "start", "from_port": "main", "to_node": "c" }, + { "from_node": "route", "from_port": "main", "to_node": "a" }, + { "from_node": "route", "from_port": "other", "to_node": "other" }, + { "from_node": "a", "from_port": "main", "to_node": "m" }, + { "from_node": "c", "from_port": "main", "to_node": "m" } + ] + }) +} + +fn referenced_child_graph(workflow_id: &str) -> Value { + json!({ + "name": "parent-with-saved-child", + "nodes": [ + { "id": "start", "kind": "trigger", "name": "Trigger" }, + { + "id": "saved-child", + "kind": "sub_workflow", + "name": "Saved child", + "config": { "workflow_id": workflow_id } + } + ], + "edges": [ + { "from_node": "start", "from_port": "main", "to_node": "saved-child" } + ] + }) +} + +fn structurally_valid_graph(value: Value) -> WorkflowGraph { + let graph = migrate_and_deserialize_graph(value).expect("graph should deserialize"); + tinyflows::validate::validate(&graph).expect("fixture should be structurally valid"); + graph +} + +fn nested_router_reconvergence_graph(inner_kind: &str, inner_ports: &[&str]) -> WorkflowGraph { + let mut edges = vec![ + json!({ "from_node": "start", "from_port": "main", "to_node": "outer" }), + json!({ "from_node": "start", "from_port": "main", "to_node": "c" }), + json!({ "from_node": "outer", "from_port": "true", "to_node": "inner" }), + json!({ "from_node": "outer", "from_port": "false", "to_node": "outer_else" }), + ]; + edges.extend( + inner_ports + .iter() + .map(|port| json!({ "from_node": "inner", "from_port": port, "to_node": "a" })), + ); + edges.extend([ + json!({ "from_node": "a", "from_port": "main", "to_node": "m" }), + json!({ "from_node": "c", "from_port": "main", "to_node": "m" }), + ]); + + structurally_valid_graph(json!({ + "name": "nested-router-reconvergence", + "nodes": [ + { "id": "start", "kind": "trigger", "name": "Trigger" }, + { "id": "outer", "kind": "condition", "name": "Outer", "config": { "field": "outer" } }, + { "id": "inner", "kind": inner_kind, "name": "Inner", "config": { "field": "inner" } }, + { "id": "outer_else", "kind": "output_parser", "name": "Outer else" }, + { "id": "a", "kind": "output_parser", "name": "A" }, + { "id": "c", "kind": "output_parser", "name": "C" }, + { "id": "m", "kind": "merge", "name": "Merge" } + ], + "edges": edges + })) +} + +#[test] +fn engine_compatibility_distinguishes_nested_from_safe_fan_ins() { + let risky = structurally_valid_graph(nested_conditional_fan_in_graph()); + let errors = engine_compatibility_errors(&risky); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].code, UNSUPPORTED_NESTED_CONDITIONAL_FAN_IN); + assert_eq!(errors[0].node_id.as_deref(), Some("m")); + + let one_level = structurally_valid_graph(json!({ + "name": "one-level-mixed-fan-in", + "nodes": [ + { "id": "start", "kind": "trigger", "name": "Trigger" }, + { "id": "cond", "kind": "condition", "name": "Condition", "config": { "field": "flag" } }, + { "id": "a", "kind": "output_parser", "name": "A" }, + { "id": "other", "kind": "output_parser", "name": "Other" }, + { "id": "c", "kind": "output_parser", "name": "C" }, + { "id": "m", "kind": "merge", "name": "Merge" } + ], + "edges": [ + { "from_node": "start", "from_port": "main", "to_node": "cond" }, + { "from_node": "start", "from_port": "main", "to_node": "c" }, + { "from_node": "cond", "from_port": "true", "to_node": "a" }, + { "from_node": "cond", "from_port": "false", "to_node": "other" }, + { "from_node": "a", "from_port": "main", "to_node": "m" }, + { "from_node": "c", "from_port": "main", "to_node": "m" } + ] + })); + assert!(engine_compatibility_errors(&one_level).is_empty()); + + let nested_without_fan_in = structurally_valid_graph(json!({ + "name": "nested-without-fan-in", + "nodes": [ + { "id": "start", "kind": "trigger", "name": "Trigger" }, + { "id": "outer", "kind": "condition", "name": "Outer", "config": { "field": "outer" } }, + { "id": "inner", "kind": "condition", "name": "Inner", "config": { "field": "inner" } }, + { "id": "outer_else", "kind": "output_parser", "name": "Outer else" }, + { "id": "a", "kind": "output_parser", "name": "A" }, + { "id": "inner_else", "kind": "output_parser", "name": "Inner else" } + ], + "edges": [ + { "from_node": "start", "from_port": "main", "to_node": "outer" }, + { "from_node": "outer", "from_port": "true", "to_node": "inner" }, + { "from_node": "outer", "from_port": "false", "to_node": "outer_else" }, + { "from_node": "inner", "from_port": "true", "to_node": "a" }, + { "from_node": "inner", "from_port": "false", "to_node": "inner_else" } + ] + })); + assert!(engine_compatibility_errors(&nested_without_fan_in).is_empty()); + + let unconditional = structurally_valid_graph(json!({ + "name": "unconditional-fan-in", + "nodes": [ + { "id": "start", "kind": "trigger", "name": "Trigger" }, + { "id": "a", "kind": "output_parser", "name": "A" }, + { "id": "c", "kind": "output_parser", "name": "C" }, + { "id": "m", "kind": "merge", "name": "Merge" } + ], + "edges": [ + { "from_node": "start", "from_port": "main", "to_node": "a" }, + { "from_node": "start", "from_port": "main", "to_node": "c" }, + { "from_node": "a", "from_port": "main", "to_node": "m" }, + { "from_node": "c", "from_port": "main", "to_node": "m" } + ] + })); + assert!(engine_compatibility_errors(&unconditional).is_empty()); +} + +#[test] +fn engine_compatibility_rejects_main_label_on_conditional_fan_in_path() { + let graph = structurally_valid_graph(main_port_conditional_fan_in_graph()); + let errors = engine_compatibility_errors(&graph); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].code, UNSUPPORTED_MAIN_PORT_CONDITIONAL_FAN_IN); + assert_eq!(errors[0].node_id.as_deref(), Some("m")); + + let reconverged = structurally_valid_graph(json!({ + "name": "main-port-reconverges-before-fan-in", + "nodes": [ + { "id": "start", "kind": "trigger", "name": "Trigger" }, + { "id": "route", "kind": "switch", "name": "Route", "config": { "field": "kind" } }, + { "id": "a", "kind": "output_parser", "name": "A" }, + { "id": "c", "kind": "output_parser", "name": "C" }, + { "id": "m", "kind": "merge", "name": "Merge" } + ], + "edges": [ + { "from_node": "start", "from_port": "main", "to_node": "route" }, + { "from_node": "start", "from_port": "main", "to_node": "c" }, + { "from_node": "route", "from_port": "main", "to_node": "a" }, + { "from_node": "route", "from_port": "default", "to_node": "a" }, + { "from_node": "a", "from_port": "main", "to_node": "m" }, + { "from_node": "c", "from_port": "main", "to_node": "m" } + ] + })); + assert!(engine_compatibility_errors(&reconverged).is_empty()); +} + +#[test] +fn engine_compatibility_requires_exhaustive_router_choices_for_reconvergence() { + let exhaustive_condition = nested_router_reconvergence_graph("condition", &["true", "false"]); + assert!(engine_compatibility_errors(&exhaustive_condition).is_empty()); + + let missing_condition_branch = nested_router_reconvergence_graph("condition", &["true"]); + let errors = engine_compatibility_errors(&missing_condition_branch); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].code, UNSUPPORTED_NESTED_CONDITIONAL_FAN_IN); + + let exhaustive_switch = nested_router_reconvergence_graph("switch", &["known-case", "default"]); + assert!(engine_compatibility_errors(&exhaustive_switch).is_empty()); + + // Same-port fan-out is unconditional: TinyFlows schedules both `main` + // successors. A side path after an exhaustive router must not make the + // reconverging path look like another conditional choice. + let exhaustive_switch_with_main_fanout = structurally_valid_graph(json!({ + "nodes": [ + { "id": "start", "kind": "trigger", "name": "Trigger" }, + { "id": "outer", "kind": "condition", "name": "Outer", "config": { "field": "outer" } }, + { "id": "inner", "kind": "switch", "name": "Inner", "config": { "field": "inner" } }, + { "id": "outer_else", "kind": "output_parser", "name": "Outer else" }, + { "id": "fanout", "kind": "output_parser", "name": "Fan out" }, + { "id": "a", "kind": "output_parser", "name": "A" }, + { "id": "side", "kind": "output_parser", "name": "Side" }, + { "id": "c", "kind": "output_parser", "name": "C" }, + { "id": "m", "kind": "merge", "name": "Merge" } + ], + "edges": [ + { "from_node": "start", "from_port": "main", "to_node": "outer" }, + { "from_node": "start", "from_port": "main", "to_node": "c" }, + { "from_node": "outer", "from_port": "true", "to_node": "inner" }, + { "from_node": "outer", "from_port": "false", "to_node": "outer_else" }, + { "from_node": "inner", "from_port": "known-case", "to_node": "fanout" }, + { "from_node": "inner", "from_port": "default", "to_node": "fanout" }, + { "from_node": "fanout", "from_port": "main", "to_node": "a" }, + { "from_node": "fanout", "from_port": "main", "to_node": "side" }, + { "from_node": "a", "from_port": "main", "to_node": "m" }, + { "from_node": "c", "from_port": "main", "to_node": "m" } + ] + })); + assert!(engine_compatibility_errors(&exhaustive_switch_with_main_fanout).is_empty()); + + // A switch with only `default` is exhaustive: every input takes that edge, + // so it is an unconditional step even though it has a single wired port. + let default_only_switch = nested_router_reconvergence_graph("switch", &["default"]); + assert!(engine_compatibility_errors(&default_only_switch).is_empty()); + + let missing_switch_default = + nested_router_reconvergence_graph("switch", &["known-case", "other-case"]); + let errors = engine_compatibility_errors(&missing_switch_default); + assert!(!errors.is_empty()); + assert!(errors + .iter() + .all(|error| error.code == UNSUPPORTED_NESTED_CONDITIONAL_FAN_IN)); + // Both the switch's own reconvergence and the downstream merge are unsafe; + // multiple switch ports may also report the same predecessor. Pin the + // affected fan-ins without coupling the test to diagnostic multiplicity. + assert!(errors + .iter() + .any(|error| error.node_id.as_deref() == Some("a"))); + assert!(errors + .iter() + .any(|error| error.node_id.as_deref() == Some("m"))); +} + +#[test] +fn engine_compatibility_rejects_reconvergence_before_nested_router() { + let graph = structurally_valid_graph(json!({ + "name": "reconverged-before-nested-router", + "nodes": [ + { "id": "start", "kind": "trigger", "name": "Trigger" }, + { "id": "outer", "kind": "condition", "name": "Outer", "config": { "field": "outer" } }, + { "id": "inner", "kind": "condition", "name": "Inner", "config": { "field": "inner" } }, + { "id": "a", "kind": "output_parser", "name": "A" }, + { "id": "inner_else", "kind": "output_parser", "name": "Inner else" }, + { "id": "c", "kind": "output_parser", "name": "C" }, + { "id": "m", "kind": "merge", "name": "Merge" } + ], + "edges": [ + { "from_node": "start", "from_port": "main", "to_node": "outer" }, + { "from_node": "start", "from_port": "main", "to_node": "c" }, + { "from_node": "outer", "from_port": "true", "to_node": "inner" }, + { "from_node": "outer", "from_port": "false", "to_node": "inner" }, + { "from_node": "inner", "from_port": "true", "to_node": "a" }, + { "from_node": "inner", "from_port": "false", "to_node": "inner_else" }, + { "from_node": "a", "from_port": "main", "to_node": "m" }, + { "from_node": "c", "from_port": "main", "to_node": "m" } + ] + })); + let errors = engine_compatibility_errors(&graph); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].code, UNSUPPORTED_NESTED_CONDITIONAL_FAN_IN); +} + +#[test] +fn engine_compatibility_treats_single_wired_router_outputs_as_conditional() { + let graph = structurally_valid_graph(json!({ + "name": "single-wired-nested-router-fan-in", + "nodes": [ + { "id": "start", "kind": "trigger", "name": "Trigger" }, + { "id": "outer", "kind": "switch", "name": "Outer", "config": { "field": "outer" } }, + { "id": "inner", "kind": "condition", "name": "Inner", "config": { "field": "inner" } }, + { "id": "a", "kind": "output_parser", "name": "A" }, + { "id": "c", "kind": "output_parser", "name": "C" }, + { "id": "m", "kind": "merge", "name": "Merge" } + ], + "edges": [ + { "from_node": "start", "from_port": "main", "to_node": "outer" }, + { "from_node": "start", "from_port": "main", "to_node": "c" }, + { "from_node": "outer", "from_port": "case", "to_node": "inner" }, + { "from_node": "inner", "from_port": "true", "to_node": "a" }, + { "from_node": "a", "from_port": "main", "to_node": "m" }, + { "from_node": "c", "from_port": "main", "to_node": "m" } + ] + })); + + let errors = engine_compatibility_errors(&graph); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].code, UNSUPPORTED_NESTED_CONDITIONAL_FAN_IN); + assert_eq!(errors[0].node_id.as_deref(), Some("m")); +} + +#[test] +fn engine_compatibility_detects_a_router_directly_preceding_fan_in() { + let nested = structurally_valid_graph(json!({ + "name": "direct-nested-router-fan-in", + "nodes": [ + { "id": "start", "kind": "trigger", "name": "Trigger" }, + { "id": "outer", "kind": "switch", "name": "Outer", "config": { "field": "outer" } }, + { "id": "inner", "kind": "condition", "name": "Inner", "config": { "field": "inner" } }, + { "id": "c", "kind": "output_parser", "name": "C" }, + { "id": "m", "kind": "merge", "name": "Merge" } + ], + "edges": [ + { "from_node": "start", "from_port": "main", "to_node": "outer" }, + { "from_node": "start", "from_port": "main", "to_node": "c" }, + { "from_node": "outer", "from_port": "case", "to_node": "inner" }, + { "from_node": "inner", "from_port": "true", "to_node": "m" }, + { "from_node": "c", "from_port": "main", "to_node": "m" } + ] + })); + let errors = engine_compatibility_errors(&nested); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].code, UNSUPPORTED_NESTED_CONDITIONAL_FAN_IN); + + let main_port = structurally_valid_graph(json!({ + "name": "direct-main-port-router-fan-in", + "nodes": [ + { "id": "start", "kind": "trigger", "name": "Trigger" }, + { "id": "route", "kind": "switch", "name": "Route", "config": { "field": "kind" } }, + { "id": "c", "kind": "output_parser", "name": "C" }, + { "id": "m", "kind": "merge", "name": "Merge" } + ], + "edges": [ + { "from_node": "start", "from_port": "main", "to_node": "route" }, + { "from_node": "start", "from_port": "main", "to_node": "c" }, + { "from_node": "route", "from_port": "main", "to_node": "m" }, + { "from_node": "c", "from_port": "main", "to_node": "m" } + ] + })); + let errors = engine_compatibility_errors(&main_port); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].code, UNSUPPORTED_MAIN_PORT_CONDITIONAL_FAN_IN); +} + +#[test] +fn engine_compatibility_recurses_through_nested_inline_sub_workflows() { + let unsafe_child = nested_conditional_fan_in_graph(); + let middle = json!({ + "nodes": [ + { "id": "middle-trigger", "kind": "trigger", "name": "Trigger" }, + { + "id": "inner-child", + "kind": "sub_workflow", + "name": "Inner child", + "config": { "workflow": unsafe_child } + } + ], + "edges": [ + { "from_node": "middle-trigger", "from_port": "main", "to_node": "inner-child" } + ] + }); + let parent = structurally_valid_graph(json!({ + "nodes": [ + { "id": "parent-trigger", "kind": "trigger", "name": "Trigger" }, + { + "id": "middle-child", + "kind": "sub_workflow", + "name": "Middle child", + "config": { "workflow": middle } + } + ], + "edges": [ + { "from_node": "parent-trigger", "from_port": "main", "to_node": "middle-child" } + ] + })); + + let errors = engine_compatibility_errors(&parent); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].code, UNSUPPORTED_NESTED_CONDITIONAL_FAN_IN); + assert!(errors[0].message.contains("middle-child")); + assert!(errors[0].message.contains("inner-child")); +} + +#[test] +fn resolver_lookup_rejects_an_incompatible_saved_child() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let child = store::create_flow( + &config, + "legacy child".to_string(), + structurally_valid_graph(nested_conditional_fan_in_graph()), + false, + false, + ) + .unwrap(); + + let error = load_engine_compatible_flow_graph(&config, &child.id) + .expect_err("resolver lookup must reject an unsafe legacy child"); + assert!( + error.contains(UNSUPPORTED_NESTED_CONDITIONAL_FAN_IN), + "{error}" + ); + assert!(error.contains(&child.id), "{error}"); +} + +#[test] +fn resolver_lookup_rejects_an_incompatible_saved_grandchild() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let grandchild = store::create_flow( + &config, + "legacy unsafe grandchild".to_string(), + structurally_valid_graph(nested_conditional_fan_in_graph()), + false, + false, + ) + .unwrap(); + let child = store::create_flow( + &config, + "saved child".to_string(), + structurally_valid_graph(referenced_child_graph(&grandchild.id)), + false, + false, + ) + .unwrap(); + + let error = load_engine_compatible_flow_graph(&config, &child.id) + .expect_err("resolver lookup must reject an unsafe saved grandchild"); + assert!( + error.contains(UNSUPPORTED_NESTED_CONDITIONAL_FAN_IN), + "{error}" + ); + assert!(error.contains(&child.id), "{error}"); + assert!(error.contains(&grandchild.id), "{error}"); + assert!(error.contains("saved-child"), "{error}"); +} + +#[test] +fn flows_validate_returns_stable_nested_conditional_fan_in_error() { + let outcome = flows_validate(nested_conditional_fan_in_graph()); + assert!(!outcome.value.valid); + assert_eq!(outcome.value.error_details.len(), 1); + assert_eq!( + outcome.value.error_details[0].code, + UNSUPPORTED_NESTED_CONDITIONAL_FAN_IN + ); + assert_eq!(outcome.value.error_details[0].node_id.as_deref(), Some("m")); + assert!(outcome.value.warnings.is_empty()); +} + +#[tokio::test] +async fn flows_run_rejects_legacy_nested_conditional_fan_in_before_execution() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + // Bypass the current author-time gate to simulate a definition persisted + // by an older OpenHuman build. Reads remain supported; execution does not. + let graph = structurally_valid_graph(nested_conditional_fan_in_graph()); + let flow = store::create_flow(&config, "legacy".to_string(), graph, false, true).unwrap(); + + let err = flows_run( + &config, + &flow.id, + json!({ "outer": true, "inner": true }), + FlowRunTrigger::Rpc, + ) + .await + .expect_err("legacy unsafe topology must fail closed"); + assert!(err.contains(UNSUPPORTED_NESTED_CONDITIONAL_FAN_IN), "{err}"); + + let reloaded = flows_get(&config, &flow.id).await.unwrap(); + assert_eq!(reloaded.value.last_status, None); + assert_eq!( + reloaded.value.graph, flow.graph, + "stored graph must be preserved" + ); +} + +#[tokio::test] +async fn flows_run_rejects_an_incompatible_saved_child_before_execution() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let child = store::create_flow( + &config, + "legacy unsafe child".to_string(), + structurally_valid_graph(nested_conditional_fan_in_graph()), + false, + false, + ) + .unwrap(); + let parent = store::create_flow( + &config, + "parent".to_string(), + structurally_valid_graph(referenced_child_graph(&child.id)), + false, + true, + ) + .unwrap(); + + let error = flows_run(&config, &parent.id, json!({}), FlowRunTrigger::Rpc) + .await + .expect_err("an unsafe saved child must fail before root execution starts"); + assert!( + error.contains(UNSUPPORTED_NESTED_CONDITIONAL_FAN_IN), + "{error}" + ); + assert!(error.contains(&child.id), "{error}"); + + let reloaded = flows_get(&config, &parent.id).await.unwrap().value; + assert_eq!(reloaded.last_status, None, "no run should have started"); +} + +#[tokio::test] +async fn flows_update_allows_metadata_only_edits_of_legacy_incompatible_graph() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let graph = structurally_valid_graph(nested_conditional_fan_in_graph()); + let flow = store::create_flow(&config, "legacy".to_string(), graph, false, false).unwrap(); + + let updated = flows_update( + &config, + &flow.id, + Some("renamed legacy".to_string()), + None, + Some(true), + None, + ) + .await + .expect("metadata-only update should preserve access to a legacy graph"); + + assert_eq!(updated.value.name, "renamed legacy"); + assert!(updated.value.require_approval); + assert_eq!(updated.value.graph, flow.graph); +} + +#[tokio::test] +async fn flows_create_rejects_an_incompatible_saved_child_before_persisting() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let child = store::create_flow( + &config, + "legacy unsafe child".to_string(), + structurally_valid_graph(nested_conditional_fan_in_graph()), + false, + false, + ) + .unwrap(); + + let error = flows_create( + &config, + "rejected parent".to_string(), + referenced_child_graph(&child.id), + false, + ) + .await + .expect_err("create must reject an unsafe saved child"); + + assert!( + error.contains(UNSUPPORTED_NESTED_CONDITIONAL_FAN_IN), + "{error}" + ); + assert!(error.contains(&child.id), "{error}"); + let flows = store::list_flows(&config).unwrap(); + assert_eq!(flows.len(), 1, "the rejected parent must not be persisted"); + assert_eq!(flows[0].id, child.id); +} + +#[tokio::test] +async fn flows_update_rejects_an_incompatible_saved_child_before_persisting() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let child = store::create_flow( + &config, + "legacy unsafe child".to_string(), + structurally_valid_graph(nested_conditional_fan_in_graph()), + false, + false, + ) + .unwrap(); + let original_graph = structurally_valid_graph(trigger_only_graph()); + let parent = store::create_flow( + &config, + "safe parent".to_string(), + original_graph.clone(), + false, + true, + ) + .unwrap(); + + let error = flows_update( + &config, + &parent.id, + None, + Some(referenced_child_graph(&child.id)), + None, + None, + ) + .await + .expect_err("update must reject an unsafe saved child"); + + assert!( + error.contains(UNSUPPORTED_NESTED_CONDITIONAL_FAN_IN), + "{error}" + ); + assert!(error.contains(&child.id), "{error}"); + let reloaded = flows_get(&config, &parent.id).await.unwrap().value; + assert_eq!( + reloaded.graph, original_graph, + "the rejected graph update must not be persisted" + ); +} + #[tokio::test] async fn flows_create_rejects_graph_without_trigger() { let tmp = TempDir::new().unwrap(); @@ -922,6 +1555,118 @@ async fn flows_resume_continues_a_paused_run_to_completion() { ); } +#[tokio::test] +async fn flows_resume_marks_an_incompatible_legacy_checkpoint_failed() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let created = flows_create(&config, "gated".to_string(), approval_gated_graph(), false) + .await + .unwrap(); + let run = flows_run( + &config, + &created.value.id, + json!({ "x": 1 }), + FlowRunTrigger::Rpc, + ) + .await + .unwrap(); + let thread_id = run.value["thread_id"].as_str().unwrap().to_string(); + let pending: Vec = + serde_json::from_value(run.value["pending_approvals"].clone()).unwrap(); + + // Simulate a graph persisted before the host compatibility gate existed. + // The store layer intentionally trusts its typed caller; authoring paths + // own validation. + store::update_flow_graph( + &config, + &created.value.id, + created.value.name.clone(), + structurally_valid_graph(nested_conditional_fan_in_graph()), + created.value.require_approval, + None, + None, + ) + .unwrap(); + + let error = flows_resume(&config, &created.value.id, &thread_id, pending, vec![]) + .await + .expect_err("an incompatible checkpoint cannot be resumed safely"); + assert!( + error.contains(UNSUPPORTED_NESTED_CONDITIONAL_FAN_IN), + "{error}" + ); + + let run_row = flows_get_run(&config, &thread_id).await.unwrap().value; + assert_eq!(run_row.status, "failed"); + assert!(run_row.pending_approvals.is_empty()); + assert!( + run_row + .error + .as_deref() + .is_some_and(|value| value.contains(UNSUPPORTED_NESTED_CONDITIONAL_FAN_IN)), + "the terminal run row should retain the rejection reason: {run_row:?}" + ); + let flow = flows_get(&config, &created.value.id).await.unwrap().value; + assert_eq!(flow.last_status.as_deref(), Some("failed")); +} + +#[tokio::test] +async fn flows_resume_marks_a_checkpoint_with_an_incompatible_saved_child_failed() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let created = flows_create(&config, "gated".to_string(), approval_gated_graph(), false) + .await + .unwrap(); + let run = flows_run( + &config, + &created.value.id, + json!({ "x": 1 }), + FlowRunTrigger::Rpc, + ) + .await + .unwrap(); + let thread_id = run.value["thread_id"].as_str().unwrap().to_string(); + let pending: Vec = + serde_json::from_value(run.value["pending_approvals"].clone()).unwrap(); + let child = store::create_flow( + &config, + "legacy unsafe child".to_string(), + structurally_valid_graph(nested_conditional_fan_in_graph()), + false, + false, + ) + .unwrap(); + store::update_flow_graph( + &config, + &created.value.id, + created.value.name.clone(), + structurally_valid_graph(referenced_child_graph(&child.id)), + created.value.require_approval, + None, + None, + ) + .unwrap(); + + let error = flows_resume(&config, &created.value.id, &thread_id, pending, vec![]) + .await + .expect_err("an incompatible saved child cannot be resumed safely"); + assert!( + error.contains(UNSUPPORTED_NESTED_CONDITIONAL_FAN_IN), + "{error}" + ); + assert!(error.contains(&child.id), "{error}"); + + let run_row = flows_get_run(&config, &thread_id).await.unwrap().value; + assert_eq!(run_row.status, "failed"); + assert!(run_row.pending_approvals.is_empty()); + assert!(run_row + .error + .as_deref() + .is_some_and(|value| value.contains(UNSUPPORTED_NESTED_CONDITIONAL_FAN_IN))); + let flow = flows_get(&config, &created.value.id).await.unwrap().value; + assert_eq!(flow.last_status.as_deref(), Some("failed")); +} + #[tokio::test] async fn flows_resume_missing_flow_errors() { let tmp = TempDir::new().unwrap(); @@ -4745,6 +5490,116 @@ async fn strict_gate_passes_a_valid_graph_and_rejects_a_structurally_invalid_one let err = strict_gate(&config, &bad).await.unwrap_err(); assert!(err.contains("structurally invalid"), "{err}"); assert!(err.contains("trigger"), "{err}"); + + // A structurally valid graph must still pass the shared engine gate. + let err = strict_gate(&config, &nested_conditional_fan_in_graph()) + .await + .unwrap_err(); + assert!(err.contains(UNSUPPORTED_NESTED_CONDITIONAL_FAN_IN), "{err}"); +} + +#[tokio::test] +async fn strict_gate_rejects_an_incompatible_saved_child_reference() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let child = store::create_flow( + &config, + "legacy unsafe child".to_string(), + structurally_valid_graph(nested_conditional_fan_in_graph()), + false, + false, + ) + .unwrap(); + + let error = strict_gate(&config, &referenced_child_graph(&child.id)) + .await + .expect_err("strict authoring must reject an incompatible saved child"); + assert!( + error.contains(UNSUPPORTED_NESTED_CONDITIONAL_FAN_IN), + "{error}" + ); + assert!(error.contains(&child.id), "{error}"); + assert!(error.contains("saved-child"), "{error}"); +} + +#[tokio::test] +async fn builder_proposal_rejects_an_incompatible_saved_child_reference() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let child = store::create_flow( + &config, + "legacy unsafe child".to_string(), + structurally_valid_graph(nested_conditional_fan_in_graph()), + false, + false, + ) + .unwrap(); + let parent = structurally_valid_graph(referenced_child_graph(&child.id)); + + let error = build_builder_proposal( + &config, + "propose_workflow", + "parent", + &parent, + false, + false, + None, + None, + None, + ) + .await + .expect_err("a proposal must reject an incompatible saved child"); + assert!( + error.contains(UNSUPPORTED_NESTED_CONDITIONAL_FAN_IN), + "{error}" + ); + assert!(error.contains(&child.id), "{error}"); + assert!(error.contains("saved-child"), "{error}"); +} + +#[test] +fn referenced_child_compatibility_stops_at_saved_workflow_cycles() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow_a = store::create_flow( + &config, + "cycle a".to_string(), + structurally_valid_graph(trigger_only_graph()), + false, + false, + ) + .unwrap(); + let flow_b = store::create_flow( + &config, + "cycle b".to_string(), + structurally_valid_graph(trigger_only_graph()), + false, + false, + ) + .unwrap(); + store::update_flow_graph( + &config, + &flow_a.id, + flow_a.name.clone(), + structurally_valid_graph(referenced_child_graph(&flow_b.id)), + false, + None, + None, + ) + .unwrap(); + store::update_flow_graph( + &config, + &flow_b.id, + flow_b.name.clone(), + structurally_valid_graph(referenced_child_graph(&flow_a.id)), + false, + None, + None, + ) + .unwrap(); + + let candidate = structurally_valid_graph(referenced_child_graph(&flow_a.id)); + assert!(referenced_workflow_compatibility_errors(&config, &candidate).is_empty()); } // ── core-managed drafts (F5) ───────────────────────────────────────────────── diff --git a/src/openhuman/flows/tools.rs b/src/openhuman/flows/tools.rs index 20eb1278d8..0544da57a7 100644 --- a/src/openhuman/flows/tools.rs +++ b/src/openhuman/flows/tools.rs @@ -23,9 +23,7 @@ use serde_json::{json, Value}; use tinyflows::model::{Node, NodeKind, WorkflowGraph}; use crate::openhuman::config::Config; -use crate::openhuman::flows::ops::{ - validate_and_migrate_graph, validate_binding_resolvability, validate_tool_contracts, -}; +use crate::openhuman::flows::ops::{build_builder_proposal, validate_and_migrate_graph}; use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; /// Max characters kept for a `config_hint` before truncation, so a long @@ -183,94 +181,34 @@ impl Tool for ProposeWorkflowTool { } }; - // Enforcing binding-resolvability gate (see - // `ops::validate_binding_resolvability`): reject outright — rather - // than merely warn — a `tool_call` binding that is guaranteed to - // resolve null (or the wrong value) at runtime, so the builder must - // fix the graph before it can even be proposed to the user. - let binding_errors = validate_binding_resolvability(&graph); - if !binding_errors.is_empty() { - tracing::debug!( - target: "flows", - %name, - error_count = binding_errors.len(), - "[flows] propose_workflow: binding-resolvability check rejected the graph" - ); - return Ok(ToolResult::error(format!( - "{}\n\nFix these bindings and call propose_workflow again.", - binding_errors.join("\n\n") - ))); - } - - // Tool-contract enforcement gate (systemic tool-contract fix, Part 2): - // reject a `tool_call` node whose slug isn't a REAL action in the - // live Composio catalog, or whose real required args aren't all - // wired — before the user ever reviews the proposal. - let contract_errors = validate_tool_contracts(&self.config, &graph).await; - if !contract_errors.is_empty() { - tracing::debug!( - target: "flows", - %name, - error_count = contract_errors.len(), - "[flows] propose_workflow: tool-contract check rejected the graph" - ); - return Ok(ToolResult::error(format!( - "{}\n\nFix these tool_call nodes and call propose_workflow again.", - contract_errors.join("\n\n") - ))); - } - - // Required-arg resolvability gate (issue B18): reject outright — - // rather than merely warn — a REQUIRED outbound arg (e.g. - // `GMAIL_SEND_EMAIL.subject`/`.body`) that LOOKS wired but resolves - // to `null` in a sandboxed test run, before the user ever reviews - // the proposal. See `ops::validate_required_arg_resolvability`. - let null_arg_errors = - crate::openhuman::flows::ops::validate_required_arg_resolvability(&graph).await; - if !null_arg_errors.is_empty() { - tracing::debug!( - target: "flows", - %name, - error_count = null_arg_errors.len(), - "[flows] propose_workflow: required-arg resolvability check rejected the graph" - ); - return Ok(ToolResult::error(format!( - "{}\n\nFix these bindings and call propose_workflow again.", - null_arg_errors.join("\n\n") - ))); - } - - let summary = build_summary(&graph); - // Author-time warnings: unfired trigger kinds + unwired REQUIRED - // Composio args (see `ops::graph_wiring_warnings`) — surfaced on the - // proposal so the builder fixes wiring before the user saves. - let mut warnings = crate::openhuman::flows::ops::graph_trigger_warnings(&graph); - warnings.extend( - crate::openhuman::flows::ops::graph_wiring_warnings(&self.config, &graph).await, - ); - let graph_value = serde_json::to_value(&graph)?; - - tracing::info!( - target: "flows", - %name, - node_count = graph.nodes.len(), + // Route every first proposal through the same canonical hard-gate and + // payload builder as revise/edit/save. In particular, this includes + // compatibility checks for literal workflow_id children, which cannot + // be detected by graph-only validation because they require the store. + match build_builder_proposal( + &self.config, + "propose_workflow", + &name, + &graph, require_approval, - warning_count = warnings.len(), - "[flows] propose_workflow: proposal ready for user review" - ); - - 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, - "summary": summary, - "warnings": warnings, - }))?)) + false, + None, + None, + None, + ) + .await + { + Ok(payload) => Ok(ToolResult::success(serde_json::to_string_pretty(&payload)?)), + Err(error) => { + tracing::debug!( + target: "flows", + %name, + %error, + "[flows] propose_workflow: builder gate rejected the graph" + ); + Ok(ToolResult::error(error)) + } + } } } diff --git a/src/openhuman/flows/tools_tests.rs b/src/openhuman/flows/tools_tests.rs index 59912f2fcf..d2133a079c 100644 --- a/src/openhuman/flows/tools_tests.rs +++ b/src/openhuman/flows/tools_tests.rs @@ -323,6 +323,78 @@ async fn propose_workflow_rejects_unschemad_agent_binding() { ); } +#[tokio::test] +async fn propose_workflow_rejects_an_incompatible_saved_child_reference() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let tool = ProposeWorkflowTool::new(Arc::clone(&config)); + + // Simulate a legacy child saved before the current TinyFlows engine + // rejected nested conditional fan-in. The parent itself is structurally + // valid, so only the config-aware shared builder gate can catch this. + let legacy_child = json!({ + "nodes": [ + { "id": "start", "kind": "trigger", "name": "Trigger" }, + { "id": "outer", "kind": "condition", "name": "Outer", "config": { "field": "outer" } }, + { "id": "inner", "kind": "condition", "name": "Inner", "config": { "field": "inner" } }, + { "id": "outer_else", "kind": "output_parser", "name": "Outer else" }, + { "id": "inner_else", "kind": "output_parser", "name": "Inner else" }, + { "id": "a", "kind": "output_parser", "name": "A" }, + { "id": "c", "kind": "output_parser", "name": "C" }, + { "id": "m", "kind": "merge", "name": "Merge" } + ], + "edges": [ + { "from_node": "start", "to_node": "outer" }, + { "from_node": "start", "to_node": "c" }, + { "from_node": "outer", "from_port": "true", "to_node": "inner" }, + { "from_node": "outer", "from_port": "false", "to_node": "outer_else" }, + { "from_node": "inner", "from_port": "true", "to_node": "a" }, + { "from_node": "inner", "from_port": "false", "to_node": "inner_else" }, + { "from_node": "a", "to_node": "m" }, + { "from_node": "c", "to_node": "m" } + ] + }); + let child_graph = crate::openhuman::flows::ops::migrate_and_deserialize_graph(legacy_child) + .expect("legacy child should deserialize"); + tinyflows::validate::validate(&child_graph) + .expect("legacy child should remain structurally valid"); + let child = crate::openhuman::flows::store::create_flow( + &config, + "Legacy unsafe child".to_string(), + child_graph, + false, + false, + ) + .unwrap(); + let parent = json!({ + "nodes": [ + { "id": "start", "kind": "trigger", "name": "Trigger" }, + { + "id": "saved-child", + "kind": "sub_workflow", + "name": "Saved child", + "config": { "workflow_id": child.id } + } + ], + "edges": [{ "from_node": "start", "to_node": "saved-child" }] + }); + + let result = tool + .execute(json!({ "name": "Parent", "graph": parent })) + .await + .unwrap(); + + assert!(result.is_error, "must reject unsafe saved child"); + let output = result.output(); + assert!( + output.contains("unsupported_nested_conditional_fan_in"), + "{output}" + ); + assert!(output.contains(&child.id), "{output}"); + assert!(output.contains("saved-child"), "{output}"); + assert!(output.contains("call propose_workflow again"), "{output}"); +} + /// Docs-drift guard (F2): `propose_workflow`'s hand-written description and the /// typed node-kind contracts are two views of the SAME DSL, and they must not /// diverge. If a node kind is added/renamed or a required config field changes diff --git a/src/openhuman/tinyagents/middleware.rs b/src/openhuman/tinyagents/middleware.rs index 366d0223b2..cfc9d07f34 100644 --- a/src/openhuman/tinyagents/middleware.rs +++ b/src/openhuman/tinyagents/middleware.rs @@ -3750,9 +3750,10 @@ 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. + /// tinyjuice's `MIN_ROWS` (3) and OpenHuman's default 2 KiB compaction + /// 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..20) .map(|i| { @@ -3809,6 +3810,13 @@ mod tests { // NOT in COMPACTION_EXEMPT_TOOLS loses the `"type"` marker. let mw = compaction_enabled_mw(); let payload = large_workflow_proposal_json(); + assert!( + payload.len() + >= crate::openhuman::config::Config::default() + .tokenjuice + .min_bytes_to_compress, + "baseline payload must clear OpenHuman's configured compaction floor" + ); let mut result = tool_result("some_other_tool", &payload); mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); assert_ne!( diff --git a/src/openhuman/tinyflows/caps.rs b/src/openhuman/tinyflows/caps.rs index a18deb1e76..f3b9668d9f 100644 --- a/src/openhuman/tinyflows/caps.rs +++ b/src/openhuman/tinyflows/caps.rs @@ -3291,7 +3291,7 @@ impl WorkflowResolver for OpenHumanWorkflowResolver { %workflow_id, "[flows] sub_workflow resolver: resolving workflow_id to a saved flow graph" ); - match flows::ops::load_flow_graph(&self.config, workflow_id) { + match flows::ops::load_engine_compatible_flow_graph(&self.config, workflow_id) { Ok(Some(graph)) => { tracing::debug!( target: "flows", @@ -4714,6 +4714,63 @@ mod tests { } } + #[tokio::test] + async fn resolver_rejects_an_engine_incompatible_saved_graph() { + let tmp = tempfile::TempDir::new().unwrap(); + let config = Arc::new(resolver_test_config(&tmp)); + let flow = flows::ops::flows_create( + &config, + "legacy child".to_string(), + serde_json::to_value(trigger_only_graph()).unwrap(), + false, + ) + .await + .unwrap() + .value; + let unsafe_graph = json!({ + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Trigger" }, + { "id": "outer", "kind": "condition", "name": "Outer", "config": { "field": "outer" } }, + { "id": "inner", "kind": "condition", "name": "Inner", "config": { "field": "inner" } }, + { "id": "outer_else", "kind": "output_parser", "name": "Outer else" }, + { "id": "inner_else", "kind": "output_parser", "name": "Inner else" }, + { "id": "a", "kind": "output_parser", "name": "A" }, + { "id": "c", "kind": "output_parser", "name": "C" }, + { "id": "m", "kind": "merge", "name": "Merge" } + ], + "edges": [ + { "from_node": "t", "from_port": "main", "to_node": "outer" }, + { "from_node": "t", "from_port": "main", "to_node": "c" }, + { "from_node": "outer", "from_port": "true", "to_node": "inner" }, + { "from_node": "outer", "from_port": "false", "to_node": "outer_else" }, + { "from_node": "inner", "from_port": "true", "to_node": "a" }, + { "from_node": "inner", "from_port": "false", "to_node": "inner_else" }, + { "from_node": "a", "from_port": "main", "to_node": "m" }, + { "from_node": "c", "from_port": "main", "to_node": "m" } + ] + }); + let db = config.workspace_dir.join("flows").join("flows.db"); + rusqlite::Connection::open(db) + .unwrap() + .execute( + "UPDATE flow_definitions SET graph_json = ?1 WHERE id = ?2", + rusqlite::params![unsafe_graph.to_string(), flow.id], + ) + .unwrap(); + + let error = OpenHumanWorkflowResolver { config } + .resolve(&flow.id) + .await + .expect_err("resolver must reject an incompatible legacy child"); + match error { + EngineError::Capability(message) => assert!( + message.contains("unsupported_nested_conditional_fan_in"), + "{message}" + ), + other => panic!("expected a capability error, got: {other:?}"), + } + } + // ── response_fields_from_schema ───────────────────────────────────────── // Direct unit tests for the pure schema-extraction step inside // `composio_response_fields`'s live-fetch loop — cheaper and more diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index ade98ad76e..e450b95775 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -15002,7 +15002,13 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { "Task B must reach done" ); assert!( - poll_team_members_status(&rpc_base, &team_id, "idle").await, + poll_team_members_status( + &rpc_base, + &team_id, + &[alice_id.as_str(), bob_id.as_str()], + "idle", + ) + .await, "team members must return to idle" ); @@ -15105,12 +15111,15 @@ async fn poll_team_task_status(rpc_base: &str, team_id: &str, task_id: &str, wan false } -/// Poll `agent_team_get` until every team member reaches `want` status. -/// -/// Task completion and member cleanup are separate ledger writes, so observing -/// a `done` task does not guarantee the member's idle transition is visible in -/// the same snapshot. -async fn poll_team_members_status(rpc_base: &str, team_id: &str, want: &str) -> bool { +/// Poll `agent_team_get` until every team member reaches `want` (or time out). +/// Task completion and the worker's transition back to idle are separate +/// durable operations, so observing a done task does not yet imply idle. +async fn poll_team_members_status( + rpc_base: &str, + team_id: &str, + expected_member_ids: &[&str], + want: &str, +) -> bool { for attempt in 0..160 { tokio::time::sleep(Duration::from_millis(250)).await; let got = post_json_rpc( @@ -15120,13 +15129,17 @@ async fn poll_team_members_status(rpc_base: &str, team_id: &str, want: &str) -> json!({ "teamId": team_id }), ) .await; - let view = assert_no_jsonrpc_error(&got, "agent_team_get member poll"); - let members = view + let members = assert_no_jsonrpc_error(&got, "agent_team_get member poll") .get("team") - .and_then(|team| team.get("members")) + .and_then(|tv| tv.get("members")) .and_then(Value::as_array); if members.is_some_and(|members| { - !members.is_empty() + members.len() == expected_member_ids.len() + && expected_member_ids.iter().all(|expected_id| { + members.iter().any(|member| { + member.get("id").and_then(Value::as_str) == Some(*expected_id) + }) + }) && members .iter() .all(|member| member.get("memberStatus").and_then(Value::as_str) == Some(want)) diff --git a/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs b/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs index 9af8a2d387..5cade5a55b 100644 --- a/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs +++ b/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs @@ -307,17 +307,17 @@ async fn round15_composio_agent_tools_backend_cache_and_trigger_history_edges() ); assert_eq!(action_tool.name(), "GMAIL_FETCH_EMAILS"); assert_eq!(action_tool.category().to_string(), "skill"); - let action_contract = action_tool + let contract_result = action_tool .execute(json!({ "query": "from:me" })) .await - .expect("per-action tool contract gate"); - assert!(action_contract.is_error); - assert!(action_contract.text().contains("Required arguments: query")); + .expect("per-action contract gate"); + assert!(contract_result.is_error); + assert!(contract_result.text().contains("Input JSON schema")); let action_result = action_tool .execute(json!({ "query": "from:me" })) .await - .expect("per-action tool execute after contract gate"); + .expect("per-action tool retry"); assert_eq!(action_result.text(), "Fetched 1 inbox message"); let reserved = composio_authorize(&config, "gmail", Some(json!({ "toolkit": "github" }))) diff --git a/vendor/tinyagents b/vendor/tinyagents index 19dc2c438e..2583fccc21 160000 --- a/vendor/tinyagents +++ b/vendor/tinyagents @@ -1 +1 @@ -Subproject commit 19dc2c438e1f8b2715a45ba7030bc455e611dcb2 +Subproject commit 2583fccc213a00f2a3d94744ff1e0d1541368f97 diff --git a/vendor/tinychannels b/vendor/tinychannels index d82953832c..14057e0d87 160000 --- a/vendor/tinychannels +++ b/vendor/tinychannels @@ -1 +1 @@ -Subproject commit d82953832c6d76259ef4a2cfc87a3f86bd31d7f1 +Subproject commit 14057e0d877d27450a98dd8c145b4f464d7d1308 diff --git a/vendor/tinyflows b/vendor/tinyflows index e5327de9f6..56998ec3ea 160000 --- a/vendor/tinyflows +++ b/vendor/tinyflows @@ -1 +1 @@ -Subproject commit e5327de9f602c1fbbf72d45150729f45b91fa3a8 +Subproject commit 56998ec3eae655d7619b634d13aad0efaf0ad76a diff --git a/vendor/tinyjuice b/vendor/tinyjuice index 41b5ca87c4..e6848ed87d 160000 --- a/vendor/tinyjuice +++ b/vendor/tinyjuice @@ -1 +1 @@ -Subproject commit 41b5ca87c4af786f908beb66eec1aae3df25fbdd +Subproject commit e6848ed87d5d661073e9eab87b6b385c3373ee38 From 353e07c117e57e8305f60d585e0633e5e1b95ea2 Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:18:36 +0530 Subject: [PATCH 43/56] fix(embeddings): classify 403 'not an embeddings model' as model-incompatible + redact API key from errors (#5116) (#5117) --- .../settings/panels/EmbeddingsPanel.tsx | 2 +- app/src/lib/i18n/ar.ts | 1 + app/src/lib/i18n/bn.ts | 1 + app/src/lib/i18n/de.ts | 1 + app/src/lib/i18n/en.ts | 1 + app/src/lib/i18n/es.ts | 1 + app/src/lib/i18n/fr.ts | 1 + app/src/lib/i18n/hi.ts | 1 + app/src/lib/i18n/id.ts | 1 + app/src/lib/i18n/it.ts | 1 + app/src/lib/i18n/ko.ts | 1 + app/src/lib/i18n/pl.ts | 1 + app/src/lib/i18n/pt.ts | 1 + app/src/lib/i18n/ru.ts | 1 + app/src/lib/i18n/zh-CN.ts | 1 + src/openhuman/embeddings/rpc.rs | 126 ++++++++++++++++-- 16 files changed, 131 insertions(+), 11 deletions(-) diff --git a/app/src/components/settings/panels/EmbeddingsPanel.tsx b/app/src/components/settings/panels/EmbeddingsPanel.tsx index f7719f5088..bcd690a878 100644 --- a/app/src/components/settings/panels/EmbeddingsPanel.tsx +++ b/app/src/components/settings/panels/EmbeddingsPanel.tsx @@ -666,7 +666,7 @@ const EmbeddingsPanel = ({ embedded = false }: EmbeddingsPanelProps = {}) => {
Option| { let mut body = serde_json::json!({ "error": error, "message": message }); if let Some(d) = detail { - body["detail"] = serde_json::Value::String(d.to_string()); + // The probe detail is the raw endpoint response body. It can carry the + // API key (OpenAI's 401 echoes `Incorrect API key provided: sk-…`), and + // the frontend appends `detail` to the surfaced message — so redact any + // key/bearer material before it ever leaves the core, for both the UI + // and logs (#5116). The clean classified `message` is the primary text; + // the sanitized detail only adds a self-diagnosis hint. + body["detail"] = serde_json::Value::String(redact_secrets(d)); } Some(RpcOutcome::new(body, vec![summary.to_string()])) }; @@ -792,19 +798,52 @@ fn embed_error_mentions_status(lower: &str, code: u16) -> bool { } /// A reachable, authenticated embeddings API that **rejected the model id** — the -/// user pointed the embeddings model field at a chat/reasoning model. Gated on a -/// 400/422 plus a model-rejection phrase so a genuine 5xx or oversized-input 400 -/// still falls through to the generic failure (issue #5017). +/// user pointed the embeddings model field at a chat/reasoning model. +/// +/// Two tiers of phrasing: +/// +/// - **Strong, status-independent phrasings** unambiguously name a model that +/// can't embed. OpenAI returns *HTTP 403* "You are not allowed to generate +/// embeddings from this model" when a chat model (e.g. `gpt-4o-mini`) is used +/// as the embeddings model — a MODEL problem, not an auth problem. Because +/// `classify_embed_probe` checks this **before** the 401/403 auth branch, that +/// 403 must be caught here or it falls through and misreports "enter a valid +/// key" (issue #5116). None of these phrases appear in a genuine auth rejection +/// (`Incorrect API key provided …`), so matching them ahead of auth is safe. +/// - **Weak phrasings** (a stray "does not exist" / odd model-name format) are +/// only unambiguous alongside a 400/422 bad-request, so a genuine 5xx or an +/// oversized-input 400 still falls through to the generic failure (issue #5017). fn is_embedding_model_incompatible(lower: &str) -> bool { + let strong_model_rejection = lower.contains("not allowed to generate embeddings") + || lower.contains("does not support embeddings") + || lower.contains("not an embedding model") + || lower.contains("is not an embedding") + || lower.contains("not supported for embeddings") + || (lower.contains("unsupported") && lower.contains("embedding")); + if strong_model_rejection { + return true; + } let bad_request = embed_error_mentions_status(lower, 400) || embed_error_mentions_status(lower, 422); bad_request - && (lower.contains("does not support embeddings") - || lower.contains("not an embedding model") - || lower.contains("is not an embedding") - || lower.contains("does not exist") - || lower.contains("not supported for embeddings") - || lower.contains("unexpected model name format")) + && (lower.contains("does not exist") || lower.contains("unexpected model name format")) +} + +/// Strip API-key / bearer-token material from any text before it reaches the UI +/// or logs. Matches OpenAI-style keys (`sk-…`, including the modern `sk-proj-…` +/// form with embedded hyphens/underscores) and `Bearer ` headers, and +/// replaces each **whole** match — the replacements deliberately contain no `sk-` +/// substring, so not even a key *prefix* can surface (#5116). +fn redact_secrets(input: &str) -> String { + use once_cell::sync::Lazy; + use regex::Regex; + static SK_KEY_RE: Lazy = Lazy::new(|| Regex::new(r"(?i)\bsk-[A-Za-z0-9_-]+").unwrap()); + static BEARER_RE: Lazy = + Lazy::new(|| Regex::new(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+").unwrap()); + let redacted = SK_KEY_RE.replace_all(input, "[redacted-key]"); + BEARER_RE + .replace_all(&redacted, "Bearer [redacted]") + .into_owned() } /// The post-response length guard fired: the endpoint embedded but returned a @@ -1224,6 +1263,73 @@ mod tests { } } + /// Issue #5116 — a **chat** model used as an embeddings model. OpenAI answers + /// *HTTP 403* "You are not allowed to generate embeddings from this model". + /// Before the fix this fell through to the 401/403 auth branch and told the + /// user to "enter a valid key" even though the key was fine — the model is the + /// problem. It must classify as MODEL_INCOMPATIBLE, ahead of the auth branch. + #[test] + fn classify_embed_probe_403_not_an_embeddings_model_is_model_incompatible_not_auth() { + for detail in [ + r#"openai embeddings returned HTTP 403 Forbidden: {"error":{"message":"You are not allowed to generate embeddings from this model","type":"invalid_request_error","param":null,"code":null}}"#, + r#"Embedding API error (403 Forbidden): {"error":{"message":"This is not an embedding model"}}"#, + r#"openai embeddings returned HTTP 403 Forbidden: {"error":{"message":"unsupported model for embedding"}}"#, + ] { + assert_eq!( + reject_code(EmbedProbe::Failed(detail.into())).as_deref(), + Some("EMBEDDINGS_MODEL_INCOMPATIBLE"), + "403 model-rejection must be model-incompatible, not auth: {detail}" + ); + } + } + + /// Issue #5116 (security) — a genuine bad key (401 "Incorrect API key + /// provided: sk-…") must STILL classify as auth, but the surfaced payload must + /// never carry the key: neither the message nor the redacted detail may + /// contain an `sk-` substring. + #[test] + fn classify_embed_probe_401_bad_key_is_auth_and_redacts_key() { + let detail = r#"openai embeddings returned HTTP 401 Unauthorized: {"error":{"message":"Incorrect API key provided: sk-proj-ABC123def456GHI789jkl012MNO. You can find your API key at https://platform.openai.com/account/api-keys.","type":"invalid_request_error","param":null,"code":"invalid_api_key"}}"#; + let rpc = classify_embed_probe(EmbedProbe::Failed(detail.into())) + .expect("bad key must reject the save"); + assert_eq!( + rpc.value.get("error").and_then(|v| v.as_str()), + Some("EMBEDDINGS_AUTH_FAILED"), + "a genuine 401 bad key must stay classified as auth" + ); + // Nothing in the surfaced payload may leak the key. + let surfaced = serde_json::to_string(&rpc.value).unwrap(); + assert!( + !surfaced.contains("sk-"), + "surfaced payload must not contain any sk- key material: {surfaced}" + ); + assert!( + rpc.value + .get("detail") + .and_then(|v| v.as_str()) + .map(|d| d.contains("[redacted-key]")) + .unwrap_or(false), + "the detail should keep a redaction marker for support diagnosis" + ); + } + + /// The redaction helper strips whole OpenAI-style keys (incl. the modern + /// `sk-proj-…` form) and bearer tokens, leaving no `sk-` prefix behind. + #[test] + fn redact_secrets_removes_key_and_bearer_material() { + let redacted = + redact_secrets("key sk-proj-ABC123_def-456 and Authorization: Bearer tok-xyz.789"); + assert!( + !redacted.contains("sk-"), + "no sk- prefix survives: {redacted}" + ); + assert!(!redacted.contains("tok-xyz.789"), "bearer token stripped"); + assert!(redacted.contains("[redacted-key]")); + assert!(redacted.contains("Bearer [redacted]")); + // Non-secret text is preserved. + assert!(redacted.contains("Authorization:")); + } + /// Issue #5017 — a transport-level failure (DNS / refused connection) is a /// reachability problem, distinct from a server that answered. Timeouts fall /// in the same bucket. From f55376b40eb02dac8b681d6cc7e5a4479f7c7f37 Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:19:12 +0530 Subject: [PATCH 44/56] fix(react-ui): surface layered pipeline status in Data Sync (GH-4690) (#5113) --- .../intelligence/MemorySourcesRegistry.tsx | 124 +++++++++++- .../MemorySourcesRegistry.sync.test.tsx | 14 ++ .../__tests__/MemorySourcesRegistry.test.tsx | 155 ++++++++++++++- .../intelligence/sourcePipelineStatus.test.ts | 185 ++++++++++++++++++ .../intelligence/sourcePipelineStatus.ts | 124 ++++++++++++ app/src/lib/i18n/ar.ts | 7 + app/src/lib/i18n/bn.ts | 7 + app/src/lib/i18n/de.ts | 10 + app/src/lib/i18n/en.ts | 8 + app/src/lib/i18n/es.ts | 10 + app/src/lib/i18n/fr.ts | 10 + app/src/lib/i18n/hi.ts | 7 + app/src/lib/i18n/id.ts | 8 + app/src/lib/i18n/it.ts | 10 + app/src/lib/i18n/ko.ts | 8 + app/src/lib/i18n/pl.ts | 10 + app/src/lib/i18n/pt.ts | 9 + app/src/lib/i18n/ru.ts | 9 + app/src/lib/i18n/zh-CN.ts | 7 + 19 files changed, 718 insertions(+), 4 deletions(-) create mode 100644 app/src/components/intelligence/sourcePipelineStatus.test.ts create mode 100644 app/src/components/intelligence/sourcePipelineStatus.ts diff --git a/app/src/components/intelligence/MemorySourcesRegistry.tsx b/app/src/components/intelligence/MemorySourcesRegistry.tsx index f7f0a21add..cd2025148e 100644 --- a/app/src/components/intelligence/MemorySourcesRegistry.tsx +++ b/app/src/components/intelligence/MemorySourcesRegistry.tsx @@ -10,6 +10,7 @@ */ import debug from 'debug'; import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useT } from '../../lib/i18n/I18nContext'; import { CoreStateContext } from '../../providers/coreStateContext'; @@ -35,10 +36,19 @@ import { openhumanGetMemorySyncSettings, openhumanUpdateMemorySyncSettings, } from '../../utils/tauriCommands/config'; -import { memoryTreeFlushSource } from '../../utils/tauriCommands/memoryTree'; +import { + memoryTreeFlushSource, + memoryTreePipelineStatus, + type MemoryTreePipelineStatus, +} from '../../utils/tauriCommands/memoryTree'; import Button from '../ui/Button'; import { AddMemorySourceDialog } from './AddMemorySourceDialog'; import { ConfirmationModal } from './ConfirmationModal'; +import { + deriveSourcePipelineHealth, + pipelineIssueMessageKey, + type SourcePipelineHealth, +} from './sourcePipelineStatus'; import { SourceSettingsPanel } from './SourceSettingsPanel'; const log = debug('intelligence:memory-sync'); @@ -124,6 +134,7 @@ export function MemorySourcesRegistry({ pollIntervalMs = 5000, }: MemorySourcesRegistryProps) { const { t } = useT(); + const navigate = useNavigate(); // Read the core snapshot directly (not via the throwing `useCoreState` // hook) so this component still renders in unit tests that mount it // without a CoreStateProvider — there `ctx` is null and `isAuthenticated` @@ -132,6 +143,11 @@ export function MemorySourcesRegistry({ const isAuthenticated = coreState?.snapshot.auth.isAuthenticated ?? false; const [sources, setSources] = useState([]); const [statuses, setStatuses] = useState([]); + // Global downstream pipeline health (GH-4690) — the same snapshot the + // Brain > Memory > Sync panel renders. Folded into each row so a source that + // ingested but failed embeddings/extraction/tree-build shows a warning rather + // than a clean synced badge. `null` until the first poll resolves. + const [pipeline, setPipeline] = useState(null); const [loading, setLoading] = useState(true); const [dialogOpen, setDialogOpen] = useState(false); // RC#1 (#3295): use a Set so multiple sources can show "syncing" concurrently. @@ -268,7 +284,7 @@ export function MemorySourcesRegistry({ const refresh = useCallback(async () => { try { - const [list, stats] = await Promise.all([ + const [list, stats, health] = await Promise.all([ listMemorySources().catch(err => { console.warn('[ui-flow][memory-sources] list failed', err); return [] as MemorySourceEntry[]; @@ -277,9 +293,17 @@ export function MemorySourcesRegistry({ console.warn('[ui-flow][memory-sources] status_list failed', err); return [] as SourceStatus[]; }), + // GH-4690: downstream pipeline health. Best-effort — a failure here + // must never hide the source list, so we fall back to `null` (rows then + // rely on the precise per-source pending-chunk signal alone). + memoryTreePipelineStatus().catch(err => { + console.warn('[ui-flow][memory-sources] pipeline_status failed', err); + return null; + }), ]); setSources(list); setStatuses(stats); + setPipeline(health); // RC#5 (#3295): The 5s poll is the safety net for missed completed/failed events. // If a source is in syncingIds but the poll shows it's no longer active (no // in-progress status indicator from the server), we clear it here. In practice @@ -534,6 +558,8 @@ export function MemorySourcesRegistry({ key={source.id} source={source} status={statusById.get(source.id) ?? null} + pipeline={pipeline} + isAuthenticated={isAuthenticated} isSyncing={syncingIds.has(source.id) || syncProgress.has(source.id)} isBuilding={buildingId === source.id} progress={syncProgress.get(source.id) ?? null} @@ -546,6 +572,16 @@ export function MemorySourcesRegistry({ onToggleSettings={handleToggleSettings} onSettingsSaved={handleSettingsSaved} onToast={onToast} + onViewHealth={() => { + console.debug('[ui-flow][memory-sources] view memory health from source row'); + navigate('/brain?tab=sync'); + }} + onSignIn={() => { + console.debug( + '[ui-flow][memory-sources] sign-in prompt from stored-without-vectors' + ); + navigate('/auth'); + }} /> ))} @@ -692,6 +728,10 @@ function MemorySyncSchedule({ lastSyncMs, onToast }: MemorySyncScheduleProps) { interface SourceRowProps { source: MemorySourceEntry; status: SourceStatus | null; + /** Global downstream pipeline health (GH-4690); `null` before first poll. */ + pipeline: MemoryTreePipelineStatus | null; + /** Whether a backend session exists — gates the "Sign in to enable" prompt. */ + isAuthenticated: boolean; isSyncing: boolean; isBuilding: boolean; progress: SyncProgress | null; @@ -704,11 +744,17 @@ interface SourceRowProps { onToggleSettings: (sourceId: string) => void; onSettingsSaved: (updated: MemorySourceEntry) => void; onToast?: (toast: Omit) => void; + /** Navigate to Brain > Memory > Sync (the full memory-health panel). */ + onViewHealth: () => void; + /** Navigate to sign-in (embeddings need a backend session). */ + onSignIn: () => void; } function SourceRow({ source, status, + pipeline, + isAuthenticated, isSyncing, isBuilding, progress, @@ -721,6 +767,8 @@ function SourceRow({ onToggleSettings, onSettingsSaved, onToast, + onViewHealth, + onSignIn, }: SourceRowProps) { const { t } = useT(); const icon = SOURCE_KIND_ICONS[source.kind] ?? '📄'; @@ -728,6 +776,21 @@ function SourceRow({ const detail = sourceDetail(source); const lastSync = status ? relativeTimestamp(status.last_chunk_at_ms, t) : null; + // GH-4690: layered pipeline verdict. Only meaningful in a settled state — + // during an active sync (`progress`) or right after one (`result`) the row + // renders live progress / a terminal chip instead, and `chunks_pending` is + // legitimately transient, so we suppress the warning until things settle. + const settled = !progress && !result; + const health: SourcePipelineHealth = settled + ? deriveSourcePipelineHealth(status, pipeline) + : { state: 'none', issues: [], authRelated: false }; + const ingestedOnly = health.state === 'ingested_only'; + if (ingestedOnly) { + console.debug( + `[ui-flow][memory-sources] source=${source.id} ingested-only issues=${health.issues.join(',')}` + ); + } + return (
  • @@ -743,7 +806,13 @@ function SourceRow({ {kindLabel} - {status && status.chunks_synced > 0 && } + {status && + status.chunks_synced > 0 && + (ingestedOnly ? ( + + ) : ( + + ))}
    {detail &&

    {detail}

    } {progress && ( @@ -811,6 +880,37 @@ function SourceRow({ )}
  • )} + {ingestedOnly && ( +
    + {health.issues.map(issue => ( +
    + + {t(pipelineIssueMessageKey(issue))} +
    + ))} +
    + {health.authRelated && !isAuthenticated && ( + + )} + +
    +
    + )}
    + {/* Star us on GitHub — a subtle, dismissible CTA (#5005). The card owns + its own surface styling and renders nothing once the user stars or + dismisses it (durable, per-user), so it is not wrapped in a + SettingsSection that would leave a hollow box behind. */} + + {/* Diagnostics (app logs, restart tour, staging Sentry test) — relocated here from the retired Developer & Diagnostics page. */} diff --git a/app/src/features/star/GitHubStarCard.test.tsx b/app/src/features/star/GitHubStarCard.test.tsx new file mode 100644 index 0000000000..a558c10cfa --- /dev/null +++ b/app/src/features/star/GitHubStarCard.test.tsx @@ -0,0 +1,75 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { renderWithProviders } from '../../test/test-utils'; +import { GitHubStarCard } from './GitHubStarCard'; + +const mocks = vi.hoisted(() => ({ openUrl: vi.fn(), trackAnalyticsEvent: vi.fn() })); + +vi.mock('../../utils/openUrl', () => ({ openUrl: mocks.openUrl })); +vi.mock('../../components/analytics', () => ({ trackAnalyticsEvent: mocks.trackAnalyticsEvent })); + +beforeEach(() => { + mocks.openUrl.mockResolvedValue(undefined); +}); + +afterEach(() => { + Object.values(mocks).forEach(m => m.mockReset()); +}); + +describe('GitHubStarCard', () => { + test('renders the CTA when not yet dismissed', () => { + renderWithProviders(); + expect(screen.getByTestId('github-star-cta')).toBeInTheDocument(); + expect(screen.getByText('Enjoying OpenHuman?')).toBeInTheDocument(); + expect(screen.getByText('Star on GitHub')).toBeInTheDocument(); + }); + + test('does not render when already dismissed (persisted dismissal)', () => { + renderWithProviders(, { + preloadedState: { githubStar: { dismissed: true } }, + }); + expect(screen.queryByTestId('github-star-cta')).not.toBeInTheDocument(); + }); + + test('star click opens the repo, tracks analytics, and dismisses durably', async () => { + const user = userEvent.setup(); + const { store } = renderWithProviders(); + + await user.click(screen.getByText('Star on GitHub')); + + await waitFor(() => expect(mocks.openUrl).toHaveBeenCalledTimes(1)); + expect(mocks.openUrl.mock.calls[0][0]).toBe('https://github.com/tinyhumansai/openhuman'); + expect(mocks.trackAnalyticsEvent).toHaveBeenCalledWith('github_star_cta_clicked'); + // Acting on the CTA retires it: the flag is set and the card unmounts. + expect(store.getState().githubStar.dismissed).toBe(true); + await waitFor(() => expect(screen.queryByTestId('github-star-cta')).not.toBeInTheDocument()); + }); + + test('star click still retires the CTA when openUrl rejects (failure path)', async () => { + const user = userEvent.setup(); + mocks.openUrl.mockRejectedValueOnce(new Error('opener unavailable')); + const { store } = renderWithProviders(); + + await user.click(screen.getByText('Star on GitHub')); + + // A failed browser hand-off must not throw and must still retire the CTA: + // the durable dismissal is dispatched before the async openUrl resolves. + await waitFor(() => expect(mocks.openUrl).toHaveBeenCalledTimes(1)); + expect(store.getState().githubStar.dismissed).toBe(true); + await waitFor(() => expect(screen.queryByTestId('github-star-cta')).not.toBeInTheDocument()); + }); + + test('dismiss click hides the CTA, tracks analytics, and never opens a URL', async () => { + const user = userEvent.setup(); + const { store } = renderWithProviders(); + + await user.click(screen.getByText('Not now')); + + expect(mocks.trackAnalyticsEvent).toHaveBeenCalledWith('github_star_cta_dismissed'); + expect(mocks.openUrl).not.toHaveBeenCalled(); + expect(store.getState().githubStar.dismissed).toBe(true); + await waitFor(() => expect(screen.queryByTestId('github-star-cta')).not.toBeInTheDocument()); + }); +}); diff --git a/app/src/features/star/GitHubStarCard.tsx b/app/src/features/star/GitHubStarCard.tsx new file mode 100644 index 0000000000..2812459566 --- /dev/null +++ b/app/src/features/star/GitHubStarCard.tsx @@ -0,0 +1,91 @@ +/** + * In-app "Star us on GitHub" CTA (#5005). + * + * A subtle, dismissible card that nudges satisfied users to star the OpenHuman + * repo without leaving the app. Deliberately non-intrusive: it blocks nothing, + * lives inside Settings → About (a surface the user navigates to on purpose, so + * it is never shown on first launch), and never reappears once the user stars + * or dismisses it — the choice is persisted per-user via the `githubStar` + * Redux slice (see store/githubStarSlice.ts). + * + * Clicking "Star" opens the repo in the host browser through the shared + * `openUrl` helper (never `window.open` directly) so it works on macOS, + * Windows, and Linux via `tauri-plugin-opener`. + */ +import { useCallback } from 'react'; + +import { trackAnalyticsEvent } from '../../components/analytics'; +import Button from '../../components/ui/Button'; +import { useT } from '../../lib/i18n/I18nContext'; +import { dismissGithubStarCta } from '../../store/githubStarSlice'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { OPENHUMAN_GITHUB_REPO_URL } from '../../utils/config'; +import { openUrl } from '../../utils/openUrl'; + +const LOG_PREFIX = '[github-star-cta]'; + +export function GitHubStarCard() { + const { t } = useT(); + const dispatch = useAppDispatch(); + const dismissed = useAppSelector(state => state.githubStar.dismissed); + + const handleStar = useCallback(() => { + console.debug(`${LOG_PREFIX} star clicked; opening repo`); + trackAnalyticsEvent('github_star_cta_clicked'); + // Acting on the CTA also retires it — a user who starred does not want the + // nudge to keep reappearing. + dispatch(dismissGithubStarCta()); + void openUrl(OPENHUMAN_GITHUB_REPO_URL).catch(err => + console.debug(`${LOG_PREFIX} openUrl failed: ${String(err)}`) + ); + }, [dispatch]); + + const handleDismiss = useCallback(() => { + console.debug(`${LOG_PREFIX} dismissed`); + trackAnalyticsEvent('github_star_cta_dismissed'); + dispatch(dismissGithubStarCta()); + }, [dispatch]); + + // Durable dismissal: once handled, never render again. + if (dismissed) return null; + + return ( +
    +
    + +
    +
    + {t('settings.about.starCta.title')} +
    +

    + {t('settings.about.starCta.body')} +

    +
    +
    +
    + + +
    +
    + ); +} + +export default GitHubStarCard; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 35ac1deeda..26a087e20d 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -1563,6 +1563,11 @@ const messages: TranslationMap = { 'settings.about.releases': 'الإصدارات', 'settings.about.releasesDesc': 'تصفح ملاحظات الإصدار والإصدارات السابقة على GitHub.', 'settings.about.openReleases': 'فتح إصدارات GitHub', + 'settings.about.starCta.title': 'هل يعجبك OpenHuman؟', + 'settings.about.starCta.body': + 'امنحنا نجمة على GitHub. هذا يساعد المزيد من الأشخاص على العثور علينا.', + 'settings.about.starCta.star': 'ضع نجمة على GitHub', + 'settings.about.starCta.dismiss': 'ليس الآن', 'settings.about.connection': 'اتصال', 'settings.about.connectionMode': 'الوضع', 'settings.about.connectionModeLocal': 'محلي', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index fca8054ce6..675bcdd5b2 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -1598,6 +1598,11 @@ const messages: TranslationMap = { 'settings.about.releases': 'রিলিজ', 'settings.about.releasesDesc': 'GitHub-এ রিলিজ নোট ও আগের বিল্ড দেখুন।', 'settings.about.openReleases': 'GitHub রিলিজ খুলুন', + 'settings.about.starCta.title': 'OpenHuman ভালো লাগছে?', + 'settings.about.starCta.body': + 'GitHub-এ আমাদের একটি স্টার দিন। এতে আরও বেশি মানুষ আমাদের খুঁজে পান।', + 'settings.about.starCta.star': 'GitHub-এ স্টার দিন', + 'settings.about.starCta.dismiss': 'এখন নয়', 'settings.about.connection': 'সংযোগ', 'settings.about.connectionMode': 'মোড', 'settings.about.connectionModeLocal': 'স্থানীয়', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 3ffd870790..d7784e8a18 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -1655,6 +1655,10 @@ const messages: TranslationMap = { 'settings.about.releases': 'Veröffentlichungen', 'settings.about.releasesDesc': 'Durchsuche Versionshinweise und frühere Builds auf GitHub.', 'settings.about.openReleases': 'Öffne GitHub-Releases', + 'settings.about.starCta.title': 'Gefällt dir OpenHuman?', + 'settings.about.starCta.body': 'Gib uns einen Stern auf GitHub. So finden mehr Menschen zu uns.', + 'settings.about.starCta.star': 'Auf GitHub einen Stern vergeben', + 'settings.about.starCta.dismiss': 'Nicht jetzt', 'settings.about.connection': 'Verbindung', 'settings.about.connectionMode': 'Modus', 'settings.about.connectionModeLocal': 'Lokal', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 0acb09f41b..be773c57ab 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1807,6 +1807,10 @@ const en: TranslationMap = { 'settings.about.releases': 'Releases', 'settings.about.releasesDesc': 'Browse release notes and earlier builds on GitHub.', 'settings.about.openReleases': 'Open GitHub releases', + 'settings.about.starCta.title': 'Enjoying OpenHuman?', + 'settings.about.starCta.body': 'Star us on GitHub. It helps more people find us.', + 'settings.about.starCta.star': 'Star on GitHub', + 'settings.about.starCta.dismiss': 'Not now', 'settings.about.connection': 'Connection', 'settings.about.connectionMode': 'Mode', 'settings.about.connectionModeLocal': 'Local', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 6a761ab307..cb1a392a52 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -1634,6 +1634,10 @@ const messages: TranslationMap = { 'settings.about.releasesDesc': 'Explora las notas de versión y compilaciones anteriores en GitHub.', 'settings.about.openReleases': 'Abrir versiones en GitHub', + 'settings.about.starCta.title': '¿Te gusta OpenHuman?', + 'settings.about.starCta.body': 'Danos una estrella en GitHub. Así más gente nos encuentra.', + 'settings.about.starCta.star': 'Danos una estrella en GitHub', + 'settings.about.starCta.dismiss': 'Ahora no', 'settings.about.connection': 'Conexión', 'settings.about.connectionMode': 'Modo', 'settings.about.connectionModeLocal': 'locales', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index eb9cde7971..54f7ea04cc 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -1648,6 +1648,11 @@ const messages: TranslationMap = { 'settings.about.releasesDesc': 'Parcourir les notes de version et les builds précédentes sur GitHub.', 'settings.about.openReleases': 'Ouvrir les versions GitHub', + 'settings.about.starCta.title': 'OpenHuman vous plaît ?', + 'settings.about.starCta.body': + 'Mettez-nous une étoile sur GitHub. Cela aide plus de gens à nous découvrir.', + 'settings.about.starCta.star': 'Ajouter une étoile sur GitHub', + 'settings.about.starCta.dismiss': 'Pas maintenant', 'settings.about.connection': 'Connexion', 'settings.about.connectionMode': 'Mode', 'settings.about.connectionModeLocal': 'local', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 05717a1b3a..efb865e42b 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -1595,6 +1595,10 @@ const messages: TranslationMap = { 'settings.about.releases': 'रिलीज़', 'settings.about.releasesDesc': 'GitHub पर रिलीज़ नोट्स और पुराने बिल्ड देखें।', 'settings.about.openReleases': 'GitHub रिलीज़ खोलें', + 'settings.about.starCta.title': 'OpenHuman पसंद आ रहा है?', + 'settings.about.starCta.body': 'GitHub पर हमें स्टार दें। इससे और लोग हमें खोज पाते हैं।', + 'settings.about.starCta.star': 'GitHub पर स्टार करें', + 'settings.about.starCta.dismiss': 'अभी नहीं', 'settings.about.connection': 'कनेक्शन', 'settings.about.connectionMode': 'मोड', 'settings.about.connectionModeLocal': 'स्थानीय', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 5dfac2eb91..309985363d 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -1609,6 +1609,11 @@ const messages: TranslationMap = { 'settings.about.releases': 'Rilis', 'settings.about.releasesDesc': 'Telusuri catatan rilis dan build sebelumnya di GitHub.', 'settings.about.openReleases': 'Buka rilis GitHub', + 'settings.about.starCta.title': 'Menikmati OpenHuman?', + 'settings.about.starCta.body': + 'Beri kami bintang di GitHub. Ini membantu lebih banyak orang menemukan kami.', + 'settings.about.starCta.star': 'Beri bintang di GitHub', + 'settings.about.starCta.dismiss': 'Nanti saja', 'settings.about.connection': 'Koneksi', 'settings.about.connectionMode': 'Mode', 'settings.about.connectionModeLocal': 'Lokal', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 015a315926..84a4eb0db5 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -1634,6 +1634,10 @@ const messages: TranslationMap = { 'settings.about.releases': 'Release', 'settings.about.releasesDesc': 'Sfoglia le note di rilascio e le build precedenti su GitHub.', 'settings.about.openReleases': 'Apri release GitHub', + 'settings.about.starCta.title': 'Ti piace OpenHuman?', + 'settings.about.starCta.body': 'Mettici una stella su GitHub. Così più persone possono trovarci.', + 'settings.about.starCta.star': 'Metti una stella su GitHub', + 'settings.about.starCta.dismiss': 'Non ora', 'settings.about.connection': 'Connessione', 'settings.about.connectionMode': 'Modalità', 'settings.about.connectionModeLocal': 'Locale', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 498d67bcd5..74036bb72a 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -1584,6 +1584,11 @@ const messages: TranslationMap = { 'settings.about.releases': '릴리스', 'settings.about.releasesDesc': 'GitHub에서 릴리스 노트와 이전 빌드를 찾아보세요.', 'settings.about.openReleases': 'GitHub 릴리스 열기', + 'settings.about.starCta.title': 'OpenHuman이 마음에 드시나요?', + 'settings.about.starCta.body': + 'GitHub에서 별을 눌러 주세요. 더 많은 사람이 저희를 발견하는 데 도움이 됩니다.', + 'settings.about.starCta.star': 'GitHub에서 별 누르기', + 'settings.about.starCta.dismiss': '나중에', 'settings.about.connection': '연결', 'settings.about.connectionMode': '모드', 'settings.about.connectionModeLocal': '로컬', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 24f8adaf34..28350bac1c 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -1626,6 +1626,11 @@ const messages: TranslationMap = { 'settings.about.releasesDesc': 'Przeglądaj notatki o wydaniach i wcześniejsze buildy na GitHubie.', 'settings.about.openReleases': 'Otwórz wydania na GitHubie', + 'settings.about.starCta.title': 'Podoba Ci się OpenHuman?', + 'settings.about.starCta.body': + 'Daj nam gwiazdkę na GitHubie. Dzięki temu więcej osób nas znajdzie.', + 'settings.about.starCta.star': 'Oznacz gwiazdką na GitHub', + 'settings.about.starCta.dismiss': 'Nie teraz', 'settings.about.connection': 'Połączenie', 'settings.about.connectionMode': 'Tryb', 'settings.about.connectionModeLocal': 'Lokalny', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 8b7520d65e..c735c9d1b2 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -1632,6 +1632,10 @@ const messages: TranslationMap = { 'settings.about.releasesDesc': 'Navegar pelas notas de versão e compilações anteriores no GitHub.', 'settings.about.openReleases': 'Abrir versões no GitHub', + 'settings.about.starCta.title': 'Gostando do OpenHuman?', + 'settings.about.starCta.body': 'Dê uma estrela no GitHub. Assim mais pessoas nos encontram.', + 'settings.about.starCta.star': 'Marcar com estrela no GitHub', + 'settings.about.starCta.dismiss': 'Agora não', 'settings.about.connection': 'Conexão', 'settings.about.connectionMode': 'Modo', 'settings.about.connectionModeLocal': 'Local', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 2f1db535c7..b2c97525d7 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -1615,6 +1615,10 @@ const messages: TranslationMap = { 'settings.about.releases': 'Релизы', 'settings.about.releasesDesc': 'Просмотр заметок к релизам и предыдущих сборок на GitHub.', 'settings.about.openReleases': 'Открыть релизы на GitHub', + 'settings.about.starCta.title': 'Нравится OpenHuman?', + 'settings.about.starCta.body': 'Поставьте нам звезду на GitHub. Так нас найдёт больше людей.', + 'settings.about.starCta.star': 'Поставить звезду на GitHub', + 'settings.about.starCta.dismiss': 'Не сейчас', 'settings.about.connection': 'Соединение', 'settings.about.connectionMode': 'Режим', 'settings.about.connectionModeLocal': 'Локальный', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 7b771ded50..733edc1180 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -1510,6 +1510,10 @@ const messages: TranslationMap = { 'settings.about.releases': '发布版本', 'settings.about.releasesDesc': '在 GitHub 上浏览发布说明和早期版本。', 'settings.about.openReleases': '打开 GitHub 发布', + 'settings.about.starCta.title': '喜欢 OpenHuman 吗?', + 'settings.about.starCta.body': '在 GitHub 上给我们点个星,这能帮助更多人发现我们。', + 'settings.about.starCta.star': '在 GitHub 上加星', + 'settings.about.starCta.dismiss': '暂不', 'settings.about.connection': '连接方式', 'settings.about.connectionMode': '模式', 'settings.about.connectionModeLocal': '本地', diff --git a/app/src/services/analytics.ts b/app/src/services/analytics.ts index a57c20e4fc..3800002d94 100644 --- a/app/src/services/analytics.ts +++ b/app/src/services/analytics.ts @@ -98,6 +98,8 @@ const ALLOWED_EVENT_NAMES = [ 'account_connect_success', 'chat_message_sent', 'chat_message_shared', + 'github_star_cta_clicked', + 'github_star_cta_dismissed', 'automation_run_started', 'automation_run_resumed', 'automation_run_cancelled', diff --git a/app/src/store/githubStarSlice.test.ts b/app/src/store/githubStarSlice.test.ts new file mode 100644 index 0000000000..5cd4563a62 --- /dev/null +++ b/app/src/store/githubStarSlice.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import reducer, { dismissGithubStarCta, type GithubStarState } from './githubStarSlice'; + +const initial: GithubStarState = { dismissed: false }; + +describe('githubStarSlice', () => { + it('starts undismissed', () => { + expect(reducer(undefined, { type: '@@INIT' })).toEqual({ dismissed: false }); + }); + + it('marks the CTA dismissed', () => { + const next = reducer(initial, dismissGithubStarCta()); + expect(next.dismissed).toBe(true); + }); + + it('is idempotent — dismissing again stays dismissed', () => { + const once = reducer(initial, dismissGithubStarCta()); + const twice = reducer(once, dismissGithubStarCta()); + expect(twice.dismissed).toBe(true); + }); +}); diff --git a/app/src/store/githubStarSlice.ts b/app/src/store/githubStarSlice.ts new file mode 100644 index 0000000000..5270c18da1 --- /dev/null +++ b/app/src/store/githubStarSlice.ts @@ -0,0 +1,30 @@ +import { createSlice } from '@reduxjs/toolkit'; + +/** + * Tracks whether this user has dismissed (or acted on) the in-app "Star us on + * GitHub" CTA (#5005). Once `dismissed` is true the CTA never shows again. + * + * Persisted through `userScopedStorage` (see store/index.ts, whitelist + * `['dismissed']`) so the choice is per-user and survives reloads/restarts — + * a durable dismissal, not a process-local one. Both the "Star" and the + * "Not now" actions flip this flag: a user who starred does not want the nudge + * to keep reappearing either. + */ +export interface GithubStarState { + dismissed: boolean; +} + +const initialState: GithubStarState = { dismissed: false }; + +const githubStarSlice = createSlice({ + name: 'githubStar', + initialState, + reducers: { + dismissGithubStarCta(state) { + state.dismissed = true; + }, + }, +}); + +export const { dismissGithubStarCta } = githubStarSlice.actions; +export default githubStarSlice.reducer; diff --git a/app/src/store/index.ts b/app/src/store/index.ts index b41171943a..2b6f152a33 100644 --- a/app/src/store/index.ts +++ b/app/src/store/index.ts @@ -27,6 +27,7 @@ import chatRuntimeReducer from './chatRuntimeSlice'; import companionReducer from './companionSlice'; import connectivityReducer from './connectivitySlice'; import coreModeReducer from './coreModeSlice'; +import githubStarReducer from './githubStarSlice'; import layoutReducer from './layoutSlice'; import localeReducer from './localeSlice'; import mascotReducer from './mascotSlice'; @@ -219,6 +220,11 @@ const persistedChatRuntimeReducer = persistReducer(chatRuntimePersistConfig, cha const announcementPersistConfig = { key: 'announcement', storage, whitelist: ['shownIds'] }; const persistedAnnouncementReducer = persistReducer(announcementPersistConfig, announcementReducer); +// Persist whether this user dismissed/acted on the "Star us on GitHub" CTA +// (#5005) so the nudge never reappears once handled (user-scoped, durable). +const githubStarPersistConfig = { key: 'githubStar', storage, whitelist: ['dismissed'] }; +const persistedGithubStarReducer = persistReducer(githubStarPersistConfig, githubStarReducer); + export const store = configureStore({ reducer: { backendMeet: backendMeetReducer, @@ -240,6 +246,7 @@ export const store = configureStore({ theme: persistedThemeReducer, ptt: persistedPttReducer, announcement: persistedAnnouncementReducer, + githubStar: persistedGithubStarReducer, // In-memory only (not persisted): survives route changes / background-job // completion, resets on restart + user switch. Durable storage is a #3931 // follow-up. diff --git a/app/src/test/setup.ts b/app/src/test/setup.ts index fab9a65f5c..a3f3c36fb4 100644 --- a/app/src/test/setup.ts +++ b/app/src/test/setup.ts @@ -213,6 +213,7 @@ vi.mock('../utils/config', () => ({ BACKEND_URL: mockApiUrl, TELEGRAM_BOT_USERNAME: 'openhuman_bot', LATEST_APP_DOWNLOAD_URL: 'https://github.com/tinyhumansai/openhuman/releases/latest', + OPENHUMAN_GITHUB_REPO_URL: 'https://github.com/tinyhumansai/openhuman', APP_VERSION: '0.0.0-test', APP_BINARY_VERSION: '0.0.0-test', APP_ENVIRONMENT: 'test', diff --git a/app/src/test/test-utils.tsx b/app/src/test/test-utils.tsx index beda189643..2cab509b00 100644 --- a/app/src/test/test-utils.tsx +++ b/app/src/test/test-utils.tsx @@ -19,6 +19,7 @@ import chatRuntimeReducer from '../store/chatRuntimeSlice'; import companionReducer from '../store/companionSlice'; import connectivityReducer from '../store/connectivitySlice'; import coreModeReducer from '../store/coreModeSlice'; +import githubStarReducer from '../store/githubStarSlice'; import layoutReducer from '../store/layoutSlice'; import localeReducer from '../store/localeSlice'; import mascotReducer from '../store/mascotSlice'; @@ -48,6 +49,7 @@ const testRootReducer = combineReducers({ companion: companionReducer, connectivity: connectivityReducer, coreMode: coreModeReducer, + githubStar: githubStarReducer, layout: layoutReducer, locale: localeReducer, mascot: mascotReducer, diff --git a/app/src/utils/config.ts b/app/src/utils/config.ts index 793d3745ed..a20912b97f 100644 --- a/app/src/utils/config.ts +++ b/app/src/utils/config.ts @@ -226,6 +226,26 @@ export const LATEST_APP_DOWNLOAD_URL = (import.meta.env.VITE_LATEST_APP_DOWNLOAD_URL as string | undefined)?.trim() || 'https://github.com/tinyhumansai/openhuman/releases/latest'; +/** + * Public GitHub repository URL. Target of the in-app "Star us on GitHub" CTA + * (#5005). Override via VITE_OPENHUMAN_GITHUB_REPO_URL for forks. + * + * The override is accepted only when it parses as an `https:` URL. This value is + * handed straight to `openUrl` by the CTA, so a malformed string or a + * custom-scheme override could break the button or invoke an unintended + * protocol handler; anything that is not valid HTTPS falls back to the default. + */ +export const OPENHUMAN_GITHUB_REPO_URL = ((): string => { + const fallback = 'https://github.com/tinyhumansai/openhuman'; + const override = (import.meta.env.VITE_OPENHUMAN_GITHUB_REPO_URL as string | undefined)?.trim(); + if (!override) return fallback; + try { + return new URL(override).protocol === 'https:' ? override : fallback; + } catch { + return fallback; + } +})(); + /** Support page base URL. The crash screen appends `?ref=` so support can correlate a user's pasted Error ID to the exact Sentry event. Override via VITE_SUPPORT_URL for deployment-specific support endpoints. */ export const SUPPORT_URL = (import.meta.env.VITE_SUPPORT_URL as string | undefined)?.trim() || From e3a7f521787d93921c96cfd1cd2ef8bb7c01a2b9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 22 Jul 2026 15:29:07 +0000 Subject: [PATCH 51/56] chore(release): v0.63.1 [skip ci] --- Cargo.lock | 2 +- Cargo.toml | 2 +- app/package.json | 2 +- app/src-tauri/Cargo.lock | 4 ++-- app/src-tauri/Cargo.toml | 2 +- app/src-tauri/tauri.conf.json | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 453f3db8ee..8a5a346eac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4701,7 +4701,7 @@ dependencies = [ [[package]] name = "openhuman" -version = "0.63.0" +version = "0.63.1" dependencies = [ "aes-gcm", "aho-corasick", diff --git a/Cargo.toml b/Cargo.toml index 1a7e253409..28baaee479 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openhuman" -version = "0.63.0" +version = "0.63.1" edition = "2021" description = "OpenHuman core business logic and RPC server" autobins = false diff --git a/app/package.json b/app/package.json index c89d6c1420..c511540023 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "openhuman-app", - "version": "0.63.0", + "version": "0.63.1", "type": "module", "engines": { "node": ">=24.0.0" diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 0481fb2fa0..dbd774b5f3 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "OpenHuman" -version = "0.63.0" +version = "0.63.1" dependencies = [ "anyhow", "async-trait", @@ -5603,7 +5603,7 @@ dependencies = [ [[package]] name = "openhuman" -version = "0.63.0" +version = "0.63.1" dependencies = [ "aes-gcm", "aho-corasick", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index b9bca5904f..a269f6a8eb 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "OpenHuman" -version = "0.63.0" +version = "0.63.1" description = "OpenHuman - AI-powered Super Assistant" authors = ["OpenHuman"] edition = "2021" diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 0e57858f74..1baae667c4 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenHuman", - "version": "0.63.0", + "version": "0.63.1", "identifier": "com.openhuman.app", "build": { "beforeDevCommand": "pnpm run dev", From c480e6d817ad2277270c5f6d153f27c4e196eb1c Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:05:44 +0530 Subject: [PATCH 52/56] fix(agents): run custom registry agents with their real tools (flows + chat + tasks) (#5121) --- src/openhuman/agent/harness/definition.rs | 8 + .../harness/session/builder/builder_tests.rs | 102 ++++++++ .../agent/harness/session/builder/factory.rs | 176 ++++++++------ src/openhuman/agent/library/ops.rs | 4 +- src/openhuman/agent_registry/defaults.rs | 218 +++++++++++++++++- src/openhuman/agent_registry/mod.rs | 6 +- src/openhuman/agent_registry/ops.rs | 88 +++++++ src/openhuman/tinyflows/caps.rs | 97 +++++++- src/openhuman/tinyflows/tests.rs | 57 ++++- 9 files changed, 673 insertions(+), 83 deletions(-) diff --git a/src/openhuman/agent/harness/definition.rs b/src/openhuman/agent/harness/definition.rs index 6c23991318..5a0cd42351 100644 --- a/src/openhuman/agent/harness/definition.rs +++ b/src/openhuman/agent/harness/definition.rs @@ -662,6 +662,14 @@ pub enum DefinitionSource { Builtin, /// Loaded from a TOML file at the given absolute path. File(PathBuf), + /// Synthesized at lookup time from a user-authored + /// [`AgentRegistryEntry`](crate::openhuman::agent_registry::AgentRegistryEntry) + /// (`AgentRegistrySource::Custom`) by `agent_registry::defaults::definition_from_registry_entry`. + /// Never persisted in the [`AgentDefinitionRegistry`] — built fresh per + /// factory call so config edits take effect immediately (closes the gap + /// where custom agents ran persona-only instead of with their real tool + /// belt). + CustomRegistry, } // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/openhuman/agent/harness/session/builder/builder_tests.rs b/src/openhuman/agent/harness/session/builder/builder_tests.rs index 7e44eebcda..07291137af 100644 --- a/src/openhuman/agent/harness/session/builder/builder_tests.rs +++ b/src/openhuman/agent/harness/session/builder/builder_tests.rs @@ -241,6 +241,108 @@ async fn build_session_agent_falls_back_to_global_default_when_no_definition() { ); } +// ───────────────────────────────────────────────────────────────────────────── +// B38 (Gap 2) — a custom (non-shipped) `AgentRegistryEntry` must synthesize a +// real `AgentDefinition` and run with its own `ToolScope::Named` filter, +// instead of the factory hard-erroring "agent definition '…' not found in +// registry" (chat / task-dispatcher) because it never consulted +// `config.agent_registry.entries`. +// +// Regression note: this test deliberately does NOT call +// `AgentDefinitionRegistry::init_global*` itself, so — depending on whether +// an earlier test in this binary already initialised the process-wide +// `OnceLock` singleton — it exercises `build_session_agent_inner`'s tool- +// visibility computation under EITHER state: `(Some(def), Some(registry))` +// or `(Some(def), None)`. Both arms must apply `def.tools` (the synthesized +// `ToolScope::Named` from `definition_from_registry_entry`); the `None` +// (registry-uninitialized) arm previously fell through to the catch-all +// "no registry, no filter" case and silently discarded the custom agent's +// allowlist, leaving `visible_tool_names_for_test()` empty. See the +// `(Some(def), None)` match arm in `factory.rs`'s delegation-tool-and- +// visibility block for the fix. +// ───────────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn from_config_for_agent_synthesizes_custom_registry_entry_with_named_scope() { + use crate::openhuman::agent::harness::session::types::Agent; + use crate::openhuman::agent_registry::types::{ + AgentRegistryEntry, AgentRegistrySource, AgentSubagentPolicy, + }; + use crate::openhuman::tokenjuice::RETRIEVE_TOOL_NAME; + + let tmp = tempfile::TempDir::new().unwrap(); + let mut config = test_config(&tmp); + config.agent_registry.entries = vec![AgentRegistryEntry { + id: "finance_analyst_b38".to_string(), + name: "Finance Analyst".to_string(), + description: "Reviews spend and drafts finance summaries.".to_string(), + source: AgentRegistrySource::Custom, + enabled: true, + model: Some("hint:reasoning".to_string()), + system_prompt: Some("You are a meticulous finance analyst.".to_string()), + tool_allowlist: vec!["memory_search".to_string(), "web_search".to_string()], + tool_denylist: Vec::new(), + subagents: AgentSubagentPolicy::default(), + tags: Vec::new(), + metadata: serde_json::Value::Null, + }]; + + // Precondition: this id must NOT be a harness definition (built-in or + // workspace TOML) — the whole point is that only the config-backed + // custom registry knows about it. + assert!( + crate::openhuman::agent::harness::definition::AgentDefinitionRegistry::global() + .map(|reg| reg.get("finance_analyst_b38").is_none()) + .unwrap_or(true), + "test id must not collide with a real harness definition" + ); + + let agent = Agent::from_config_for_agent(&config, "finance_analyst_b38").expect( + "a custom agent_registry entry must synthesize a real AgentDefinition and build \ + successfully instead of erroring", + ); + + let visible = agent.visible_tool_names_for_test(); + assert!( + visible.contains("memory_search") && visible.contains("web_search"), + "the custom agent's tool_allowlist must become a real ToolScope::Named filter: {visible:?}" + ); + assert!( + visible.contains(RETRIEVE_TOOL_NAME), + "the compaction recovery tool must join any non-empty Named allowlist: {visible:?}" + ); + assert!( + !visible.contains("automate"), + "a tool outside the custom agent's allowlist must not be visible: {visible:?}" + ); +} + +#[tokio::test] +async fn from_config_for_agent_still_errors_for_a_genuinely_unknown_id() { + use crate::openhuman::agent::harness::session::types::Agent; + + let tmp = tempfile::TempDir::new().unwrap(); + let config = test_config(&tmp); + + // No harness definition AND no config.agent_registry entry for this id — + // the factory must still hard-error rather than silently building an + // unfiltered/legacy agent. + // + // Note: `Agent` intentionally has no `Debug` impl (it holds `Box` / provider trait objects), so this must use `match` + + // `.is_err()` rather than `.expect_err()`, which requires `T: Debug`. + let result = Agent::from_config_for_agent(&config, "totally_unknown_agent_id_b38"); + assert!( + result.is_err(), + "an id with no harness definition and no custom entry must error" + ); + let err = result.err().unwrap(); + assert!( + err.to_string().contains("totally_unknown_agent_id_b38"), + "error should name the unresolved agent id: {err}" + ); +} + // ── #5050 Fix 1: shared `Arc` for the per-build tool config ────────── #[test] diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index a711f138b9..89600c7a79 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -74,47 +74,11 @@ impl Agent { pub fn from_config_for_agent(config: &Config, agent_id: &str) -> Result { // Look up the target definition up front so we can fail fast // with a clear error instead of building half an agent and then - // discovering the id is unknown. The registry is a singleton - // initialised at startup; if it's not yet populated we - // conservatively fall back to the legacy "orchestrator-shaped" - // build by proceeding without a definition override. - let target_def: Option = - match AgentDefinitionRegistry::global() { - Some(reg) => match reg.get(agent_id) { - Some(def) => Some(def.clone()), - None if agent_id == "orchestrator" => { - // Orchestrator is allowed to be missing from the - // registry (legacy path, tests, pre-startup) — - // fall back to default behaviour. - log::debug!( - "[agent::builder] orchestrator definition not in registry — \ - using legacy default prompt + filter" - ); - None - } - None => { - return Err(anyhow::anyhow!( - "agent definition '{}' not found in registry", - agent_id - )); - } - }, - None => { - if agent_id != "orchestrator" { - return Err(anyhow::anyhow!( - "AgentDefinitionRegistry is not initialised — cannot \ - resolve agent '{}'. Call AgentDefinitionRegistry::init_global \ - at startup.", - agent_id - )); - } - log::debug!( - "[agent::builder] registry not initialised, orchestrator requested — \ - using legacy default prompt + filter" - ); - None - } - }; + // discovering the id is unknown. See `resolve_target_definition` + // for the full resolution order (harness registry, then the + // config-backed custom agent registry, then the orchestrator's + // legacy pre-startup fallback). + let target_def = resolve_target_definition(config, agent_id)?; log::info!( "[agent::builder] building session agent id={} \ @@ -197,30 +161,7 @@ impl Agent { profile_prompt_suffix: Option, profile: Option<&crate::openhuman::profiles::AgentProfile>, ) -> Result { - let target_def: Option = - match AgentDefinitionRegistry::global() { - Some(reg) => match reg.get(agent_id) { - Some(def) => Some(def.clone()), - None if agent_id == "orchestrator" => None, - None => { - return Err(anyhow::anyhow!( - "agent definition '{}' not found in registry", - agent_id - )); - } - }, - None => { - if agent_id != "orchestrator" { - return Err(anyhow::anyhow!( - "AgentDefinitionRegistry is not initialised — cannot \ - resolve agent '{}'. Call AgentDefinitionRegistry::init_global \ - at startup.", - agent_id - )); - } - None - } - }; + let target_def = resolve_target_definition(config, agent_id)?; Self::build_session_agent_inner( config, agent_id, @@ -885,7 +826,35 @@ impl Agent { }; (synthed, None) } - (_, None) => { + (Some(def), None) => { + // We have a target definition (either a pre-populated + // harness entry looked up before the registry singleton + // existed, or — the common case today — a `CustomRegistry` + // definition `resolve_target_definition` synthesizes + // straight from `config.agent_registry.entries` without + // ever consulting `AgentDefinitionRegistry::global()`, see + // `agent_registry::find_custom_in_config`). Delegation-tool + // synthesis needs the registry (to resolve named + // subagents), so it's skipped here, but `def.tools` is a + // real scope the caller authored and MUST still gate + // visibility — silently dropping it into the `(_, None)` + // "no registry, no filter" catch-all would leave a custom + // agent's `ToolScope::Named` allowlist entirely + // unenforced (visible tools empty rather than the named + // set), regressing the least-privilege contract this + // synthesis path exists to provide. + log::debug!( + "[agent::builder] AgentDefinitionRegistry not initialised — skipping \ + delegation tool synthesis, but still applying target definition's own \ + tool scope" + ); + let filter: Option> = match &def.tools { + ToolScope::Named(names) => Some(names.iter().cloned().collect()), + ToolScope::Wildcard => None, + }; + (Vec::new(), filter) + } + (None, None) => { log::debug!( "[agent::builder] AgentDefinitionRegistry not initialised — \ skipping delegation tool synthesis" @@ -1214,6 +1183,81 @@ impl Agent { } } +/// Resolves the `AgentDefinition` a session should be built from, given the +/// requested `agent_id`, in three steps: +/// +/// 1. **Harness registry** (`AgentDefinitionRegistry`, the process-global +/// singleton of built-in + workspace-TOML-override definitions) — a hit +/// here wins outright. +/// 2. **Config-backed custom agent registry** (`config.agent_registry.entries`, +/// `AgentRegistrySource::Custom`) — on a harness-registry miss (or the +/// registry not yet being initialised), a user-authored custom agent is +/// synthesized into a real `AgentDefinition` via +/// `agent_registry::definition_from_registry_entry` so it runs through +/// the exact same `build_session_agent_inner` path (and therefore the +/// exact same `SecurityPolicy` / tool-filtering / approval gate) as a +/// built-in. This closes the gap where a custom agent either hard-errored +/// (chat, task-dispatcher) or silently ran tool-less/persona-only (flows' +/// `RegistryFallback`) — see the cross-cutting fix in the PR that added +/// this function. +/// 3. **Orchestrator legacy fallback** — `orchestrator` alone is allowed to +/// resolve to `None` (pre-startup, tests): the caller then builds with the +/// default prompt/filter, matching pre-#1 behaviour. +/// +/// Any other id that resolves nowhere is a hard error, exactly as before this +/// function existed — only the *search order* changed, not the failure +/// contract for a genuinely-unknown id. +fn resolve_target_definition( + config: &Config, + agent_id: &str, +) -> Result> { + let registry = AgentDefinitionRegistry::global(); + + if let Some(reg) = registry { + if let Some(def) = reg.get(agent_id) { + return Ok(Some(def.clone())); + } + } + + // Harness registry miss (or not yet initialised). Before failing, check + // the config-backed custom agent registry — the one place custom + // (non-shipped) agents live. + if let Some(entry) = crate::openhuman::agent_registry::find_custom_in_config(config, agent_id) { + log::info!( + "[agent::builder] agent_id={} not found in the harness AgentDefinitionRegistry — \ + synthesizing a definition from its custom agent_registry entry so it runs with its \ + real tool belt instead of persona-only / erroring", + agent_id + ); + return Ok(Some( + crate::openhuman::agent_registry::definition_from_registry_entry(&entry), + )); + } + + if agent_id == "orchestrator" { + // Orchestrator is allowed to be missing from every source (legacy + // path, tests, pre-startup) — fall back to default behaviour. + log::debug!( + "[agent::builder] orchestrator definition not in any registry — using legacy \ + default prompt + filter" + ); + return Ok(None); + } + + if registry.is_none() { + return Err(anyhow::anyhow!( + "AgentDefinitionRegistry is not initialised — cannot resolve agent '{}'. Call \ + AgentDefinitionRegistry::init_global at startup.", + agent_id + )); + } + + Err(anyhow::anyhow!( + "agent definition '{}' not found in registry", + agent_id + )) +} + /// Resolve the `Config` the tool registry is built from (#5050, Fix 1). /// /// Normally this is the shared `base_config` — returned as a refcount bump, not a diff --git a/src/openhuman/agent/library/ops.rs b/src/openhuman/agent/library/ops.rs index 9033b01486..b993f35278 100644 --- a/src/openhuman/agent/library/ops.rs +++ b/src/openhuman/agent/library/ops.rs @@ -59,7 +59,9 @@ pub fn metadata_from_definition(def: &AgentDefinition) -> AgentDefinitionDisplay write_capable: is_write_capable(def), source: match &def.source { DefinitionSource::Builtin => AgentDefinitionSource::Builtin, - DefinitionSource::File(_) => AgentDefinitionSource::Custom, + DefinitionSource::File(_) | DefinitionSource::CustomRegistry => { + AgentDefinitionSource::Custom + } }, } } diff --git a/src/openhuman/agent_registry/defaults.rs b/src/openhuman/agent_registry/defaults.rs index 6fe73b58e8..c4e505a355 100644 --- a/src/openhuman/agent_registry/defaults.rs +++ b/src/openhuman/agent_registry/defaults.rs @@ -3,7 +3,8 @@ use serde_json::Value; use crate::openhuman::agent::harness::definition::{ - AgentDefinition, ModelSpec, SubagentEntry, ToolScope, + AgentDefinition, AgentTier, DefinitionSource, IterationPolicy, ModelSpec, PromptSource, + SandboxMode, SubagentEntry, ToolScope, TriggerMemoryAgent, }; use super::types::{AgentRegistryEntry, AgentRegistrySource, AgentSubagentPolicy}; @@ -49,6 +50,104 @@ fn default_entry_from_definition(def: AgentDefinition) -> AgentRegistryEntry { } } +/// Inverse of [`default_entry_from_definition`] — synthesizes a harness +/// [`AgentDefinition`] from a user-authored [`AgentRegistryEntry`] so a +/// custom agent (one with no shipped harness definition) can be built through +/// the same [`crate::openhuman::agent::Agent::from_config_for_agent`] factory +/// path as a built-in, and therefore run with its real tool belt rather than +/// degrade to a persona-only completion. +/// +/// Mapping (mirrors `default_entry_from_definition` field-for-field, in +/// reverse): +/// * `description` -> `when_to_use`; `name` -> `display_name`. +/// * `system_prompt` -> `PromptSource::Inline` (empty string when unset, +/// which renders as an empty subagent body rather than erroring). +/// * `model` -> `ModelSpec` via [`registry_value_to_model_spec`], the +/// inverse of [`model_to_registry_value`]. +/// * `tool_allowlist` -> `ToolScope` via [`allowlist_to_tool_scope`]: exactly +/// `["*"]` means `Wildcard`; an empty list means `Named(vec![])` (no +/// tools) — matching how `tools_to_allowlist` renders `Wildcard` as +/// `["*"]` and `Named(vec![])` as `[]`. +/// * `tool_denylist` -> `disallowed_tools` (direct clone). +/// * `subagents.allowlist` -> one `SubagentEntry::AgentId` per entry. +/// +/// Every other field is a harness-side concern a custom agent never +/// authors today, so it takes the harness's own safe default: `omit_* = +/// true` (narrow/lean prompt, matching every non-orchestrator built-in), +/// `temperature = 0.4`, `max_iterations = 8` under `IterationPolicy::Strict`, +/// `sandbox_mode = None`, `agent_tier = Worker`. `source` is stamped +/// [`DefinitionSource::CustomRegistry`] so it's visibly distinct from a +/// shipped/TOML-file definition in logs and `agent::list_definitions`. +pub fn definition_from_registry_entry(entry: &AgentRegistryEntry) -> AgentDefinition { + AgentDefinition { + id: entry.id.clone(), + when_to_use: entry.description.clone(), + display_name: Some(entry.name.clone()), + system_prompt: PromptSource::Inline(entry.system_prompt.clone().unwrap_or_default()), + omit_identity: true, + omit_memory_context: true, + omit_safety_preamble: true, + omit_skills_catalog: true, + omit_profile: true, + omit_memory_md: true, + model: registry_value_to_model_spec(entry.model.as_deref()), + temperature: 0.4, + tools: allowlist_to_tool_scope(&entry.tool_allowlist), + disallowed_tools: entry.tool_denylist.clone(), + skill_filter: None, + extra_tools: Vec::new(), + max_iterations: 8, + iteration_policy: IterationPolicy::Strict, + max_result_chars: None, + max_turn_output_tokens: None, + timeout_secs: None, + sandbox_mode: SandboxMode::None, + background: false, + trigger_memory_agent: TriggerMemoryAgent::Never, + tokenjuice_compression: Default::default(), + subagents: entry + .subagents + .allowlist + .iter() + .cloned() + .map(SubagentEntry::AgentId) + .collect(), + delegate_name: None, + agent_tier: AgentTier::Worker, + source: DefinitionSource::CustomRegistry, + graph: Default::default(), + } +} + +/// Inverse of [`model_to_registry_value`]: `None`/`"inherit"` -> `Inherit`; +/// `"hint:"` -> `Hint(role)`; anything else -> `Exact(value)`. +fn registry_value_to_model_spec(value: Option<&str>) -> ModelSpec { + match value.map(str::trim).filter(|v| !v.is_empty()) { + None => ModelSpec::Inherit, + Some("inherit") => ModelSpec::Inherit, + Some(v) => match v.strip_prefix("hint:") { + Some(hint) => ModelSpec::Hint(hint.to_string()), + None => ModelSpec::Exact(v.to_string()), + }, + } +} + +/// Inverse of [`tools_to_allowlist`]'s `Wildcard` rendering: exactly `["*"]` +/// means "all tools" (`ToolScope::Wildcard`). An **empty** allowlist is a +/// deliberate `ToolScope::Named(vec![])` — i.e. tool-less — matching what the +/// settings UI/schema mean by "no tools selected", and matching the forward +/// direction: `tools_to_allowlist(&ToolScope::Named(vec![]), &[])` already +/// renders back to `[]`, never `["*"]`. Collapsing empty to `Wildcard` here +/// would silently grant a custom agent saved with no tools selected every +/// enabled tool, bypassing the least-privilege setting the editor shows. +fn allowlist_to_tool_scope(allowlist: &[String]) -> ToolScope { + if allowlist == ["*"] { + ToolScope::Wildcard + } else { + ToolScope::Named(allowlist.to_vec()) + } +} + fn model_to_registry_value(model: &ModelSpec) -> Option { match model { ModelSpec::Inherit => Some("inherit".to_string()), @@ -85,4 +184,121 @@ mod tests { .iter() .all(|agent| agent.source == AgentRegistrySource::Default)); } + + fn custom_entry(id: &str) -> AgentRegistryEntry { + AgentRegistryEntry { + id: id.to_string(), + name: "Finance Analyst".to_string(), + description: "Reviews spend and drafts finance summaries.".to_string(), + source: AgentRegistrySource::Custom, + enabled: true, + model: Some("hint:reasoning".to_string()), + system_prompt: Some("You are a meticulous finance analyst.".to_string()), + tool_allowlist: vec!["memory_search".to_string(), "web_search".to_string()], + tool_denylist: vec!["file_write".to_string()], + subagents: AgentSubagentPolicy::from_allowlist(vec!["researcher".to_string()]), + tags: vec!["finance".to_string()], + metadata: Value::Null, + } + } + + #[test] + fn definition_from_registry_entry_preserves_tools_model_denylist_subagents() { + let entry = custom_entry("finance_analyst"); + let def = definition_from_registry_entry(&entry); + + assert_eq!(def.id, "finance_analyst"); + assert_eq!(def.when_to_use, entry.description); + assert_eq!(def.display_name(), "Finance Analyst"); + assert!(matches!( + def.model, + ModelSpec::Hint(ref hint) if hint == "reasoning" + )); + assert!(matches!( + def.tools, + ToolScope::Named(ref names) + if names == &vec!["memory_search".to_string(), "web_search".to_string()] + )); + assert_eq!(def.disallowed_tools, vec!["file_write".to_string()]); + assert_eq!( + def.subagents, + vec![SubagentEntry::AgentId("researcher".to_string())] + ); + assert_eq!(def.source, DefinitionSource::CustomRegistry); + } + + #[test] + fn definition_from_registry_entry_wildcard_allowlist_round_trips() { + let mut entry = custom_entry("wildcard_agent"); + entry.tool_allowlist = vec!["*".to_string()]; + let def = definition_from_registry_entry(&entry); + assert!(matches!(def.tools, ToolScope::Wildcard)); + + // Round trip back through the forward direction should reproduce the + // same wildcard shape `default_entry_from_definition` would emit. + assert_eq!(tools_to_allowlist(&def.tools, &[]), vec!["*".to_string()]); + } + + #[test] + fn definition_from_registry_entry_empty_allowlist_stays_tool_less() { + // Regression test (P1 review comment on this PR): an empty + // `tool_allowlist` means "no tools selected" in the settings UI/schema + // — it must synthesize a `ToolScope::Named(vec![])`, NEVER + // `ToolScope::Wildcard`. Collapsing empty to Wildcard would silently + // grant every enabled tool to a custom agent saved with no tools + // selected, bypassing the least-privilege setting the editor shows. + let mut entry = custom_entry("tool_less_agent"); + entry.tool_allowlist = Vec::new(); + let def = definition_from_registry_entry(&entry); + + assert!( + matches!(def.tools, ToolScope::Named(ref names) if names.is_empty()), + "an empty tool_allowlist must synthesize a tool-less Named([]) scope, not Wildcard: {:?}", + def.tools + ); + + // Round trip back through the forward direction must reproduce the + // same empty shape, not `["*"]`. + assert_eq!(tools_to_allowlist(&def.tools, &[]), Vec::::new()); + } + + #[test] + fn entry_to_definition_to_entry_round_trip_preserves_key_fields() { + let entry = custom_entry("finance_analyst"); + let def = definition_from_registry_entry(&entry); + + // Rebuild an entry from the synthesized definition the same way + // `default_entry_from_definition` does, and confirm the + // execution-critical fields survive the round trip. + let roundtripped = AgentRegistryEntry { + id: def.id.clone(), + name: def.display_name().to_string(), + description: def.when_to_use.clone(), + source: AgentRegistrySource::Custom, + enabled: true, + model: model_to_registry_value(&def.model), + system_prompt: None, + tool_allowlist: tools_to_allowlist(&def.tools, &def.extra_tools), + tool_denylist: def.disallowed_tools.clone(), + subagents: AgentSubagentPolicy::from_allowlist( + def.subagents + .iter() + .filter_map(|s| match s { + SubagentEntry::AgentId(id) => Some(id.clone()), + SubagentEntry::Skills(_) => None, + }) + .collect(), + ), + tags: Vec::new(), + metadata: Value::Null, + }; + + assert_eq!(roundtripped.id, entry.id); + assert_eq!(roundtripped.name, entry.name); + assert_eq!(roundtripped.description, entry.description); + assert_eq!(roundtripped.model, entry.model); + assert_eq!(roundtripped.tool_allowlist, entry.tool_allowlist); + assert_eq!(roundtripped.tool_denylist, entry.tool_denylist); + assert_eq!(roundtripped.subagents, entry.subagents); + } } diff --git a/src/openhuman/agent_registry/mod.rs b/src/openhuman/agent_registry/mod.rs index 370bfb4af3..30ab345732 100644 --- a/src/openhuman/agent_registry/mod.rs +++ b/src/openhuman/agent_registry/mod.rs @@ -13,10 +13,10 @@ mod schemas; pub mod tools; pub mod types; -pub use defaults::default_agents; +pub use defaults::{default_agents, definition_from_registry_entry}; pub use ops::{ - get_agent, list_agents, merge_entries, remove_agent, set_agent_enabled, update_agent, - upsert_custom_agent, + find_custom_in_config, get_agent, list_agents, merge_entries, remove_agent, set_agent_enabled, + update_agent, upsert_custom_agent, }; pub use schemas::{ all_controller_schemas as all_agent_registry_controller_schemas, diff --git a/src/openhuman/agent_registry/ops.rs b/src/openhuman/agent_registry/ops.rs index 3b7e7c3c86..f1c5485599 100644 --- a/src/openhuman/agent_registry/ops.rs +++ b/src/openhuman/agent_registry/ops.rs @@ -5,6 +5,7 @@ use std::collections::HashMap; use crate::openhuman::agent::harness::AgentDefinitionRegistry; use crate::openhuman::agent::Agent; use crate::openhuman::config::rpc as config_rpc; +use crate::openhuman::config::Config; use super::defaults::default_agents; use super::types::{AgentRegistryEntry, AgentRegistryPatch, AgentRegistrySource, AgentToolInfo}; @@ -197,6 +198,43 @@ pub fn merge_entries( result } +/// Synchronous, config-only lookup for a user-authored (`Custom`-source), +/// **enabled** agent registry entry by id. +/// +/// Used by the agent factory (`Agent::from_config_for_agent` family, see +/// `agent::harness::session::builder::factory`) on a harness-registry lookup +/// miss so a custom agent can be synthesized into a real +/// `AgentDefinition` (via `definition_from_registry_entry`) and run with its +/// real tool belt, instead of erroring (chat/task-dispatcher) or degrading to +/// a persona-only completion (flows). Deliberately sync — unlike +/// [`get_agent`]/[`list_agents`] — because the factory already holds a +/// `&Config` in scope and must not spawn an async config reload mid-build. +/// +/// Only `AgentRegistrySource::Custom` entries match: a `Default`-sourced +/// override (a user edit to a shipped agent, e.g. via `update_agent`) is +/// already resolvable through the harness `AgentDefinitionRegistry` by id — +/// that agent ships an `agent.toml`/builtin definition — so it never reaches +/// this fallback path. +/// +/// A **disabled** custom entry is deliberately treated as a miss (`None`), +/// same as an unknown id — never synthesized into a runnable definition here. +/// Every caller of this function (chat, task-dispatcher, flows' registry +/// routing) resolves an agent id directly to "runnable or not"; without this +/// filter a disabled custom agent referenced by an existing profile or a +/// direct caller could still run through the harness path, silently +/// bypassing the disabled flag the flows path already enforces explicitly. +pub fn find_custom_in_config(config: &Config, id: &str) -> Option { + let id = id.trim(); + config + .agent_registry + .entries + .iter() + .find(|entry| { + entry.id == id && entry.enabled && matches!(entry.source, AgentRegistrySource::Custom) + }) + .cloned() +} + fn apply_patch(entry: &mut AgentRegistryEntry, patch: AgentRegistryPatch) { if let Some(name) = patch.name { entry.name = name; @@ -294,6 +332,56 @@ mod tests { assert_eq!(merged.last().unwrap().id, "finance_analyst"); } + #[test] + fn find_custom_in_config_returns_matching_custom_entry() { + let mut config = Config::default(); + config.agent_registry.entries = vec![custom_agent("finance_analyst", true)]; + + let found = find_custom_in_config(&config, "finance_analyst"); + assert!(found.is_some()); + assert_eq!(found.unwrap().id, "finance_analyst"); + } + + #[test] + fn find_custom_in_config_ignores_default_source_entries() { + // A `Default`-sourced override (a user edit to a shipped agent) must + // NOT be picked up here — it already resolves via the harness + // `AgentDefinitionRegistry`, so this fallback should stay a miss. + let mut config = Config::default(); + config.agent_registry.entries = vec![AgentRegistryEntry { + source: AgentRegistrySource::Default, + ..custom_agent("researcher", true) + }]; + + assert!(find_custom_in_config(&config, "researcher").is_none()); + } + + #[test] + fn find_custom_in_config_misses_unknown_id() { + let mut config = Config::default(); + config.agent_registry.entries = vec![custom_agent("finance_analyst", true)]; + + assert!(find_custom_in_config(&config, "totally_unknown").is_none()); + } + + #[test] + fn find_custom_in_config_ignores_disabled_custom_entries() { + // Regression test (P2 review comment on this PR): a disabled custom + // agent must be treated as a miss here, exactly like an unknown id — + // otherwise a direct factory caller (chat, task-dispatcher) that + // references a disabled custom agent's id (e.g. via an existing + // profile) would still synthesize it into a runnable definition, + // bypassing the disabled flag that the flows path already enforces + // explicitly via `route_custom_entry_lookup`. + let mut config = Config::default(); + config.agent_registry.entries = vec![custom_agent("finance_analyst", false)]; + + assert!( + find_custom_in_config(&config, "finance_analyst").is_none(), + "a disabled custom entry must not be returned as a runnable custom agent" + ); + } + #[test] fn ensure_orchestrator_enabled_rejects_disabled_orchestrator() { let mut entry = custom_agent("orchestrator", false); diff --git a/src/openhuman/tinyflows/caps.rs b/src/openhuman/tinyflows/caps.rs index f3b9668d9f..54c2e1ef68 100644 --- a/src/openhuman/tinyflows/caps.rs +++ b/src/openhuman/tinyflows/caps.rs @@ -893,31 +893,108 @@ impl AgentRunner for OpenHumanAgentRunner { agent_ref, "[flows] agent_runner: HARNESS path — running the full agent tool loop" ); - self.run_via_harness(agent_ref, request, conn).await + // A shipped/TOML harness definition has no `entry.model` — the + // definition's own `ModelSpec` (already applied by the session + // builder) is the only model pin in play here. + self.run_via_harness(agent_ref, request, conn, None).await } AgentRoute::RegistryFallback => { - tracing::info!( - target: "flows", + // `route_for_agent_ref` only consults the harness + // `AgentDefinitionRegistry`, so a miss there used to mean + // "run the persona-only completion fallback" unconditionally + // — even for a user-created custom agent, which has real + // `tool_allowlist`/`model` settings that fallback ignores. + // + // The agent factory (`Agent::from_config_for_agent`) now + // also consults `config.agent_registry.entries` on a + // harness-registry miss and synthesizes a real + // `AgentDefinition` for any `AgentRegistrySource::Custom` + // entry it finds (issue B38/Gap 2). So: route a *known, + // enabled* custom entry through the harness turn — it gets + // its real tool belt — and reserve the persona-only + // completion for `agent_ref`s that are unknown to both the + // harness registry AND the custom config registry (or that + // are disabled, which `run_via_registry_fallback` already + // rejects with a clear error). + let custom_entry = crate::openhuman::agent_registry::find_custom_in_config( + &self.config, agent_ref, - "[flows] agent_runner: FALLBACK path — persona-shaping single completion for a \ - custom registry entry" ); - self.run_via_registry_fallback(agent_ref, request, conn) - .await + let entry_model = custom_entry.as_ref().and_then(|e| e.model.clone()); + match route_custom_entry_lookup(custom_entry.as_ref()) { + AgentRoute::Harness => { + tracing::info!( + target: "flows", + agent_ref, + "[flows] agent_runner: CUSTOM-REGISTRY path — routing through the \ + harness so the custom agent runs with its real tool belt instead of \ + the persona-only completion fallback" + ); + // Preserve the custom entry's own `model` pin (e.g. + // `hint:reasoning` or a raw BYOK model id) as the + // fallback below the node's own override — same + // precedence `run_via_registry_fallback` already gave + // it, now honored on the harness path too (P2 review + // comment on this PR: this previously regressed to the + // default chat model for a custom flow agent with no + // per-node override). + self.run_via_harness(agent_ref, request, conn, entry_model.as_deref()) + .await + } + AgentRoute::RegistryFallback => { + tracing::info!( + target: "flows", + agent_ref, + "[flows] agent_runner: FALLBACK path — persona-shaping single \ + completion for a custom registry entry" + ); + self.run_via_registry_fallback(agent_ref, request, conn) + .await + } + } } } } } +/// Decides how to run an `agent_ref` that has no harness definition, given +/// the (already-performed) config-backed custom registry lookup: an +/// [`AgentRoute::Harness`] for a known, *enabled* custom entry — the factory +/// synthesizes a real `AgentDefinition` for it (issue B38/Gap 2), so it can +/// run the full tool loop — and [`AgentRoute::RegistryFallback`] for +/// anything else (no entry at all, or a disabled one, which +/// [`OpenHumanAgentRunner::run_via_registry_fallback`] itself rejects with a +/// clear "is disabled" error rather than silently skipping it here). Pure +/// over the lookup result so the decision is unit-testable without a live +/// `Config`/registry. +pub(crate) fn route_custom_entry_lookup( + entry: Option<&crate::openhuman::agent_registry::AgentRegistryEntry>, +) -> AgentRoute { + match entry { + Some(e) if e.enabled => AgentRoute::Harness, + _ => AgentRoute::RegistryFallback, + } +} + impl OpenHumanAgentRunner { /// Full harness turn: build a real session agent for `agent_ref` and drive /// one `run_single` under the node's model override + timeout. See /// [`OpenHumanAgentRunner`] for the security/origin contract. + /// + /// `entry_model` is the custom `AgentRegistryEntry`'s own `model` pin (a + /// `hint:` or raw BYOK model id), when `agent_ref` resolved to one — + /// `None` for a shipped/TOML harness definition, which has no such entry. + /// It is the fallback below the node's own `config.model` override (see + /// `resolve_node_model`), matching the precedence + /// `run_via_registry_fallback` already gave `entry.model` — without this, + /// a custom flow agent's model pin (e.g. `hint:reasoning`) silently + /// dropped to the default chat model once routed through the harness. async fn run_via_harness( &self, agent_ref: &str, request: Value, conn: Option<&str>, + entry_model: Option<&str>, ) -> Result { use crate::openhuman::agent::Agent; @@ -931,9 +1008,9 @@ impl OpenHumanAgentRunner { } // Model precedence for a harness node: node `config.model` > the - // definition's own default. There is no custom registry `entry_model` on - // this path. - let node_model = resolve_node_model(&request, None); + // custom registry entry's own `model` pin (if any) > the definition's + // own default. + let node_model = resolve_node_model(&request, entry_model); // Apply the override the cron way (`run_agent_job`): a cloned `Config` // with a new `default_model`, so we never mutate the shared config or diff --git a/src/openhuman/tinyflows/tests.rs b/src/openhuman/tinyflows/tests.rs index 6a097c4ad0..dd36c8516f 100644 --- a/src/openhuman/tinyflows/tests.rs +++ b/src/openhuman/tinyflows/tests.rs @@ -539,8 +539,8 @@ async fn preflight_invoker_gates_the_mock_tool_path() { use super::caps::{ build_agent_result, clamp_run_timeout_secs, harness_model_default_override, - node_request_to_prompt, resolve_node_model, route_for_agent_ref, structured_output_instruction, - AgentRoute, + node_request_to_prompt, resolve_node_model, route_custom_entry_lookup, route_for_agent_ref, + structured_output_instruction, AgentRoute, }; #[test] @@ -697,3 +697,56 @@ fn route_for_agent_ref_selects_harness_for_definitions_else_fallback() { AgentRoute::RegistryFallback ); } + +// ── B38 (Gap 2): a custom agent_ref must route to the harness (real tools), +// not the persona-only completion fallback ───────────────────────────────── + +fn custom_registry_entry(enabled: bool) -> crate::openhuman::agent_registry::AgentRegistryEntry { + use crate::openhuman::agent_registry::types::{AgentRegistrySource, AgentSubagentPolicy}; + crate::openhuman::agent_registry::AgentRegistryEntry { + id: "finance_analyst".to_string(), + name: "Finance Analyst".to_string(), + description: "Reviews spend and drafts finance summaries.".to_string(), + source: AgentRegistrySource::Custom, + enabled, + model: Some("hint:reasoning".to_string()), + system_prompt: Some("You are a meticulous finance analyst.".to_string()), + tool_allowlist: vec!["memory_search".to_string()], + tool_denylist: Vec::new(), + subagents: AgentSubagentPolicy::default(), + tags: Vec::new(), + metadata: json!(null), + } +} + +#[test] +fn route_custom_entry_lookup_routes_enabled_custom_entry_to_harness() { + let entry = custom_registry_entry(true); + assert_eq!( + route_custom_entry_lookup(Some(&entry)), + AgentRoute::Harness, + "a known, enabled custom-registry agent must run through the harness (real tools), \ + not the persona-only completion fallback" + ); +} + +#[test] +fn route_custom_entry_lookup_falls_back_for_disabled_custom_entry() { + let entry = custom_registry_entry(false); + assert_eq!( + route_custom_entry_lookup(Some(&entry)), + AgentRoute::RegistryFallback, + "a disabled custom entry must still go through run_via_registry_fallback, which \ + rejects it with a clear \"is disabled\" error" + ); +} + +#[test] +fn route_custom_entry_lookup_falls_back_when_no_entry_exists() { + assert_eq!( + route_custom_entry_lookup(None), + AgentRoute::RegistryFallback, + "an agent_ref unknown to both the harness registry and the custom config registry \ + must still resolve through the fallback (which reports \"unknown agent_ref\")" + ); +} From 2bcd34496ccb2f85e42b2e7f204c51372d8c68fc Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Thu, 23 Jul 2026 05:42:14 +0300 Subject: [PATCH 53/56] refactor(memory): finish TinyCortex consolidation (#5140) --- .github/workflows/ci-lite.yml | 2 +- Cargo.lock | 27 +- Cargo.toml | 14 +- app/src-tauri/Cargo.lock | 49 +- docs/TEST-COVERAGE-MATRIX.md | 4 +- docs/tinycortex-api-gap-audit.md | 17 +- docs/tinycortex-drift-ledger.md | 40 +- docs/tinycortex-memory-migration-plan.md | 13 +- docs/tinycortex-migration-plan-2026-07-22.md | 417 +++++++++ docs/tinycortex-migration-spec.md | 44 +- docs/tinycortex-parity-checklist.md | 44 +- gitbooks/developing/architecture.md | 4 +- src/core/observability.rs | 11 +- .../agent/harness/archivist/hook_impl.rs | 12 +- .../agent/harness/archivist/recap.rs | 7 +- src/openhuman/embeddings/README.md | 133 +-- src/openhuman/embeddings/cohere_adapter.rs | 51 -- src/openhuman/embeddings/mod.rs | 14 +- src/openhuman/embeddings/mod_tests.rs | 8 +- src/openhuman/embeddings/noop.rs | 12 +- src/openhuman/embeddings/ollama_adapter.rs | 61 -- src/openhuman/embeddings/openai_adapter.rs | 71 -- src/openhuman/embeddings/voyage_adapter.rs | 69 -- .../inference/local/model_requirements.rs | 8 +- src/openhuman/learning/README.md | 2 +- src/openhuman/learning/cache.rs | 2 +- src/openhuman/memory/README.md | 2 +- src/openhuman/memory/mod.rs | 1 - src/openhuman/memory/read_rpc_tests.rs | 2 +- src/openhuman/memory_archivist/mod.rs | 44 - src/openhuman/memory_queue/mod.rs | 1 - src/openhuman/memory_search/mod.rs | 2 - src/openhuman/memory_search/scoring.rs | 40 - .../memory_search/tools/hybrid_search.rs | 7 +- .../memory_search/tools/vector_search.rs | 2 +- src/openhuman/memory_search/vector/mmr.rs | 9 - src/openhuman/memory_search/vector/mod.rs | 3 - src/openhuman/memory_store/README.md | 7 +- src/openhuman/memory_store/client.rs | 4 +- src/openhuman/memory_store/factories.rs | 2 +- src/openhuman/memory_store/kv.rs | 2 +- src/openhuman/memory_store/memory_trait.rs | 4 +- src/openhuman/memory_store/mod.rs | 15 +- .../{unified => namespace_store}/README.md | 9 +- .../{unified => namespace_store}/documents.rs | 0 .../documents_tests.rs | 0 .../{unified => namespace_store}/events.rs | 0 .../events_tests.rs | 0 .../{unified => namespace_store}/fts5.rs | 0 .../{unified => namespace_store}/graph.rs | 0 .../{unified => namespace_store}/helpers.rs | 0 .../{unified => namespace_store}/init.rs | 0 .../{unified => namespace_store}/mod.rs | 0 .../{unified => namespace_store}/profile.rs | 0 .../profile_tests.rs | 0 .../{unified => namespace_store}/query.rs | 0 .../query_tests.rs | 0 .../{unified => namespace_store}/segments.rs | 4 +- .../segments_tests.rs | 0 src/openhuman/memory_sync/README.md | 106 +-- .../composio/providers/clickup/mod.rs | 4 +- .../clickup/{sync.rs => normalization.rs} | 2 +- .../composio/providers/clickup/provider.rs | 16 +- .../composio/providers/clickup/tests.rs | 2 +- .../composio/providers/github/mod.rs | 4 +- .../github/{sync.rs => normalization.rs} | 2 +- .../composio/providers/github/provider.rs | 14 +- .../composio/providers/github/tests.rs | 8 +- .../composio/providers/gmail/mod.rs | 1 - .../composio/providers/gmail/sync.rs | 242 ----- .../composio/providers/gmail/tests.rs | 291 +----- .../composio/providers/linear/mod.rs | 2 +- .../linear/{sync.rs => normalization.rs} | 2 +- .../composio/providers/linear/provider.rs | 14 +- .../composio/providers/linear/tests.rs | 2 +- .../composio/providers/notion/mod.rs | 2 +- .../notion/{sync.rs => normalization.rs} | 2 +- .../composio/providers/notion/provider.rs | 10 +- .../composio/providers/notion/tests.rs | 2 +- src/openhuman/memory_sync/workspace/mod.rs | 2 +- src/openhuman/memory_tools/README.md | 12 +- src/openhuman/memory_tools/capture.rs | 5 +- src/openhuman/memory_tools/prompt.rs | 4 +- src/openhuman/memory_tools/store.rs | 8 +- src/openhuman/memory_tools/types.rs | 8 +- src/openhuman/memory_tree/README.md | 3 +- src/openhuman/memory_tree/mod.rs | 1 - .../memory_tree/score/embed/README.md | 29 +- .../memory_tree/score/embed/cloud.rs | 109 --- .../memory_tree/score/embed/factory.rs | 47 +- src/openhuman/memory_tree/score/embed/mod.rs | 41 +- .../memory_tree/score/embed/ollama.rs | 384 -------- src/openhuman/memory_tree/tools.rs | 4 - src/openhuman/mod.rs | 1 - src/openhuman/scheduler_gate/gate.rs | 2 +- src/openhuman/tinycortex/mod.rs | 28 +- src/openhuman/tinycortex/queue_driver.rs | 10 +- tests/agent_retrieval_e2e.rs | 2 +- tests/memory_golden_parity_e2e.rs | 4 +- ...osio_credentials_state_raw_coverage_e2e.rs | 2 +- .../embeddings_ollama_raw_coverage_e2e.rs | 828 ------------------ .../memory_sync_providers_raw_coverage_e2e.rs | 2 +- ...ory_tree_embed_round25_raw_coverage_e2e.rs | 46 +- vendor/tinyagents | 2 +- vendor/tinycortex | 2 +- 105 files changed, 887 insertions(+), 2723 deletions(-) create mode 100644 docs/tinycortex-migration-plan-2026-07-22.md delete mode 100644 src/openhuman/embeddings/cohere_adapter.rs delete mode 100644 src/openhuman/embeddings/ollama_adapter.rs delete mode 100644 src/openhuman/embeddings/openai_adapter.rs delete mode 100644 src/openhuman/embeddings/voyage_adapter.rs delete mode 100644 src/openhuman/memory_archivist/mod.rs delete mode 100644 src/openhuman/memory_search/scoring.rs delete mode 100644 src/openhuman/memory_search/vector/mmr.rs delete mode 100644 src/openhuman/memory_search/vector/mod.rs rename src/openhuman/memory_store/{unified => namespace_store}/README.md (86%) rename src/openhuman/memory_store/{unified => namespace_store}/documents.rs (100%) rename src/openhuman/memory_store/{unified => namespace_store}/documents_tests.rs (100%) rename src/openhuman/memory_store/{unified => namespace_store}/events.rs (100%) rename src/openhuman/memory_store/{unified => namespace_store}/events_tests.rs (100%) rename src/openhuman/memory_store/{unified => namespace_store}/fts5.rs (100%) rename src/openhuman/memory_store/{unified => namespace_store}/graph.rs (100%) rename src/openhuman/memory_store/{unified => namespace_store}/helpers.rs (100%) rename src/openhuman/memory_store/{unified => namespace_store}/init.rs (100%) rename src/openhuman/memory_store/{unified => namespace_store}/mod.rs (100%) rename src/openhuman/memory_store/{unified => namespace_store}/profile.rs (100%) rename src/openhuman/memory_store/{unified => namespace_store}/profile_tests.rs (100%) rename src/openhuman/memory_store/{unified => namespace_store}/query.rs (100%) rename src/openhuman/memory_store/{unified => namespace_store}/query_tests.rs (100%) rename src/openhuman/memory_store/{unified => namespace_store}/segments.rs (99%) rename src/openhuman/memory_store/{unified => namespace_store}/segments_tests.rs (100%) rename src/openhuman/memory_sync/composio/providers/clickup/{sync.rs => normalization.rs} (98%) rename src/openhuman/memory_sync/composio/providers/github/{sync.rs => normalization.rs} (98%) delete mode 100644 src/openhuman/memory_sync/composio/providers/gmail/sync.rs rename src/openhuman/memory_sync/composio/providers/linear/{sync.rs => normalization.rs} (99%) rename src/openhuman/memory_sync/composio/providers/notion/{sync.rs => normalization.rs} (99%) delete mode 100644 src/openhuman/memory_tree/score/embed/cloud.rs delete mode 100644 src/openhuman/memory_tree/score/embed/ollama.rs delete mode 100644 src/openhuman/memory_tree/tools.rs delete mode 100644 tests/raw_coverage/embeddings_ollama_raw_coverage_e2e.rs diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index 4b71a31e6a..4e2afa1c88 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -856,7 +856,7 @@ jobs: key: tinycortex - name: Run TinyCortex engine and Composio sync tests - run: bash scripts/ci-cancel-aware.sh cargo test --manifest-path vendor/tinycortex/Cargo.toml --features git-diff,sync + run: bash scripts/ci-cancel-aware.sh cargo test --manifest-path vendor/tinycortex/Cargo.toml --features git-diff,sync,persona pr-ci-gate: name: PR CI Gate diff --git a/Cargo.lock b/Cargo.lock index 2e46dbb883..bfdee024d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3079,7 +3079,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.57.0", ] [[package]] @@ -4746,7 +4746,7 @@ dependencies = [ "tar", "tempfile", "thiserror 2.0.18", - "tinyagents 2.1.0", + "tinyagents", "tinychannels", "tinycortex", "tinyflows", @@ -7332,23 +7332,6 @@ dependencies = [ "crunchy", ] -[[package]] -name = "tinyagents" -version = "2.0.0" -dependencies = [ - "async-trait", - "bytes", - "chrono", - "futures", - "reqwest 0.12.28", - "serde", - "serde_json", - "sha2 0.11.0", - "thiserror 2.0.18", - "tokio", - "tracing", -] - [[package]] name = "tinyagents" version = "2.1.0" @@ -7433,7 +7416,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "thiserror 2.0.18", - "tinyagents 2.0.0", + "tinyagents", "tokio", "toml 0.8.23", "tracing", @@ -7453,7 +7436,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", - "tinyagents 2.1.0", + "tinyagents", "tracing", ] @@ -8864,7 +8847,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d004bd3921..6b023d3904 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -109,10 +109,12 @@ tinyagents = { version = "2.1", features = ["sqlite"] } # queue/ingest/score + long tail), vendored as a git submodule and patched # below to `vendor/tinycortex`. OpenHuman's memory subsystem migrates onto this # crate through the adapter seam in `src/openhuman/tinycortex/` (mirroring the -# tinyagents seam): engine logic in the crate; RPC, agent tools, live sync, -# security gating, and the global singleton stay host-side. rusqlite/git2 are +# tinyagents seam): engine logic (including provider sync pipelines) in the +# crate; RPC, agent tools, sync scheduling/credentials/events, security gating, +# and the global singleton stay host-side. rusqlite/git2 are # aligned to the host pins (=0.40 / 0.21) so one bundled SQLite + one libgit2 -# link. Keep the version pin in lockstep with the submodule tag. +# link. The submodule intentionally tracks reviewed upstream main commits; +# keep this semver requirement compatible with the vendored crate version. tinycortex = { version = "0.1", features = ["git-diff", "persona", "sync"] } tinychannels = { version = "0.1", features = ["relay-websocket"] } serde = { version = "1", features = ["derive"] } @@ -704,12 +706,6 @@ tinyjuice = { path = "vendor/tinyjuice" } tinychannels = { path = "vendor/tinychannels" } tinyplace = { path = "vendor/tinyplace/sdk/rust" } -# TinyCortex temporarily pins the embedding API port while tinyagents #58 is -# pending. Resolve that git source to the same vendored crate so host adapters -# and TinyCortex share one `EmbeddingModel` trait identity. -[patch."https://github.com/senamakel/tinyagents"] -tinyagents = { path = "vendor/tinyagents" } - # Emit just enough DWARF in release builds for Sentry to symbolicate Rust # panics + render surrounding source lines. `line-tables-only` keeps the # binary small (only file+line tables, no full type info) while still diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index d3e7cce184..e1aa752e4d 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -205,7 +205,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -216,7 +216,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2061,7 +2061,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2479,7 +2479,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3706,7 +3706,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.57.0", ] [[package]] @@ -4107,7 +4107,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4991,7 +4991,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5603,7 +5603,7 @@ dependencies = [ "tar", "tempfile", "thiserror 2.0.18", - "tinyagents 2.1.0", + "tinyagents", "tinychannels", "tinycortex", "tinyflows", @@ -7133,7 +7133,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7192,7 +7192,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7949,7 +7949,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -8809,7 +8809,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -8968,23 +8968,6 @@ dependencies = [ "strict-num", ] -[[package]] -name = "tinyagents" -version = "2.0.0" -dependencies = [ - "async-trait", - "bytes", - "chrono", - "futures", - "reqwest 0.12.28", - "serde", - "serde_json", - "sha2 0.11.0", - "thiserror 2.0.18", - "tokio", - "tracing", -] - [[package]] name = "tinyagents" version = "2.1.0" @@ -9064,7 +9047,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "thiserror 2.0.18", - "tinyagents 2.0.0", + "tinyagents", "tokio", "toml 0.8.2", "tracing", @@ -9084,7 +9067,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", - "tinyagents 2.1.0", + "tinyagents", "tracing", ] @@ -9670,7 +9653,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -10466,7 +10449,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index 1153bc1667..717434d05a 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -366,10 +366,10 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an | ----- | ------------------------------------------ | ----- | -------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------- | | 8.4.1 | Save Preference (general / situational) | RU | `src/openhuman/agent/tools/save_preference_tests.rs` | ✅ | `save_preference` tool → `user_pref_{general,situational}`, topic-keyed | | 8.4.2 | Lane A — Standing Prefs in System Prompt | RU | `src/openhuman/learning/prompt_sections.rs`, `src/openhuman/agent/harness/session/turn_tests.rs` | ✅ | General prefs rendered into the system prompt at thread start | -| 8.4.3 | Lane B — Situational Recall (vector-gated) | RU | `src/openhuman/memory/store/unified/query_tests.rs::recall_relevant_by_vector_gates_on_similarity` | ✅ | Per-turn; relevant query injects, unrelated suppresses | +| 8.4.3 | Lane B — Situational Recall (vector-gated) | RU | `src/openhuman/memory_store/namespace_store/query_tests.rs::recall_relevant_by_vector_gates_on_similarity` | ✅ | Per-turn; relevant query injects, unrelated suppresses | | 8.4.4 | Same-Topic Contradiction (replace) | RU | `src/openhuman/agent/tools/save_preference_tests.rs::recategorising_moves_pref_between_namespaces` | ✅ | `ON CONFLICT REPLACE`; a topic lives in exactly one scope | | 8.4.5 | Cross-Topic Contradiction Surfacing | RU | `src/openhuman/agent/tools/save_preference_tests.rs::save_surfaces_related_preference_for_contradiction_check` | ✅ | Related prefs surfaced in the tool result for the chat agent to resolve | -| 8.4.6 | vector_chunks Model-Signature Recall Guard | RU | `src/openhuman/memory/store/unified/query_tests.rs::vector_recall_excludes_other_model_signature` | ✅ | Excludes cross-model vectors; dim-guards legacy rows | +| 8.4.6 | vector_chunks Model-Signature Recall Guard | RU | `src/openhuman/memory_store/namespace_store/query_tests.rs::vector_recall_excludes_other_model_signature` | ✅ | Excludes cross-model vectors; dim-guards legacy rows | ### 8.5 Long-term Goals diff --git a/docs/tinycortex-api-gap-audit.md b/docs/tinycortex-api-gap-audit.md index 1272f307ca..2cf269eae0 100644 --- a/docs/tinycortex-api-gap-audit.md +++ b/docs/tinycortex-api-gap-audit.md @@ -1,5 +1,10 @@ # TinyCortex API Gap Audit (Phase 0.2) +**Status (2026-07-22):** Historical gap inventory for the completed engine +cutover. Counts and source anchors below describe the audit base; unresolved +host consolidation work, including G1 re-homing and sync dedupe, is tracked in +`tinycortex-migration-plan-2026-07-22.md`. + **Purpose.** Map every host call site into the engine-mapping modules (`memory_store`, `memory_tree`, `memory_queue`, and the long tail) onto a TinyCortex public API. Where no crate API covers a host need, record a **gap** — each gap becomes a @@ -48,16 +53,16 @@ The crate exposes every seam the plan expects, plus more. Host implements these: | `Summariser` (time-tree, string) | `tree/runtime/engine.rs:29` | **divergent — see G6** | | `EntityExtractor` (regex + LLM) | `score/extract/composite.rs:16` | `tinycortex/chat.rs` (LLM variant) | | `QueueDelegates` | `queue/handlers.rs:96` | `tinycortex/queue_driver.rs` | -| `TreeJobSink` | `ingest/types.rs:15` | `tinycortex/sinks.rs` | -| `TreeLeafSink` | `archivist/sink.rs:44` | `tinycortex/sinks.rs` | -| `SnapshotItemSource` | `diff/source.rs:28` | `tinycortex/sinks.rs` | -| `EntityOccurrenceIndex` | `graph/types.rs:44` | `tinycortex/sinks.rs` | +| `TreeJobSink` | `ingest/types.rs:15` | `tinycortex/ingest.rs` | +| `TreeLeafSink` | `archivist/sink.rs:44` | `tinycortex/ingest.rs` | +| `SnapshotItemSource` | `diff/source.rs:28` | host diff adapter | +| `EntityOccurrenceIndex` | `graph/types.rs:44` | host graph adapter | | `SelfIdentity` | `store/entity_index/store.rs:25` | host identity registry | | `GoalsGenerator` | `goals/reflect.rs:57` | `tinycortex/chat.rs` | | `SourceReader` | `sources/readers/mod.rs:1732` | local readers only *(amended: live-sync readers join the crate in W-SYNC, plan §8)* | | `SyncEventSink` *(new, W-SYNC)* | `sync/traits.rs` (planned) | `tinycortex/sync_sink.rs` → `MemorySyncStage` bus events | | `SkillDocSink` *(new, W-SYNC)* | `sync/traits.rs` (planned) | forwards to `MemoryClient::store_skill_sync` (host-retained unified tier) | -| `ConversationEventBus` / `ChannelEventHandler` | `conversations/bus.rs:106/95` | `tinycortex/bus.rs` (translate to `DomainEvent`) | +| `ConversationEventBus` / `ChannelEventHandler` | `conversations/bus.rs:106/95` | host `memory_conversations/bus.rs` (translate to `DomainEvent`) | Config seam: `MemoryConfig{workspace, embedding: EmbeddingConfig{dim,model,strict}, tree: TreeConfig{input_token_budget,output_token_budget,summary_fanout,flush_age_secs}, @@ -86,7 +91,7 @@ retrieval: RetrievalConfig{default_profile}, sync_budget: SyncBudgetConfig}` + ### G2 — Graph relation-edge persistence (E2GraphRAG accumulation) — **HARD** -- **Host:** `memory_store/unified/graph.rs`, `unified/query.rs` persist and query co-occurrence +- **Host:** `memory_store/namespace_store/{graph,query}.rs` persist and query co-occurrence / LLM-triple relations; `NamespaceMemoryHit.supporting_relations: Vec` is populated at retrieval. - **Crate:** persists the **entity occurrence index** at persist time diff --git a/docs/tinycortex-drift-ledger.md b/docs/tinycortex-drift-ledger.md index 0f15010fec..66420eb77b 100644 --- a/docs/tinycortex-drift-ledger.md +++ b/docs/tinycortex-drift-ledger.md @@ -25,7 +25,9 @@ touched an engine-mapping memory module after the port line, and classifies each | Host audit SHA | `7850cf363559bcbb7ba688cbc4fccdb6bd9ce754` (`main`, 2026-07-04) | | TinyCortex submodule | `vendor/tinycortex` → `tinyhumansai/tinycortex` | | TinyCortex audit SHA | `d1a8c7be2babc8fff7a72ed93861f459f3d6fa58` | -| **TinyCortex pinned SHA (current)** | `a8e10f7dd8ebdb9b0905e1380fefcc6bf5a65207` — audit SHA **+ #59** (native-dep alignment, §0.4) **+ #63/#64** (D2/D1 drift ports) merged; this is the live gitlink | +| TinyCortex historical cutover SHA | `a8e10f7dd8ebdb9b0905e1380fefcc6bf5a65207` — audit SHA **+ #59** (native-dep alignment, §0.4) **+ #63/#64** (D2/D1 drift ports) merged | +| **TinyCortex reviewed gitlink (2026-07-22)** | `daaaf6ba5f02635c08deae2b2b2ed7fcc8c06b6a` — includes OpenHuman #4794/#4820/#4863-era crate work; upstream currently has no tags | +| **TinyCortex migration branch (current)** | `7b4b115` — TinyAgents 2.1 dependency alignment, standalone patch configuration, and corrected sync ownership docs | | TinyCortex crate version | `0.1.1` | | **Port line (derived)** | **after 2026-06-25, before 2026-06-28** (see below) | @@ -38,6 +40,11 @@ touched an engine-mapping memory module after the port line, and classifies each > (conversations) are now unblocked per the gate rule. D4 (memory_sync corpus, > W-SYNC) remains its own separate track. +> **Consolidation status (2026-07-22): D1–D4 CLOSED.** D1–D3 remain closed. +> D4 was re-audited against the crate sync implementation and the live host +> dispatch path. The host schedulers, credentials, RPC, source-scope/redaction +> policy, product task normalizers, and event-bus adapters remain host-owned. + ### How the port line was located The port commits in `vendor/tinycortex` are all dated **2026-06-29**, but that is the date the @@ -132,26 +139,23 @@ its `tools/` stay host (agent tools), its `vector`/`scoring` are engine (W5) — ## D4 — memory_sync corpus (2026-07-09 reclassification, W-SYNC) -The plan's §8 amendment moves the sync engine into the crate, so the blanket **HOST-OWNED -"live sync"** classification above is superseded for engine-mapping sync code. Unlike D1–D3 there -is **no existing crate port to diff against** — the entire `memory_sync/` engine (plus the -`memory_sources` dispatcher/reconcile parts) is port scope. The port baseline for W-SYNC is taken -**fresh from host `main` at W-SYNC.1 branch time**, so ordinary drift cannot accumulate; this entry -exists to (a) record the reclassification and (b) pin the post-port-line commits that already -touched the corpus, so the W-SYNC.1 port provably includes them: +The plan's §8 amendment moved the generic sync engine into the crate, so the +blanket **HOST-OWNED "live sync"** classification above is superseded for +engine-mapping sync code. The crate port landed in `0333d10` and is the path +called by `memory_sync::composio::run_connection_sync` and the default +`ComposioProvider::sync` implementation. | # | Host commit | Files in corpus | Note | | --- | --- | --- | --- | -| D4.1 | `c43f79641` (07-03) | `composio/providers/{sync_state,traits}.rs` | TinyAgents-cutover import churn | -| D4.2 | `27b00b539` (07-05) | `sources/rebuild.rs` | test-parity cleanup (1 line) | -| D4.3 | `653e6e143` (07-06) | `memory_sources/sync.rs` (+312) | self-heal drifted content-sha tokens + prune vanished folder items | -| D4.4 | `e456b7799` (07-07) | `memory_sources/{rpc,sync}.rs`, `canonicalize/email.rs`, `composio/providers/{gmail/post_process,notion/source,orchestrator}.rs`, `sources/github.rs` | orchestration-fixes wave | - -**Gate:** W-SYNC.1 (crate scaffolding PR) requires this enumeration current as of its branch point -(re-run the scan: `git log --since=2026-06-25 --oneline -- src/openhuman/memory_sync -src/openhuman/memory_sources`); the W-SYNC.3 host flip requires D4 **CLOSED** — every listed commit -(and any accrued since) verifiably contained in the crate port or explicitly waived. Host-retained -parts (schedulers, bus subscribers, RPC wrappers, keychain/OAuth) remain HOST-OWNED as before. +| D4.1 | `c43f79641` (07-03) | `composio/providers/{sync_state,traits}.rs` | ✅ **CLOSED.** Import churn only; persistence and provider dispatch are implemented by the `SyncStateStore` seam and crate dispatcher. | +| D4.2 | `27b00b539` (07-05) | `sources/rebuild.rs` | ✅ **CLOSED.** Test-only parity cleanup; no engine semantic delta. | +| D4.3 | `653e6e143` (07-06) | `memory_sources/sync.rs` (+312) | ✅ **CLOSED.** Crate `sync/workspace.rs` updates changed items and prunes vanished items through `LocalDocumentSink::delete`; covered by workspace sync tests. | +| D4.4 | `e456b7799` (07-07) | `memory_sources/{rpc,sync}.rs`, `canonicalize/email.rs`, `composio/providers/{gmail/post_process,notion/source,orchestrator}.rs`, `sources/github.rs` | ✅ **CLOSED.** Generic provider fetch/pagination/canonical memory records are in the crate port; orchestration, product task normalization, action-result presentation, and RPC fixes are host policy by design. | + +**Gate result:** D4 is **CLOSED**. Gmail's Composio 413 mitigation is additionally +present in crate commit `ba9e12e` (25-message fetch pages). Host-retained parts +(schedulers, bus subscribers, RPC wrappers, keychain/OAuth, action tools and +product task/profile projections) remain HOST-OWNED. ## Closing the ledger (procedure) diff --git a/docs/tinycortex-memory-migration-plan.md b/docs/tinycortex-memory-migration-plan.md index c6f673aac4..3fd78a86f1 100644 --- a/docs/tinycortex-memory-migration-plan.md +++ b/docs/tinycortex-memory-migration-plan.md @@ -1,6 +1,9 @@ # TinyCortex Memory Migration — Plan & Audit -**Status:** W1–W2 landed; W3 partial (chunks sub-store flipped). Amended 2026-07-09 (see §8) to a **sync-inclusive** scope. +**Status:** Historical execution plan. W1–W8 and the engine test port are landed +(OpenHuman #4794/#4820); persona/coding-session ingest followed in #4863. The +remaining consolidation work is tracked in +[`tinycortex-migration-plan-2026-07-22.md`](tinycortex-migration-plan-2026-07-22.md). **Anchor precedent:** the TinyAgents harness migration (#4249 / #4399 / #4473 and follow-ups). **Target:** migrate large portions of the OpenHuman memory subsystem onto the `tinycortex` crate, vendored as a git submodule at **`vendor/tinycortex`** (`https://github.com/tinyhumansai/tinycortex`). @@ -83,8 +86,8 @@ New sibling module mirroring `src/openhuman/tinyagents/`, holding every impl of - `chat.rs` — `impl ChatProvider` + `impl Summariser` over `memory::chat::build_chat_provider` / `inference::provider`. - `queue_driver.rs` — tokio worker loop calling `queue::run_once`/`drain_until_idle`, owned by `memory/global.rs`, with event-bus progress emission and Sentry hooks (host-side, since TinyCortex dropped its scheduler). - `config.rs` — `Config` → `MemoryConfig` (workspace roots, `EmbeddingConfig`, `TreeConfig`, `WeightProfile`, `SyncBudgetConfig`) with `tree_policy.rs` flavour overlays. -- `sinks.rs` — `TreeJobSink`, `SnapshotItemSource` impls bridging to host state. -- `bus.rs` — translate engine outcomes into `core::event_bus` `DomainEvent`s (host-side only; the crate stays bus-free). +- `ingest.rs` / `seal.rs` — `TreeJobSink`, seal observer, and host state bridges. +- `sync.rs` — sync persistence/policy plus translation into host `DomainEvent`s (the crate stays bus-free). - `mod.rs` — facade re-exports (`pub use tinycortex::memory::{…}`) so the rest of the host imports through one seam, plus module-doc explaining the boundary (the tinyagents seam's `mod.rs` header is the template). --- @@ -103,7 +106,7 @@ This phase produces documents and upstream issues only; it is the gate for every **0.5 Type-unification decision.** Host `memory::traits` types and crate types are wire-compatible twins. Decide: host re-exports crate types (preferred — one source of truth, 30+ consumer sites unchanged via `pub use`), vs. keeping host types + `From` conversions (fallback if serde/API divergence is found in 0.3). Special care: `MemoryTaint` is **security-critical provenance** (fails closed to `ExternalSync`, drives external-effect-tool gating) — its semantics, serde representation, and fail-closed defaults must be proven identical before re-exporting. -**0.6 Spec doc.** Write `docs/tinycortex-migration-spec.md` version-anchored to exact reviewed SHAs (host + tinycortex), with the ownership lists above, the drift/gap/parity ledgers, and a **deletion ledger** skeleton (every legacy file, with preconditions for deletion) — directly modeled on `docs/tinyagents-migration-spec.md` + `99-deletion-ledger.md`. +**0.6 Spec doc.** Write `docs/tinycortex-migration-spec.md` version-anchored to exact reviewed SHAs (host + tinycortex), with the ownership lists above, the drift/gap/parity ledgers, and a **deletion ledger** skeleton (every legacy file, with preconditions for deletion) — coordinated with `docs/tinyagents-migration-plan-2026-07-22.md`; the TinyCortex deletion ledger lives in §2 of the spec. --- @@ -146,7 +149,7 @@ W3–W5 are the risky core (user data on disk). W1–W2 can land quickly; W6–W Ordering rule for this migration: **within each workstream, the implementation (adapter + cutover + deletion) lands first; the test work follows as the second slice.** Tests are not skipped — they are sequenced after the implementation is proven to compile and pass the *existing* suites. Concretely: -1. **Slice A (impl):** adapters + cutover + legacy deletion. Gate: `cargo check` both worlds, existing crate-level integration tests (`tests/memory_roundtrip_e2e.rs`, `memory_tree_sync_deep_raw_coverage_e2e.rs`, etc.) still green — these exercise the public `openhuman::memory::` surface and act as the built-in parity harness for every flip. +1. **Slice A (impl):** adapters + cutover + legacy deletion. Gate: `cargo check` both worlds, existing crate-level integration tests (`tests/memory_roundtrip_e2e.rs`, `tests/raw_coverage/memory_tree_sync_deep_raw_coverage_e2e.rs`, etc.) still green — these exercise the public `openhuman::memory::` surface and act as the built-in parity harness for every flip. 2. **Slice B (tests):** port and extend the test estate for the new boundary: - **Engine-internal tests move upstream.** The host's `#[path]`-included sibling tests (`ops_tests.rs` pattern) that poke engine internals migrate into `vendor/tinycortex` as crate tests (via tinycortex PRs), following the crate's own `*_tests.rs` sibling convention. - **Host keeps boundary tests:** RPC schema/handler tests, tool gating tests, seam adapter tests (config mapping, embedding signature, taint fail-closed, source-scope enforcement), and the E2E files under `tests/`. diff --git a/docs/tinycortex-migration-plan-2026-07-22.md b/docs/tinycortex-migration-plan-2026-07-22.md new file mode 100644 index 0000000000..8cdb9912d6 --- /dev/null +++ b/docs/tinycortex-migration-plan-2026-07-22.md @@ -0,0 +1,417 @@ +# TinyCortex Migration — Current-State Audit & Consolidated Plan (2026-07-22) + +**Status:** **DONE** on `feat/tinycortex-migration-2026-07-22`. +[CI Full run 29925645209](https://github.com/senamakel/openhuman/actions/runs/29925645209) +is green, including the final gate and every Linux, macOS, and Windows desktop +shard +(companion to `tinyagents-migration-plan-2026-07-22.md`; +supersedes the *status/phasing* in `tinycortex-memory-migration-plan.md`, +`tinycortex-migration-spec.md`, `tinycortex-api-gap-audit.md`, and +`tinycortex-parity-checklist.md` — those docs' ownership splits, gap lists, and +parity dimensions remain the reference detail. `tinycortex-drift-ledger.md` +stays the row-level ledger but its anchors are stale — see §2). +**Scope:** finish moving the remaining generic memory-engine code from +`src/openhuman/` into the vendored `tinycortex` crate (`vendor/tinycortex`), +delete the in-tree duplicates and staging code, migrate/retire the affected +tests, and clean up every dangling doc/code reference. +**Method:** fresh four-way audit — (1) the crate surface, (2) the core memory +domains, (3) the periphery + the `src/openhuman/tinycortex/` seam, (4) the +docs/tests/git history — against the working tree at `main` (`5b8a9f269`, +2026-07-22). + +--- + +## 1. Executive summary + +**The engine migration is essentially done and the docs don't know it.** All +five `docs/tinycortex-*` docs self-describe a "W1–W2 landed; W3 partial" state, +but git shows the full W-track landed: W1 seam (#4526), W2 type re-export +(#4529), the W3 chunk-store cutover chain (#4532–#4559), W4 queue flip +(#4781), W5 vector+scoring (#4790), the W7 long-tail shims (#4785–#4789), +**engine-migration completion (#4794)**, **engine test port into the crate +(#4820)**, and post-completion feature work (persona/coding-session ingest +#4863) on top. The drift ledger's D1–D3 rows are all CLOSED. + +What remains is not "migrate the engine" but five bounded packages (§5): + +1. **Housekeeping** — the pin/tag story is broken (crate has *no git tags* + despite the Cargo.toml comment demanding tag-lockstep), the host CI vendor + suite omits the `persona` feature the host enables, and there are ~8 + broken/stale references including a seam module doc naming files that were + never created. +2. **Delete `memory_store/unified/`** — the explicitly "staging for removal" + tier (~5k LOC + ~3.7k test LOC), gated by the G1 `sqlite_conn()` escape + hatch. +3. **W-SYNC completion** — `memory_sync/` (17.7k LOC, the largest remaining + host mass) still runs the host provider pipelines while the crate now ships + a full `memory::sync` Composio engine; the D4 ledger rows gate the flip. +4. **Embedding-provider dedupe** — host `embeddings/` adapters and + `memory_tree/score/embed/` providers now duplicate what + `tinyagents::harness::embeddings` ships since tinyagents #58 (this package + lands in **tinyagents**, not tinycortex — the crates split the job). +5. **Shim retirement + funnel decision** — the compatibility shims left behind + by W3–W7, and a policy call on the fact that domains import + `tinycortex::memory::*` directly while the seam's re-export facade has + **zero** consumers. + +--- + +## 2. Ground truth: pins, versions, and discrepancies (fix first) + +| Fact | Value | Where | +| --- | --- | --- | +| Host requirement | `tinycortex = { version = "0.1", features = ["git-diff", "persona", "sync"] }` | root `Cargo.toml:116` | +| Path override | `[patch.crates-io] tinycortex = { path = "vendor/tinycortex" }` | `Cargo.toml:682` | +| Vendored crate | `0.1.1`, edition 2021, **MIT** (not GPL — unlike tinyagents), repo `tinyhumansai/tinycortex` | `vendor/tinycortex/Cargo.toml` | +| Submodule HEAD | `daaaf6ba` (`heads/main`, dependabot-era, ~50+ commits past every SHA the docs cite) | `git submodule status` | +| Tags | **none exist** — `git describe --tags` fails; yet host `Cargo.toml:115` says "Keep the version pin in lockstep with the submodule tag" | submodule | +| Docs' claimed pin | gitlink `a8e10f7` (exists in history, long since passed) | drift ledger / spec anchors | +| Nested dep | tinycortex requires `tinyagents = { version = "2", path = "vendor/tinyagents" }`, **its own nested submodule pinned at v2.0.0** | `vendor/tinycortex/Cargo.toml:69` | +| Host tinyagents req | `2.1` | root `Cargo.toml:107` | +| Fork patch | `[patch."https://github.com/senamakel/tinyagents"]` exists *for tinycortex's sake* ("temporarily pins the embedding API port while tinyagents #58 is pending" — #58 is now merged at the tinyagents submodule HEAD) | `Cargo.toml:685-691` | +| Host CI vendor suite | `cargo test --manifest-path vendor/tinycortex/Cargo.toml --features git-diff,sync` — **omits `persona`**, which the host enables | `.github/workflows/ci-lite.yml:859` | + +Version skew summary: three different tinyagents expectations (host `2.1`, +tinycortex `2`, nested submodule `2.0.0`) resolve only via path patches. The +tinyagents plan's WP-0 (bump `vendor/tinyagents` to v2.1.0) should be executed +together with bumping tinycortex's *nested* `vendor/tinyagents` so both crates +share one trait identity at one version, after which the `senamakel` patch +block and its "temporarily pins" comment can likely be retired. + +--- + +## 3. What the crate already ships + +`tinycortex 0.1.1` exposes a single `memory` module — the engine "ported from +OpenHuman" — with features `tokio`, `git-diff` (git2-backed `memory::diff`), +`providers-http` (reqwest embedding/LLM providers, host does **not** enable), +`sync` (live Composio + workspace-scan engine), `persona` (doc-06 persona +distillation). Modules: `store` (content/vectors/kv/entity_index/safety+PII), +`chunks`, `sources` (+readers), `score` (embed/extract/signals), `tree` +(+runtime, bucket/document seal, flavoured trees), `queue` (SQLite job queue + +tokio runtime), `retrieval` (hybrid search, MMR, rerank), `entities`, `graph`, +`goals`, `tool_memory`, `conversations`, `archivist`, `ingest` +(canonicalize→chunk→score→tree), `diff`, `sync` (composio +clickup/github/linear/notion/slack/gmail), `providers`, `persona` +(readers for claude_code/codex/git history), `fsutil`. + +Host-facing extension points (~20 public traits) with the seam's current +implementations in parentheses: `Memory`, `MemoryStore`, `EmbeddingBackend` + +`Embedder` (`SeamEmbedder`), `ChatProvider` (`SeamChatProvider`), `Summariser` +(`HostSummariser`), `SealObserver` + seal-time `Embedder` bridge (`seal.rs`), +`TreeJobSink` (`HostTreeJobSink`), `QueueDelegates` (`HostQueueDelegates`), +`SourceReader`, `SelfIdentity`, `EntityOccurrenceIndex`, `SnapshotItemSource`, +`GoalsGenerator`, `ConversationEventBus`/`ChannelEventHandler`, +`PersonaStateStore`, and the sync seams `SyncStateStore` / `SyncEventSink` / +`SkillDocSink` / `LocalDocumentSink` / `ExternalSourceReader` / `SyncPipeline` +/ `ActionExecutor` / `IncrementalSource` (`sync.rs`, `HostSyncAdapter`). + +The crate consumes `tinyagents::harness::embeddings::{EmbeddingModel, +format_embedding_signature}` in `store/vectors/embedding.rs` — that is the +whole tinyagents dependency (the W-EMB decision made real by tinyagents #58). + +Crate CI: fmt, clippy `-D warnings`, build+test `--all-features`, doc build +with warnings denied, plus a per-feature matrix (`core`, `tokio`, `git-diff`, +`sync`, all). Offline by convention; Composio/live tests are wiremock-mocked or +env-key-gated. + +--- + +## 4. Audit: what remains host-side + +### 4.1 Per-domain state (crate-backed file counts via `use tinycortex::`) + +| Domain | Files / LOC | Crate-backed | State | +| --- | --- | --- | --- | +| `memory/` | 69 / 18.0k | 3 | Orchestration + policy + RPC layer (by design "no SQLite here"). Predominantly host: `read_rpc/`, `schema*/`, `rpc_models`, agent tools, sync orchestration, tree *policy*. Stays, minus shim cleanup. | +| `memory_store/` | 54 / 17.7k | 15 | Sub-stores flipped to crate (chunks, content, vectors, kv, entities, trees, safety/PII per W3/W7 markers). **`unified/` (~5k + ~3.7k tests) is README-marked "Staging for removal"** — documents/query/segments/events/profile await per-kind flips. `factories.rs` (config/provider selection) stays host. | +| `memory_tree/` | 47 / 10.3k | 16 | Largest crate-backed surface; engine parts delegate. Host keeps RPC (`tree/rpc.rs` 1,387, `retrieval/rpc.rs`), CLI, bus subscriber, `health/` doctor (G5, host-retained by decision), and `score/embed/` **providers** (~1.9k — see WP-3). | +| `memory_queue/` | 7 / 1.4k | 2 | W4 flipped: `worker.rs` delegates claim→dispatch→settle to `tinycortex::memory::queue::run_once` via `HostQueueDelegates`; per-kind handlers deleted. Host keeps worker pool, scheduler tick, `scheduler_gate` throttle, SQLite glue. Done, minus the `memory::jobs` re-export shim. | +| `memory_search/` | 8 / 0.8k | 2 | W5 flipped: `scoring.rs`/`vector/mmr.rs` are near-empty shims over crate `WeightProfile`/`retrieval::mmr`. Host keeps the three agent tools. Done. | +| `memory_tools/` | 9 / 1.4k | 3 | W7 flipped: types/store/prompt are shims over `tool_memory::{types,store,render}`. Host keeps `capture.rs` (`PostTurnHook`) + put/list tools. Done. | +| `memory_archivist/` | 1 / 44 | 1 | Pure W7 shim over `memory::archivist`. Done. | +| `memory_conversations/` | 2 / 0.9k | 2 | W7 shim over `memory::conversations`; host keeps `bus.rs` event-bus glue (+ a `tinychannels` legacy-message conversion). Done. | +| `memory_diff/` | 7 / 2.0k | 11 uses | W7 flipped: engine crate-owned; host keeps RPC/tools + `DomainEvent::MemoryDiff*` publishing. Done. | +| `memory_goals/` | 6 / 0.9k | 4 uses | W7 flipped: types/store crate-owned (byte-identical `MEMORY_GOALS.md`); host keeps agent wiring. Done. | +| `memory_sources/` | 16 / 5.0k | 12 uses | Product registry (config-persisted connectors) with crate readers underneath. Host by design. | +| `memory_sync/` | **73 / 17.7k** | 5 uses | **The largest remaining mass.** Host Composio provider pipelines (gmail/slack/github/notion/linear/clickup) still run host-side while the crate's `memory::sync` ships the same provider engine. W-SYNC seam (`sync.rs`, 679) is landed; the host flip (W-SYNC.3) has not happened. See WP-2. | +| `embeddings/` | 13 / 3.1k | **0** | Bridges **tinyagents** (9 files), not tinycortex. Post-#58, the host per-provider adapters duplicate crate `harness::embeddings` providers. See WP-4. | +| `agent_memory/`, `subconscious/` (memory parts), `learning/` (memory parts) | — | 0 | Host product consumers of the memory stack; no crate duplication. Stay. | + +Consumer counts (host memory tiers are deeply embedded and *stay* host, so +these are context, not deletion blockers): `memory::` 166 files, +`memory_store::` 132, `memory_tree::` 67, `memory_queue::` 18, +`memory_tools::` 11, `memory_search::` 3. + +### 4.2 The seam — `src/openhuman/tinycortex/` (11 files, ~3.2k LOC) + +`config.rs`, `embeddings.rs`, `chat.rs`, `summariser.rs`, `ingest.rs`, +`seal.rs`, `sync.rs` (679), `queue_driver.rs` (1,007), `persona.rs` (446), +`parity.rs` (`#[cfg(test)]` format pins), `mod.rs`. Healthy and intentionally +host-owned. Two findings: + +- **The seam is not the funnel.** `mod.rs` re-exports crate types + (`MemoryEntry`, `MemoryTaint`, `RecallOpts`, …) as the intended + type-unification funnel, but **zero** files import `crate::…::tinycortex::` + re-exports — every memory domain imports `tinycortex::memory::*` directly. + Either convention works; pick one and record it (WP-5). Direct crate imports + are simpler and match how `tinyagents` is consumed; if so, shrink the facade + instead of promoting it. +- **Stale internal doc:** `mod.rs:28-30` names sibling adapters `sinks.rs` and + `bus.rs` that were never created (their roles landed as + `ingest.rs`/`seal.rs`/`sync.rs`). `queue_driver.rs:21-26` still says "This + brick is additive: nothing is flipped yet" — the flip landed in #4781. + +Coupling is bidirectional by design (seam reaches back into +`memory_store`/`memory_tree`/`memory_sync` to do host work inside crate +callbacks); not a defect, but `queue_driver.rs`'s 11 host imports will thin as +WP-1/WP-3 shims retire. + +### 4.3 Broken/dangling references (resolved in WP-0) + +| Resolved issue | Resolution | +| --- | --- | +| Missing TinyAgents companion-spec link | Replaced with the existing July 22 migration plan. | +| Missing standalone deletion-ledger link | Replaced with §2 of the TinyCortex migration spec. | +| Wrong agent-harness documentation path | Pointed at the architecture subdirectory. | +| Never-landed golden-workspace generator | Removed after the post-cutover parity decision descoped synthetic pre-cutover fixtures. | +| Wrong raw-coverage test path | Corrected to the `tests/raw_coverage/` location. | +| Stale line anchors `Cargo.toml:82`, `src/openhuman/mod.rs:130` | `tinycortex-migration-spec.md` §0.4 (actual: `Cargo.toml:116`, `mod.rs:140`) | +| Never-created seam sibling names | Replaced with the adapters that actually landed. | +| Developer-specific absolute path in crate docs | Replaced with repository-relative paths. | + +Also stale-in-place: all five docs' status headers (pre-#4794), the drift +ledger's pin anchors, and the crate migration doc's claim that "TinyCortex +will not own memory sync" (superseded by the plan's §8 sync-inclusive +amendment and the crate's shipped `sync` module — the *scheduler/credentials/ +RPC* stay host, the *engine* is crate; restate the boundary in one place). + +### 4.4 Test inventory + +- **In-crate (done):** engine coverage was ported by #4820; the crate runs its + own suite offline (wiremock Composio mock; live tests env-gated). W8's CI + wiring exists: `ci-lite.yml:859` runs the vendor suite on submodule-pointer + changes — **but with `--features git-diff,sync` only; `persona` is untested + in host CI while enabled in the host build.** +- **Dies with WP-1:** `memory_store/unified/*_tests.rs` (~3.7k LOC: + `documents_tests` 1,275, `query_tests` 1,000, `profile_tests` 763, + `segments_tests` 393, `events_tests` 287) go with the unified tier; port any + still-unique assertions to the per-kind crate backends first. +- **Retargets with WP-2:** the memory_sync suites (389 inline tests across 35 + files + provider `*_tests.rs`: github 655, gmail 301+354, slack 180, linear + 172, clickup 155, notion 119) — post-flip, provider *engine* behavior is + crate-tested; host keeps orchestration/bus/config tests. Integration: + `memory_sync_pipeline_e2e.rs` (570), `memory_golden_parity_e2e.rs` (275), + `raw_coverage/memory_sync_*` retarget to the crate-backed path. +- **Golden parity harness:** comparators 1 & 5 green; 2/3/4 TODO; + `frontmatter_parity` Layer-1 asserter pending; fixture generator script + missing. Decide finish-vs-descope in WP-0 (post-cutover, parity pins matter + mainly for `MemoryTaint`/on-disk stability — keep those, consider dropping + the rest). +- **Stays host:** `json_rpc_e2e.rs` memory sections, `memory_sources_e2e` + (837), `memory_tree_summarizer_e2e` (583), `memory_roundtrip_e2e`, + `memory_graph_sync_e2e`, `memory_fast_retrieve_e2e`, `autocomplete_memory_e2e`, + `memory_artifacts_e2e`, the 15 `raw_coverage/memory_*` modules, and all + subconscious/learning suites. + +--- + +## 5. The plan — five work packages + +Same conventions as the tinyagents companion plan: WP-0 first (hygiene, +unblocks honest review), then largely independent packages; drift-ledger rows +before deletions; failing-before/passing-after regression tests; small +validated commits on feature branches per submodule. + +### WP-0 — Version/tag + CI + docs housekeeping (no engine behavior change) + +1. **Establish the tag discipline the Cargo comment promises:** tag + `vendor/tinycortex` (e.g. `v0.1.1` at the current release point, or cut + `v0.2.0` at HEAD), and record the bump workflow the plan prescribed + (`chore(vendor): bump tinycortex …` — zero such commits exist in host + history). Alternatively amend the comment to describe reality + (main-tracking submodule). Either is fine; the current state (comment + demands tags, none exist) is the worst option. +2. **Align the nested tinyagents:** when the tinyagents plan's WP-0 bumps + `vendor/tinyagents` to v2.1.0, bump `vendor/tinycortex/vendor/tinyagents` + in lockstep and update tinycortex's `tinyagents = "2"` if needed; then + evaluate retiring the `[patch."…senamakel/tinyagents"]` block and its + obsolete "while tinyagents #58 is pending" comment (#58 is merged). +3. **Close the persona CI hole:** add `persona` to the vendor-suite features + in `.github/workflows/ci-lite.yml:859` (and confirm the crate's own + feature-matrix covers `persona`, which today it does not list either). +4. **Docs refresh:** update the five docs' status headers to post-#4794/#4820 + reality; update drift-ledger anchors (pin `daaaf6ba`+tag, note #4794/#4820/ + #4863); document the `persona` feature + seam (currently invisible in all + five docs); restate the sync ownership boundary once (engine=crate, + scheduler/credentials/RPC/bus=host) and fix the crate-side migration doc's + contradictory "will not own sync" line + its absolute local path. +5. **Fix the §4.3 broken references**, including the two code-adjacent ones: + correct the seam module inventory and the queue-driver's stale pre-flip + description. +6. **Golden-parity decision:** finish comparators 2/3/4 + `frontmatter_parity` + + the fixture script, **or** descope to the security-relevant pins + (`MemoryTaint` byte-identity, chunk/vector on-disk format) with the + decision recorded in the parity checklist. + +**Exit:** pins/tags/CI/docs agree with the tree; `grep` for the phantom paths +comes back clean. + +### WP-1 — Retire `memory_store/unified/` (the "staging for removal" tier) + +1. Inventory the five remaining unified facets (documents, query, segments, + events, profile — ~5k LOC) against their per-kind crate backends; flip + callers facet-by-facet (the W3 sub-store pattern, already proven for + chunks/content/vectors/kv/entities/trees/safety). +2. The blocker is **G1** (`sqlite_conn()` escape hatch, ~312 refs at audit + time): re-audit the current count, then either land the crate-side access + the gap audit sketched or explicitly re-scope G1 to the host-retained + namespace-document tables and record it. +3. Decide the fate of the 10-table host-retained `UnifiedMemory` + namespace-document tier: keep host (spec's standing decision) but move it + out of `unified/` into a clearly-named home so "staging for removal" can + actually be removed. +4. Tests: port unique assertions from the ~3.7k LOC of `unified/*_tests.rs` + to per-kind backends (crate-side where engine behavior, host-side where + glue), then delete with the code. + +**Exit:** `memory_store/unified/` gone or reduced to the renamed host tier; +`memory_store/` ≈ 9–10k LOC of glue + host tier; `memory_roundtrip_e2e` + +`memory_golden_parity_e2e` + full mock-suite green. + +### WP-2 — W-SYNC.3: flip `memory_sync/` onto the crate sync engine + +The seam (`sync.rs`: `ExternalSourceReader`, `SkillDocSink`, +`LocalDocumentSink`, `SyncStateStore`, `SyncEventSink`) is landed; the crate +ships the Composio engine incl. per-provider modules. Remaining: + +1. Close the **D4.1–D4.4** drift rows (ledger requires this before the flip). +2. Flip provider-by-provider (gmail first — the crate already carries the + Gmail 413-page fix #73), keeping host-side: scheduler/periodic trigger, + credentials, `config.toml` source registry (`memory_sources/`), event-bus + publishing (`ChannelMessageReceived` et al.), RPC/status surface, and + redaction/`source_scope` gating. +3. Dedupe post-processing: host `providers/*/post_process*` vs crate provider + modules — upstream generic normalization, keep host product policy. +4. Tests per §4.4: engine behavior moves to crate suites (wiremock Composio), + host keeps orchestration/bus/e2e; `memory_sync_pipeline_e2e` retargets. + +**Exit:** `memory_sync/` shrinks from 17.7k toward ~6–8k LOC of +orchestration/product glue; D4 rows CLOSED; no duplicated provider parsing. + +### WP-3 — Embedding-provider dedupe (lands in *tinyagents*, coordinated here) + +Two host clusters now duplicate `tinyagents::harness::embeddings` (post-#58: +openai/cohere/voyage/ollama/cloud + rate-limit/retry-after): + +1. `src/openhuman/embeddings/` adapters (`openai/voyage/cohere/ollama/ + cloud_adapter.rs`, `provider_trait.rs`) → construct crate + `EmbeddingModel`s directly; host keeps `rpc.rs` (1,479), `schemas.rs`, + `catalog.rs`, `factory.rs` (config/BYOK selection, #4056 dimension + gating). +2. `memory_tree/score/embed/{openai_compat,ollama,cloud,factory}.rs` (~1.9k) + → same crate models via the seam's `SeamEmbedder`/`EmbedderBridge`. + +Because tinycortex consumes the same `EmbeddingModel` trait, this single +change serves both crates — it is the concrete payoff of the W-EMB decision. +Sequence after WP-0's tinyagents version alignment. + +**Exit:** one embedding-provider implementation per provider across the +workspace; `embeddings_rpc_e2e` (822) + `ollama_embeddings_fallback_e2e` (234) +green against the crate-backed path. + +### WP-4 — Shim retirement + funnel decision + +1. **Funnel policy:** adopt direct `tinycortex::memory::*` imports as the + convention (matches reality and the tinyagents precedent), shrink the seam + `mod.rs` facade to the types host code genuinely re-brands + (`MemoryTaint` stays pinned by parity tests) — or the reverse; either way, + record it in the spec and stop carrying both. +2. Retire compatibility shims whose consumers can move: `memory_tree/tools.rs` + (points at `memory`), `memory_store/trees` legacy paths, the + `memory_queue`→`memory::jobs` re-export, `memory_archivist` (44-line shim — + move its 2 call sites and delete the domain), and the W5/W7 near-empty + shims once import counts hit zero (`memory_search/scoring.rs`, + `vector/mmr.rs`, `memory_tools/{types,store}.rs`). +3. Thin `queue_driver.rs` (1,007) as WP-1 removes its `memory_store` + reach-backs. + +**Exit:** shim count measurably down (each deletion = one commit with its +import-migration); seam ≤ ~2.5k LOC; no module whose entire body is a +re-export except deliberate facades recorded in the spec. + +### WP-5 — Exit gate + +- Full suite: `scripts/test-rust-with-mock.sh` (incl. the 15 + `raw_coverage/memory_*` modules), vendor suite + `cargo test --manifest-path vendor/tinycortex/Cargo.toml --features + git-diff,sync,persona`, slim disabled build (`--no-default-features + --features tokenjuice-treesitter` — memory domains are ungated but the + standing repo rule applies), `pnpm rust:check`. +- Drift ledger: D4 CLOSED; new rows for every WP-1/WP-2 deletion; the spec's + §2 deletion-ledger skeleton filled in with actuals. +- Docs: the five tinycortex docs + `src/openhuman/memory*/README.md` files + + `gitbooks/developing/architecture.md` reflect the post-flip reality; this + plan stamped done. + +### Execution record (2026-07-22) + +| Package | Result | +| --- | --- | +| WP-0 | TinyAgents unified at 2.1 across OpenHuman, TinyCortex, and TinyFlows; obsolete fork patch removed; TinyCortex CI covers `persona`; docs and phantom references repaired. TinyCortex intentionally tracks reviewed upstream commits rather than nonexistent tags. | +| WP-1 | G1 count is zero. The ten-table namespace/document product tier is host-owned, so the complete `unified/` directory was renamed to `namespace_store/` instead of deleted as engine duplication. Its 153 targeted tests pass. | +| WP-2 | The live default path was already TinyCortex-backed. D4 is CLOSED; dead Gmail duplicate removed; remaining provider task/profile projections explicitly classified as product policy. Provider tests pass (301). | +| WP-3 | Concrete provider transports deduplicated into TinyAgents. The memory tree now uses a thin `ProviderEmbedder`; Ollama's 8k context/batch and missing-model guidance moved upstream before the host client was deleted. TinyAgents embedding tests pass (38); host library check passes. | +| WP-4 | Archivist, search scoring/MMR, tool-memory type/store, tree-tool, jobs-alias, and unused seam type facades retired. Direct crate imports are canonical. Seam production code is 2,229 LOC (below the 2.5k exit target). | +| WP-5 | Local focused validation is recorded above; the slim `--no-default-features --features tokenjuice-treesitter` build also passes. [CI Full run 29925645209](https://github.com/senamakel/openhuman/actions/runs/29925645209) is green: core quality and full tests, TinyCortex, Tauri, frontend, mock-backend Rust E2E, Playwright, three desktop builds, every launched desktop shard, and the final gate. Two first-attempt Linux jobs ended without uploaded logs; rerunning only failed jobs passed the Linux build, Rust integration suite, and all eight Linux shards. | + +--- + +## 6. Risks and standing gotchas + +- **`MemoryTaint` is security-critical.** The type is proven byte-identical + host↔crate and fails closed to `ExternalSync`; the parity pins in + `tinycortex/parity.rs` are the regression guard — whatever WP-0 decides + about the golden harness, **keep these**. +- **Redaction and `source_scope` gating stay host.** WP-2 must not let crate + sync code become a path around host redaction or the source allowlist — + review the `SyncEventSink`/`LocalDocumentSink` implementations for every + provider flipped. +- **On-disk compat:** chunk/vector formats, `MEMORY_GOALS.md` byte-identity, + and the P5 fresh-DB divergence (3 legacy inline-embedding columns in + `mem_tree_chunks`) — migrations of user data must be forward-only and + tested against a pre-migration workspace fixture. +- **MIT vs GPL:** tinycortex is MIT (tinyagents is GPL-3.0) — moving host code + *down* into tinycortex relicenses it; fine for our own code, but keep the + "no product policy / no keys / nothing openhuman-branded as API" rule, and + don't move anything derived from GPL-only sources into the MIT crate. +- **Two Cargo worlds + nested submodule:** vendor bumps now touch up to three + lockfiles (root, `app/src-tauri`, and tinycortex's own) and two submodule + pointers (`vendor/tinycortex`, `vendor/tinycortex/vendor/tinyagents`). CI + clones with `submodules: recursive` for the vendor suite — verify after any + bump. +- **Event-bus surface:** `memory_sync` publishes 7 distinct `DomainEvent` + variants; the WP-2 flip must keep publishing them from host callbacks (the + crate has no event bus by design). + +--- + +## 7. Quick reference — disposition of every audited module + +| Module | Verdict | +| --- | --- | +| `memory_store/unified/` | DELETE after per-kind flips; host tier re-homed (WP-1) | +| `memory_store/` sub-stores, `factories.rs` | DONE (crate-backed) / STAYS (host glue) | +| `memory_sync/` provider engines | FLIP to crate `memory::sync` (WP-2) | +| `memory_sync/` scheduler, bus, RPC, credentials | STAYS | +| `embeddings/` per-provider adapters | REPLACE with `tinyagents::harness::embeddings` models (WP-3) | +| `embeddings/` rpc/schemas/catalog/factory | STAYS | +| `memory_tree/score/embed/` providers | REPLACE via seam + crate models (WP-3) | +| `memory_tree/` engine files | DONE (crate-backed); RPC/CLI/bus/health STAY | +| `memory_queue/`, `memory_search/`, `memory_tools/`, `memory_diff/`, `memory_goals/`, `memory_conversations/` | DONE — retire residual shims when import counts reach zero (WP-4) | +| `memory_archivist/` | DELETE the 44-line shim after moving 2 call sites (WP-4) | +| `memory/` (orchestration/RPC/tools/tree-policy) | STAYS | +| `memory_sources/` | STAYS (product registry over crate readers) | +| `agent_memory/`, `subconscious/`, `learning/` | STAYS (consumers, no duplication) | +| Seam `src/openhuman/tinycortex/` | STAYS, shrinks; fix stale module docs (WP-0), thin `queue_driver.rs` (WP-4) | +| Golden-parity harness | FINISH or DESCOPE by decision (WP-0); `MemoryTaint`/format pins KEEP | diff --git a/docs/tinycortex-migration-spec.md b/docs/tinycortex-migration-spec.md index 0b8711438c..7bb8c8298b 100644 --- a/docs/tinycortex-migration-spec.md +++ b/docs/tinycortex-migration-spec.md @@ -1,11 +1,10 @@ # TinyCortex Memory Migration — Spec (Phase 0.5 / 0.6) -**Status:** Phase 0 baseline **+ execution underway** — #59 (native-dep alignment) merged and the dep -is active (§0.4); W1 seam + W2 type re-export + W3-chunks partial landed; **drift closure COMPLETE** -(**D3** via #59, **D2** via tinycortex#63, **D1** via tinycortex#64; gitlink `a8e10f7`) — so **W4 -(queue) and W7 (conversations) are unblocked**. Anchors the migration to exact reviewed SHAs and ties -together the drift, gap, and parity ledgers. Modeled on `docs/tinyagents-migration-spec.md` + its -deletion ledger. +**Status:** Post-engine-cutover reference. W1–W8 and the crate-owned engine test +port landed in OpenHuman #4794/#4820, with persona/coding-session ingest in +#4863. The remaining host consolidation is tracked by +[`tinycortex-migration-plan-2026-07-22.md`](tinycortex-migration-plan-2026-07-22.md). +This document retains the ownership contract and deletion ledger detail. **Companion plan:** [`tinycortex-memory-migration-plan.md`](tinycortex-memory-migration-plan.md) **Ledgers:** [`tinycortex-drift-ledger.md`](tinycortex-drift-ledger.md) · @@ -19,7 +18,10 @@ deletion ledger. | `tinyhumansai/openhuman` | `7850cf363559bcbb7ba688cbc4fccdb6bd9ce754` | host audit base (`main`, 2026-07-04) | | `tinyhumansai/tinycortex` | `d1a8c7be2babc8fff7a72ed93861f459f3d6fa58` | crate audit base (v0.1.1) | | `tinyhumansai/tinycortex` | `33dda943053e61ef585fc39647cf1854344b6323` | audit base **+ #59** (native-dep alignment, §0.4) merged | -| `tinyhumansai/tinycortex` | `a8e10f7dd8ebdb9b0905e1380fefcc6bf5a65207` | **current pinned gitlink** — **+ #63/#64** (D2/D1 drift closure) merged; all drift rows CLOSED | +| `tinyhumansai/tinycortex` | `a8e10f7dd8ebdb9b0905e1380fefcc6bf5a65207` | historical cutover gitlink — **+ #63/#64** (D2/D1 drift closure) merged | +| `tinyhumansai/openhuman` | `5b8a9f269` | 2026-07-22 consolidation audit base | +| `tinyhumansai/tinycortex` | `daaaf6ba5f02635c08deae2b2b2ed7fcc8c06b6a` | 2026-07-22 reviewed gitlink; no upstream tags exist, so the host tracks reviewed main commits | +| `tinyhumansai/tinycortex` | `7b4b115` | current migration branch: TinyAgents 2.1 alignment + corrected ownership docs | Port line (derived by content, §0.1): **after 2026-06-25, before 2026-06-28** for engine features. @@ -72,14 +74,14 @@ ledger. Re-export covers the data types (`MemoryEntry`, `MemoryCategory`, `Memor | **rusqlite alignment** | ✅ **resolved & merged (#59).** Crate was pinned `0.32` (bundled), host pins `=0.40.0` (bundled). Two `links = "sqlite3"` = hard Cargo error. Fixed in #59 (bump to `0.40` + `usize`→`i64`/`try_from` — the same sweep that closed drift **D3**). | | **git2 alignment** | ✅ **resolved & merged (#59).** Crate was pinned `0.19`, host `0.21` (vendored-libgit2). Two `links = "git2"`. Fixed in #59 (bump to `0.21` + API deltas: `Tag::message`, `StringArray::Iter`, `Buf::as_str`). | | Crate compiles with aligned deps | ✅ `cargo check --all-targets` clean; 38 diff/checkpoint tests pass. | -| **Host root world compiles with dep active** | ✅ `cargo check --manifest-path Cargo.toml --lib` **exit 0** with `tinycortex = "0.1"` active (`Cargo.toml:82`) + submodule at `33dda94`. **No `multiple packages link to native library` error** — one bundled SQLite + one libgit2 confirmed. **Now landed** (post-#59-merge): the `[dependencies]` line and gitlink are committed on the working branch, not reverted. Re-verified 2026-07-09. | +| **Host root world compiles with dep active** | ✅ `cargo check --manifest-path Cargo.toml --lib` **exit 0** with `tinycortex = "0.1"` active (`Cargo.toml:116`) + submodule at `33dda94`. **No `multiple packages link to native library` error** — one bundled SQLite + one libgit2 confirmed. **Now landed** (post-#59-merge): the `[dependencies]` line and gitlink are committed on the working branch, not reverted. Re-verified 2026-07-09. | | Host `app/src-tauri` world | to verify in W1 (separate Cargo world / lockfile). | | `GGML_NATIVE=OFF` macOS ARM | to verify on a macOS runner in W1 (no macOS host here). | **Activation landed (post-#59).** The native-dep alignment merged upstream as **#59** and the host gitlink was bumped to `33dda94`, so — per the submodule rule (bump only to a **merged** SHA) — W1's activation is now in place: `[dependencies] tinycortex = "0.1"` is active in the root world -(`Cargo.toml:82`), the seam (`src/openhuman/tinycortex/`) is wired (`src/openhuman/mod.rs:130`), and +(`Cargo.toml:116`), the seam (`src/openhuman/tinycortex/`) is wired (`src/openhuman/mod.rs:140`), and `cargo check --lib` is **exit 0**. Remaining §0.4 follow-ups: the `app/src-tauri` world and the `GGML_NATIVE=OFF` macOS-runner check still verify in W1 (see rows above). @@ -117,7 +119,7 @@ activation is now in place: `[dependencies] tinycortex = "0.1"` is active in the coexist in the shared DB but **do not move**: `memory_docs`, `graph_global`, `graph_namespace`, `episodic_log` (+ `episodic_fts` + triggers), `event_log` (+ `event_fts`, `event_embeddings`, triggers), `conversation_segments`, `segment_embeddings`, `vector_chunks`, `user_profile`. - These live in `memory_store/unified/{init,fts5,events,segments,profile}.rs` and remain host — the + These live in `memory_store/namespace_store/{init,fts5,events,segments,profile}.rs` and remain host — the crate is the **primitive substrate**, not a drop-in for the whole DB. - **Content-store host surfaces the crate explicitly excludes:** `content::wiki_git`, `content::obsidian`, `content::obsidian_registry`. @@ -128,8 +130,11 @@ activation is now in place: `[dependencies] tinycortex = "0.1"` is active in the `embeddings.rs` (`EmbeddingBackend`/`Embedder`), `chat.rs` (`ChatProvider`/`Summariser`×2/ `EntityExtractor`/`GoalsGenerator`), `queue_driver.rs` (`QueueDelegates` + tokio worker loop + Sentry/bus), `config.rs` (`Config`→`MemoryConfig`), `sinks.rs` (`TreeJobSink`/`TreeLeafSink`/ -`SnapshotItemSource`/`EntityOccurrenceIndex`), `bus.rs` (engine outcomes → `DomainEvent`), -`mod.rs` (facade re-exports + boundary doc). All 17 W1 seam traits confirmed present (§0.2). +`SnapshotItemSource`/`EntityOccurrenceIndex`), `sync.rs` (sync outcomes → `DomainEvent`), +`mod.rs` (adapter namespace + compatibility re-exports + boundary doc). New +engine consumers import `tinycortex::memory::*` directly; the seam owns +implementations rather than serving as a second type funnel. All 17 W1 seam +traits confirmed present (§0.2). **Later seam additions (amended 2026-07-09):** @@ -144,7 +149,7 @@ Sentry/bus), `config.rs` (`Config`→`MemoryConfig`), `sinks.rs` (`TreeJobSink`/ --- -## 2. Deletion ledger (skeleton) +## 2. Deletion ledger Every legacy engine file is deleted only when its module's **drift row is closed**, its **gaps are resolved**, and the **golden-workspace parity harness is green** for its flip. Counts from the host @@ -152,7 +157,7 @@ audit SHA. | Legacy module | Files (test files) | Deletes in | Preconditions | | --- | --- | --- | --- | -| `memory_store/` | 66 (11) | W3 | drift **D3** closed; gap **G1** (escape hatch) migrated to `with_connection`; parity P3/P5/P11/P12 green; `unified/` tier re-homed as host-retained (kept, not deleted) | +| `memory_store/` | 66 (11) | W3/WP-1 | drift **D3** closed; gap **G1** migrated completely (zero `sqlite_conn()` call sites at the 2026-07-22 audit); parity P3/P5/P11/P12 green; namespace tier re-homed to `namespace_store/` as host-retained | | `memory_tree/` | 65 (7) | W5 | gaps **G3** (seal-embed), **G6** (2× Summariser) resolved; `source_scope` allowlist re-verified; parity P7/P11 green; `health/` + `tree_policy.rs` kept host (G5) | | `memory_queue/` | 10 (1) | W4 | drift **D2** closed (predicate upstreamed); job payload_json parity (P4/P9); host worker loop + Sentry/degraded wiring kept host | | `memory_conversations/` | 7 (1) | W7 | drift **D1** closed; `bus.rs` kept host | @@ -168,10 +173,19 @@ audit SHA. | `memory_search/` | 8 (0) | W5 | `vector`/`scoring` → crate `retrieval`/`score`; `tools/` kept host | | `memory/ingest_pipeline.rs` internals | (thin entry points kept) | W6 | `ingest_chat`/`ingest_document_with_scope` signatures unchanged; 11 call sites untouched | +### 2026-07-22 consolidation actuals + +| Package | Actual result | +| --- | --- | +| WP-1 namespace tier | `memory_store/unified/` removed and re-homed byte-for-byte as `memory_store/namespace_store/`; G1 re-audit found zero `sqlite_conn()` call sites. The retained ten-table product store explains why total `memory_store` remains 17.7k LOC rather than the plan's speculative 9–10k target. | +| WP-2 sync | The default provider `sync()` and `run_connection_sync` already call the TinyCortex engine. Deleted the dead host Gmail sync parser; renamed GitHub/Notion/Linear/ClickUp product projections from `sync.rs` to `normalization.rs`. D4.1-D4.4 are CLOSED. The retained 17.2k LOC is schedulers, bus/RPC, action tools/catalogs, credentials, profiles, post-processing, and product task projections; 6.9k LOC is provider catalogs/tools/normalization/profile/RPC alone. | +| WP-3 embeddings | Deleted host OpenAI, Cohere, Voyage, general Ollama, memory-tree cloud, and memory-tree Ollama provider implementations (746 production LOC) plus their obsolete 828-line raw-coverage suite. Provider transport now has one implementation in TinyAgents; OpenHuman retains selection and credential/privacy adapters. | +| WP-4 shims | Deleted `memory_archivist`, `memory_search::{scoring,vector}`, `memory_tools::{types,store}`, `memory_tree::tools`, and the `memory::jobs` alias. Removed the unused TinyCortex type facade; direct `tinycortex::memory::*` imports are the convention. The seam is 2,229 pre-test LOC. | + **Kept host (never deleted):** `memory/{ops,schemas,schema,read_rpc,tools,query,tree_source, ingestion,util}`, `memory/{global,source_scope,chat,sync,preferences,remember,tree_policy,rpc_models, traits(→re-exports)}.rs`, `memory_sync/`'s host-retained shell only (schedulers `periodic.rs`, -`bus.rs` subscribers, RPC registration — the engine moves in W-SYNC, plan §8), `memory_store/unified/*` (the namespace-document +`bus.rs` subscribers, RPC registration — the engine moves in W-SYNC, plan §8), `memory_store/namespace_store/*` (the namespace-document tier), `memory_store/content/{wiki_git,obsidian,obsidian_registry}`, `memory_tree/health/`, and the new `src/openhuman/tinycortex/` seam. diff --git a/docs/tinycortex-parity-checklist.md b/docs/tinycortex-parity-checklist.md index bd91c65684..e1af5f08da 100644 --- a/docs/tinycortex-parity-checklist.md +++ b/docs/tinycortex-parity-checklist.md @@ -1,5 +1,13 @@ # TinyCortex Data-Format Parity Checklist (Phase 0.3) +**Status (2026-07-22):** The engine cutover and crate test port are complete. +The proposed pre-migration golden-fixture generator and differential +comparators 2–4 are deliberately **descoped**: there is no trustworthy +pre-cutover binary/fixture left to generate them from after cutover. Retained +gates are the security-critical `MemoryTaint` byte/serde pins, deterministic +chunk IDs, vector encoding/signature, content paths, schema composition, +idempotent reopen, and the host public-surface E2E suites. + **Purpose.** Existing user workspaces must open **unchanged** after every cutover flip. This checklist enumerates every on-disk format shared between the host engine and TinyCortex, records the audit result, and specifies the **golden-workspace parity harness** that gates W3, W5, W6. @@ -7,7 +15,8 @@ records the audit result, and specifies the **golden-workspace parity harness** **Hard rule (plan §0.3/§6):** any mismatch is fixed **upstream in tinycortex**, never papered over with a host shim. -Anchors: host `7850cf363` · tinycortex `d1a8c7be` (v0.1.1). +Anchors: historical audit host `7850cf363` · crate `d1a8c7be`; consolidation +base host `5b8a9f269` · reviewed crate `daaaf6ba` · migration branch `7b4b115`. ## Ownership tiers in the shared workspace (key finding) @@ -28,10 +37,10 @@ A user workspace's `chunks.db` (and content vault) holds **two tiers**: `event_ai/ad/au` triggers), `conversation_segments`, `segment_embeddings`, `vector_chunks`, `user_profile` **(10 tables + FTS + triggers)**. - These live in `src/openhuman/memory_store/unified/{init,fts5,events,segments,profile}.rs`. - **W3 must keep the host creating/reading these in the same DB the crate now manages** — + These live in `src/openhuman/memory_store/namespace_store/{init,fts5,events,segments,profile}.rs`. + **The host continues creating/reading these in the same DB the crate now manages** — the crate's `chunks::with_connection` opens the shared handle; host `UnifiedMemory` schema - init runs alongside the crate's. Parity requirement: crate schema init and host `unified` + init runs alongside the crate's. Parity requirement: crate schema init and host namespace-store init must **compose without collision** on both fresh and existing DBs. ## Parity results by format dimension @@ -93,17 +102,15 @@ The core mechanism from plan §0.3: **one on-disk workspace, opened by both engi > tables) and the host `UnifiedMemory` tier (10 tables) **coexist without collision** (P3/P5/P11/P12), > and that re-running the flow adds/drops no tables (comparator 5). Still TODO: `vectors`/`store_meta`/ > `kv_*` (created by the chunk/embed pipeline, need a widened ingest flow), the seeded golden fixture + -> `scripts/gen-golden-workspace.sh`, and comparators **2** (recall/retrieval snapshot), **3** (tree -> read), **4** (byte-compare vault) — these require a populated, sealed fixture and the W5 retrieval -> surface, so they land alongside the W3/W5 flips. - -**Fixture.** Check in a small, deterministic `tests/fixtures/golden-workspace/` produced by the -*pre-migration* build: a real `chunks.db` + content vault + diff `.git`, seeded via a fixed script -(`scripts/gen-golden-workspace.sh`) with: a handful of chat + document + email sources across ≥2 -namespaces, ingested + scored + sealed to ≥2 tree levels, some entities/edges, a few queue jobs in -mixed states, and both tiers populated (episodic/event/segment rows present). Store the **generator -script** and a **manifest** (expected chunk-ids, summary paths, recall snapshots) so the fixture is -regenerable and reviewable, not an opaque blob. +> Comparators **2** (recall/retrieval snapshot), **3** (tree read), and **4** +> (byte-compare vault) were never landed before cutover and are now descoped by +> the decision above. Comparator 1 (schema composition) and comparator 5 +> (idempotent reopen) remain active. + +**Fixture decision.** Do not synthesize a fixture and label it “pre-migration” +after the fact. Compatibility is guarded by the retained format pins, schema +composition/idempotency test, and real host E2E fixtures. A future format +migration may add a versioned fixture captured before that migration starts. **Comparators (read-only, both engines open the SAME copied workspace):** @@ -124,7 +131,8 @@ regenerable and reviewable, not an opaque blob. **Wiring.** Runs under `pnpm test:rust` (host-side, counts toward coverage) and as a dedicated `tests/memory_golden_parity_e2e.rs`. The existing crate-level integration tests -(`tests/memory_roundtrip_e2e.rs`, `memory_tree_sync_deep_raw_coverage_e2e.rs`) act as the +(`tests/memory_roundtrip_e2e.rs`, +`tests/raw_coverage/memory_tree_sync_deep_raw_coverage_e2e.rs`) act as the "public-surface still green" guard (plan §5.1); this harness adds the **differential** guard that a flip preserves *existing* data, not just that the API still functions. @@ -154,13 +162,13 @@ Engine tests now run at their ownership boundary rather than through OpenHuman r surfaces, agent-tool response post-processing, source registry side effects, and the security-critical `MemoryTaint` seam. - OpenHuman CI now runs `cargo test --manifest-path vendor/tinycortex/Cargo.toml --features - git-diff,sync` when the submodule pointer changes and in the reusable full Rust suite. This is + git-diff,sync,persona` when the submodule pointer changes and in the reusable full Rust suite. This is required because Cargo does not run dependency test targets while testing `openhuman`. The focused local verification commands are: ```bash -cargo test --manifest-path vendor/tinycortex/Cargo.toml --features git-diff,sync +cargo test --manifest-path vendor/tinycortex/Cargo.toml --features git-diff,sync,persona cargo test --test raw_coverage_all memory_sync -- --test-threads=1 cargo test --test memory_sync_pipeline_e2e --test memory_artifacts_e2e \ --test memory_golden_parity_e2e --test memory_roundtrip_e2e --test memory_sources_e2e diff --git a/gitbooks/developing/architecture.md b/gitbooks/developing/architecture.md index e7f48a02f1..cb9cea6dcc 100644 --- a/gitbooks/developing/architecture.md +++ b/gitbooks/developing/architecture.md @@ -291,10 +291,10 @@ Every layer is async and non-blocking. The Rust core processes thousands of conc ## Vendored crate family & recent shifts -Core subsystems are being re-platformed onto published `tiny*` crates, vendored as git submodules under `vendor/` (`tinyagents`, `tinyflows`, `tinycortex`, `tinychannels`, `tinyjuice`, `tinyplace`) so crate changes can be tested in-tree before publishing. The major ongoing shifts: +Core subsystems run on published `tiny*` crates, vendored as git submodules under `vendor/` (`tinyagents`, `tinyflows`, `tinycortex`, `tinychannels`, `tinyjuice`, `tinyplace`) so crate changes can be tested in-tree before publishing. The major ownership boundaries are: - **Agent engine on tinyagents** — every agent turn runs through the `tinyagents` crate harness via the seam in `src/openhuman/tinyagents/`; see [Agent Harness](architecture/agent-harness.md). -- **Memory on tinycortex** — memory modules (`memory_diff`, `memory_conversations`, `memory_queue`, …) are being deleted or shimmed onto the `tinycortex` crate engine (W7 migration; #4785–#4788). +- **Memory on tinycortex** — the generic store/tree/queue/retrieval/sync engine is crate-owned. OpenHuman keeps RPC, tools, scheduling, credentials, security/event policy, worker orchestration, and the host namespace-document store; `src/openhuman/tinycortex/` implements those seams. Concrete embedding transports are shared through `tinyagents::harness::embeddings`. - **Inference on the crate ModelRouter** — host workload-tier model routing and cloud provider slugs now use the crate-native `ModelRouter`/`OpenAiModel` (#4782, #4783). - **Hosted-only brain** — the client-local orchestration graph engine (`src/openhuman/orchestration/graph/`) was retired (#4738); the client is a thin hosted-brain participant (pushers, effect/tool executors, wire allowlist — #4725) surfaced in the `/orchestration` and `/brain/tinyplace-orchestration` routes. diff --git a/src/core/observability.rs b/src/core/observability.rs index 1f3ff24d33..d3f862a934 100644 --- a/src/core/observability.rs +++ b/src/core/observability.rs @@ -674,7 +674,7 @@ pub fn expected_error_kind(message: &str) -> Option { /// disk-full condition during its own page bookkeeping (journal/WAL extension) /// before the next syscall surfaces an errno, rusqlite renders the `SQLITE_FULL` /// result code as `"database or disk is full"` (Sentry TAURI-RUST-B6N, hit at -/// `memory_store::unified::documents::tx.commit()` during +/// `memory_store::namespace_store::documents::tx.commit()` during /// `openhuman.memory_doc_ingest`). `SQLITE_FULL` has only two causes: /// genuine ENOSPC/ERROR_DISK_FULL (always the case in practice — the same /// burst always produces an os-error-28/112 sibling event) or a @@ -683,7 +683,7 @@ pub fn expected_error_kind(message: &str) -> Option { /// rusqlite renders `SQLITE_FULL` in one of two shapes. The **bare** shape is /// the five words `"database or disk is full"` — Our local memory-store write /// call-sites wrap it with `format!(": {e}")` (e.g. `"commit tx: ..."` / -/// `"clear_namespace commit tx: ..."` in `memory_store::unified::documents`), +/// `"clear_namespace commit tx: ..."` in `memory_store::namespace_store::documents`), /// so the phrase lands as the **suffix** of the local emit. The **extended** /// shape carries the full error-code envelope, `"database or disk is full: /// Error code 13: Insertion failed because database is full"` (Sentry @@ -1190,9 +1190,8 @@ fn is_loopback_unavailable(lower: &str) -> bool { /// the local Ollama daemon — pure user-state errors the UI already surfaces /// (toast / settings page warning) where Sentry has no remediation path. /// -/// Several canonical wire shapes are covered, all emitted by -/// `openhuman::embeddings::ollama::OllamaEmbedding::embed` and the embed -/// service fallback path: +/// Several canonical wire shapes are covered, all emitted by the TinyAgents +/// Ollama embedder and the host embed service fallback path: /// /// - **TAURI-RUST-XS** (~376 events on self-hosted Sentry): user pointed the /// embedder at a chat / vision model id with a temperature suffix (e.g. @@ -4250,7 +4249,7 @@ mod tests { // SQLITE_FULL rendering from rusqlite — engine-level disk-full // detection during page-bookkeeping (journal/WAL extension) that // beats the next syscall to the errno. Production hit at - // `memory_store::unified::documents::tx.commit()` during + // `memory_store::namespace_store::documents::tx.commit()` during // `openhuman.memory_doc_ingest`, in the same burst that emits // os-error-112 siblings (Sentry TAURI-RUST-B6N). "commit tx: database or disk is full", diff --git a/src/openhuman/agent/harness/archivist/hook_impl.rs b/src/openhuman/agent/harness/archivist/hook_impl.rs index ab4db9a559..cea1ea003b 100644 --- a/src/openhuman/agent/harness/archivist/hook_impl.rs +++ b/src/openhuman/agent/harness/archivist/hook_impl.rs @@ -80,15 +80,17 @@ impl PostTurnHook for ArchivistHook { tracing::debug!("[archivist] episodic rows written: session={session_id}"); - // Dual-write into memory_archivist::store (md-backed) so we can + // Dual-write into the crate-owned archivist store (md-backed) so we can // validate the FTS5 → md migration before flipping the read side. // Best-effort: a write failure here must not break the turn. The // user turn's assigned seq is captured into `current_seq` so the // segment ops can store it alongside the FTS5 episodic id. let mut current_seq: Option = None; if let Some(cfg) = self.config.as_ref() { + let engine_config = + crate::openhuman::tinycortex::memory_config_from(cfg, cfg.workspace_dir.clone()); let ts_ms = (timestamp * 1000.0) as i64; - let user_turn = crate::openhuman::memory_archivist::ArchivedTurn { + let user_turn = tinycortex::memory::archivist::types::ArchivedTurn { session_id: session_id.to_string(), seq: 0, // assigned by record_turn timestamp_ms: ts_ms, @@ -98,7 +100,7 @@ impl PostTurnHook for ArchivistHook { tool_calls_json: None, cost_microdollars: 0, }; - match crate::openhuman::memory_archivist::store::record_turn(cfg, user_turn) { + match tinycortex::memory::archivist::store::record_turn(&engine_config, user_turn) { Ok(stored) => current_seq = Some(stored.seq), Err(e) => { tracing::warn!("[archivist] memory_archivist user dual-write failed: {e}"); @@ -113,7 +115,7 @@ impl PostTurnHook for ArchivistHook { } else { Some(serde_json::to_string(&ctx.tool_calls).unwrap_or_default()) }; - let assistant_turn = crate::openhuman::memory_archivist::ArchivedTurn { + let assistant_turn = tinycortex::memory::archivist::types::ArchivedTurn { session_id: session_id.to_string(), seq: 0, timestamp_ms: ts_ms + 1, @@ -124,7 +126,7 @@ impl PostTurnHook for ArchivistHook { cost_microdollars: 0, }; if let Err(e) = - crate::openhuman::memory_archivist::store::record_turn(cfg, assistant_turn) + tinycortex::memory::archivist::store::record_turn(&engine_config, assistant_turn) { tracing::warn!("[archivist] memory_archivist assistant dual-write failed: {e}"); } diff --git a/src/openhuman/agent/harness/archivist/recap.rs b/src/openhuman/agent/harness/archivist/recap.rs index ce670be3bb..07a1fbad68 100644 --- a/src/openhuman/agent/harness/archivist/recap.rs +++ b/src/openhuman/agent/harness/archivist/recap.rs @@ -11,7 +11,7 @@ use std::sync::Arc; impl ArchivistHook { /// Read every entry recorded for `session_id`, preferring the - /// md-backed `memory_archivist::store` when `self.config` is set and + /// crate-owned md-backed archivist store when `self.config` is set and /// falling back to the legacy FTS5 episodic table otherwise. /// /// Returns `EpisodicEntry` so the existing call sites (segment @@ -23,7 +23,10 @@ impl ArchivistHook { session_id: &str, ) -> Vec { if let Some(cfg) = self.config.as_ref() { - match crate::openhuman::memory_archivist::store::session_entries(cfg, session_id) { + let engine_config = + crate::openhuman::tinycortex::memory_config_from(cfg, cfg.workspace_dir.clone()); + match tinycortex::memory::archivist::store::session_entries(&engine_config, session_id) + { Ok(turns) => { return turns .into_iter() diff --git a/src/openhuman/embeddings/README.md b/src/openhuman/embeddings/README.md index a3501a4ed3..d23f993109 100644 --- a/src/openhuman/embeddings/README.md +++ b/src/openhuman/embeddings/README.md @@ -1,106 +1,31 @@ # embeddings -Embedding providers for the OpenHuman memory system. Converts text into numerical vectors for semantic search, abstracting over multiple backends behind a single `EmbeddingProvider` trait. Owns provider construction (factory + slug catalog), per-endpoint client-side rate limiting, 429/503 retry-with-backoff logic, and the JSON-RPC surface that the Settings UI uses to pick a provider, store API keys, test connectivity, and embed text. The default provider is the OpenHuman backend ("managed" / "cloud", Voyage-backed); other paths include direct Voyage, OpenAI, Cohere, local Ollama, any OpenAI-compatible `custom:` endpoint, and a no-op (keyword-only) fallback. - -## Responsibilities - -- Define `EmbeddingProvider` (async trait) and the canonical embedding-space signature format (`provider=…;model=…;dims=…`) — the single source of truth so config-derived and live-provider signatures stay byte-identical (#1574). -- Implement each provider: cloud/managed, Voyage, OpenAI(-compatible), Cohere, Ollama, noop. -- Construct providers from a slug + model + dims (with or without credentials) via the factory. -- Maintain the static provider/model catalog (slugs, labels, API-key/endpoint requirements, dimension presets) consumed by the frontend picker. -- Throttle outbound cloud-embedding HTTP requests with a process-global, per-endpoint token bucket (loopback exempt). -- Parse `Retry-After` and apply 429/503 exponential backoff in HTTP-based providers. -- Expose RPC handlers for settings, API-key management, embed, and connection testing; trigger memory wipe / re-embed backfill when the embedding signature changes. - -## Key files - -| File | Role | -| --- | --- | -| `src/openhuman/embeddings/mod.rs` | Module docstring + module decls + embedding provider `pub use` re-exports; exposes `all_embeddings_controller_schemas` / `all_embeddings_registered_controllers`. | -| `src/openhuman/embeddings/provider_trait.rs` | `EmbeddingProvider` trait (`name`/`model_id`/`dimensions`/`signature`/`embed`/`embed_one`) and `format_embedding_signature`. | -| `src/openhuman/embeddings/factory.rs` | `create_embedding_provider`, `create_embedding_provider_with_credentials`, `default_embedding_provider` (cloud), `default_local_embedding_provider` (Ollama). Maps provider slugs to concrete impls; unknown slugs error. | -| `src/openhuman/embeddings/catalog.rs` | Static catalog of `EmbeddingProviderEntry` + `EmbeddingModelPreset`; slug constants; `all_providers` / `find_provider` / `find_model` / `default_model_for`. | -| `src/openhuman/embeddings/cloud.rs` | `OpenHumanCloudEmbedding` — default provider; resolves session JWT + API URL per call, delegates HTTP to `OpenAiEmbedding` against `/openai/v1`. `DEFAULT_CLOUD_EMBEDDING_MODEL` = `embedding-v1`, dims 1024. | -| `src/openhuman/embeddings/openai.rs` | `OpenAiEmbedding` — OpenAI-compatible `POST /v1/embeddings`; URL inference, 429/503 retry, rate-limit gating, dimension/count validation. Used directly by cloud, voyage, and custom paths. | -| `src/openhuman/embeddings/voyage.rs` | `VoyageEmbedding` — thin wrapper delegating to `OpenAiEmbedding` against `api.voyageai.com`. | -| `src/openhuman/embeddings/cohere.rs` | `CohereEmbedding` — Cohere-native `POST /v2/embed` wire format (`texts`, `embedding_types`, nested `embeddings.float`) with its own 429/503 retry loop. | -| `src/openhuman/embeddings/ollama.rs` | `OllamaEmbedding` — local Ollama `POST /api/embed`; base-url/model normalization, blank-input zero-vector preservation, NaN-encoding 500 per-text recovery (TAURI-RUST-AZ). Defaults `bge-m3`/1024. | -| `src/openhuman/embeddings/noop.rs` | `NoopEmbedding` — returns empty vectors; keyword-only fallback (`name`/`model_id` = "none", dims 0). | -| `src/openhuman/embeddings/rate_limit.rs` | Process-global, per-endpoint token-bucket request limiter; `set_embedding_rate_limit`, `embedding_rate_limit`, `acquire_embedding_slot`. Default 60/min; loopback exempt; `0` disables. | -| `src/openhuman/embeddings/retry_after.rs` | `parse_retry_after_ms`, `backoff_ms_for_attempt`, and the `MAX_429_RETRIES` / backoff constants. | -| `src/openhuman/embeddings/rpc.rs` | RPC business logic: `get_settings`, `update_settings`, `set_api_key`, `clear_api_key`, `embed`, `test_connection`, plus `provider_from_config` and `resolve_api_key`. | -| `src/openhuman/embeddings/schemas.rs` | Controller schemas + `handle_*` param-deserializing wrappers delegating to `rpc.rs`. | -| `src/openhuman/embeddings/mod_tests.rs` | Module-level tests (via `#[path]`). | -| `src/openhuman/embeddings/openai_tests.rs` / `ollama_tests.rs` | Sibling test suites for the OpenAI and Ollama providers (via `#[path]`). | - -## Public surface - -From `mod.rs` re-exports: - -- Trait + helper: `EmbeddingProvider`, `format_embedding_signature`. -- Providers: `OpenHumanCloudEmbedding`, `OpenAiEmbedding`, `OllamaEmbedding`, `NoopEmbedding` (Voyage/Cohere are public via their submodules through the factory). -- Factory fns: `create_embedding_provider`, `default_embedding_provider`, `default_local_embedding_provider`. -- Config-driven builder: `provider_from_config` (re-exported from `rpc`) — same construction `embed` uses, for callers like `codegraph` that need a provider without a JSON-RPC round-trip. -- Defaults/consts: `DEFAULT_CLOUD_EMBEDDING_MODEL`, `DEFAULT_CLOUD_EMBEDDING_DIMENSIONS`, `DEFAULT_OLLAMA_MODEL`, `DEFAULT_OLLAMA_DIMENSIONS`. -- RPC registry: `all_embeddings_controller_schemas`, `all_embeddings_registered_controllers`. - -## RPC / controllers - -Namespace `embeddings` (6 controllers, registered through `src/core/all.rs`): - -| Method | Description | -| --- | --- | -| `embeddings.get_settings` | Current provider/model/dims/rate-limit + full provider catalog with `has_api_key` flags and `vector_search_enabled`. | -| `embeddings.update_settings` | Update provider/model/dimensions/custom_endpoint/rate_limit. Requires `confirm_wipe=true` when **dimensions** change (returns `EMBEDDINGS_DIMENSION_CHANGE_REQUIRES_WIPE` otherwise); wipes memory on dim change and queues a re-embed backfill on any signature change. Also syncs `config.embeddings_provider` workload routing. | -| `embeddings.set_api_key` | Store an API key under credential provider `embeddings:`. | -| `embeddings.clear_api_key` | Remove the stored key for `embeddings:`. | -| `embeddings.embed` | Embed input texts using the configured provider; returns vectors, dimensions, count. | -| `embeddings.test_connection` | Run a single test embed against the configured or specified provider/model/dims. | - -All handlers return `RpcOutcome` via `into_cli_compatible_json`. - -## Agent tools - -None. This module owns no `tools.rs`; agent-facing memory tools live in `memory/tools/` and consume embeddings indirectly through the memory store. - -## Events - -None. No `bus.rs` / `EventHandler` impls. Cross-module side effects in `update_settings` are direct calls (`memory::read_rpc::wipe_all_rpc`, `memory_queue::ensure_reembed_backfill`), not domain events. - -## Persistence - -No `store.rs`. Settings live in the global `Config` (`config.memory.embedding_provider` / `embedding_model` / `embedding_dimensions` / `embedding_rate_limit_per_min`, plus `config.embeddings_provider`). API keys are persisted via the credentials domain (`AuthService`) under provider key `embeddings:`. Vectors themselves are stored by `memory_store::vectors`. - -## Dependencies - -- `crate::openhuman::config` — `Config`, `config::ops::load_config_with_timeout`, `build_runtime_proxy_client` (proxy-aware reqwest), config save. -- `crate::openhuman::credentials` — `AuthService`, `APP_SESSION_PROVIDER` for resolving the session JWT (cloud) and storing/loading provider API keys. -- `crate::openhuman::memory_store::vectors` — canonical vector store owner used by memory storage and retrieval. -- `crate::openhuman::memory::read_rpc` — `wipe_all_rpc` on a dimension change. -- `crate::openhuman::memory_queue` — `ensure_reembed_backfill` on a signature change. -- `crate::openhuman::inference::local` — `ollama_base_url()` when constructing the Ollama provider. -- `crate::api::config` — `effective_api_url` for the cloud backend base URL. -- `crate::core::all` — `ControllerFuture`, `RegisteredController` for the RPC registry. -- `crate::core::{ControllerSchema, FieldSchema, TypeSchema}` — schema definitions. -- `crate::core::observability` — `report_error_or_expected` so transient upstream HTTP failures demote to warning breadcrumbs instead of Sentry errors. -- `crate::rpc::RpcOutcome` — handler return contract. - -## Used by - -- `memory_store/*` — factories, vectors store, chunks store, unified store, retrieval, client (primary consumer; constructs providers and uses signatures to partition the embedding space). -- `memory/*` — ingestion queue, preferences, tools (recall/store/forget), `read_rpc`. -- `memory_tree/score/embed/cloud.rs` — scoring path. -- `codegraph/*` — index/search/tools obtain a provider via `provider_from_config` for `signature()` + direct embedding. -- `voice/factory.rs`, `screen_intelligence`, `channels`, `agent` tools — indirect consumers. -- `config/schema/load.rs` — wires `memory.embedding_rate_limit_per_min` into `rate_limit::set_embedding_rate_limit`. -- `core/all.rs` — registers the RPC controllers; `core/observability.rs` — classifies the canonical embedding error strings. - -## Notes / gotchas - -- **Signature drift is the core hazard.** `format_embedding_signature` is the single source of truth; both config-derived and live-provider signatures route through it. A mismatch silently splits one embedding space into two (#1574). `update_settings` keys memory wipe off **dimension** change (vectors stay comparable across provider/model swaps at the same dimensionality) but queues a re-embed backfill on **any** signature change. -- **Rate limiting is account-wide and process-global**, keyed by resolved base URL, with capacity = 1 token (no burst) to stay strictly under a hard per-minute cap. Ephemeral provider instances share one budget per endpoint. Loopback hosts are exempt; `limit==0` disables. The acquire chokepoint sits **inside** the retry loop so retried attempts consume tokens. -- **Cloud provider resolves auth lazily per call** — it can be constructed before login; the first `embed()` errors clearly if unauthenticated. It honors `OPENHUMAN_WORKSPACE` for the auth-profiles directory so non-default workspaces (tests/multi-instance) don't silently lose their session. -- **Ollama NaN recovery (TAURI-RUST-AZ):** a single bad input can poison a whole batch with a 500 `unsupported value: NaN`; the provider re-issues per-text and substitutes empty embeddings for the offending entries. Blank/whitespace inputs are preserved as zero-vectors so result length always matches input length. `local-*` model IDs are rejected (they're virtual routing aliases). -- **Custom endpoints** are encoded in the provider slug as `custom:`; `resolve_api_key` and the RPC handlers normalize this back to the `custom` credential slug. -- **Voyage and cloud reuse `OpenAiEmbedding`** for HTTP plumbing (Voyage's API is an OpenAI superset). **Cohere** speaks a distinct wire format and has its own retry loop. -- Error strings from OpenAI/Cohere providers intentionally preserve the `(429 ` / status substring so `core::observability`'s `TransientUpstreamHttp` classifier downgrades them. +Host policy and RPC surface for vector embeddings. Concrete OpenAI-compatible, +Cohere, Voyage, Ollama, cloud-transport, retry, and rate-limit implementations +live in `tinyagents::harness::embeddings`; this domain selects and adapts those +models for OpenHuman. + +## Host-owned responsibilities + +- `factory.rs`: provider slug/model/dimension selection and construction of + TinyAgents models. +- `provider_trait.rs`: compatibility contract used by existing OpenHuman + consumers plus `TinyAgentsEmbeddingProvider`, the one trait adapter. +- `cloud_adapter.rs`: OpenHuman session-token resolution, egress disclosure, + and local-only privacy enforcement around TinyAgents `CloudEmbeddingModel`. +- `catalog.rs`, `rpc.rs`, `schemas.rs`: Settings catalog, credentials, JSON-RPC, + connection tests, and re-embed/wipe policy. +- `noop.rs`: unit-struct compatibility adapter over TinyAgents' no-op model. + +Provider HTTP behavior and its tests belong in `vendor/tinyagents`. Do not add +new per-provider clients here. + +The canonical embedding-space signature is +`provider={name};model={model};dims={dims}`. Configuration-derived and live +provider signatures must remain byte-identical or stored vectors split into +incompatible spaces. + +The memory tree uses the same factory through +`memory_tree/score/embed::ProviderEmbedder`; its fixed on-disk dimension remains +1024. Ollama requests use TinyAgents' +`RECOMMENDED_OLLAMA_CONTEXT_TOKENS` for both `num_ctx` and `num_batch`. diff --git a/src/openhuman/embeddings/cohere_adapter.rs b/src/openhuman/embeddings/cohere_adapter.rs deleted file mode 100644 index 1ff51e7a9c..0000000000 --- a/src/openhuman/embeddings/cohere_adapter.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! Compatibility wrapper for tinyagents' Cohere model. - -use async_trait::async_trait; -use tinyagents::harness::embeddings::{CohereEmbeddingModel, EmbeddingModel}; - -use super::EmbeddingProvider; - -pub struct CohereEmbedding { - inner: CohereEmbeddingModel, -} - -impl CohereEmbedding { - pub fn new(api_key: &str, model: &str, dimensions: usize) -> Self { - Self { - inner: CohereEmbeddingModel::new(api_key) - .with_model(model) - .with_dimensions(dimensions), - } - } - - pub fn with_base_url(mut self, base_url: impl Into) -> Self { - self.inner = self.inner.with_base_url(base_url); - self - } -} - -#[async_trait] -impl EmbeddingProvider for CohereEmbedding { - fn name(&self) -> &str { - self.inner.name() - } - fn model_id(&self) -> &str { - self.inner.model_id() - } - fn dimensions(&self) -> usize { - self.inner.dimensions() - } - fn signature(&self) -> String { - self.inner.signature() - } - async fn embed(&self, texts: &[&str]) -> anyhow::Result>> { - let texts = texts - .iter() - .map(|text| (*text).to_owned()) - .collect::>(); - self.inner - .embed(&texts) - .await - .map_err(|error| anyhow::anyhow!(error)) - } -} diff --git a/src/openhuman/embeddings/mod.rs b/src/openhuman/embeddings/mod.rs index 4854da55e4..29b6366339 100644 --- a/src/openhuman/embeddings/mod.rs +++ b/src/openhuman/embeddings/mod.rs @@ -15,14 +15,8 @@ pub mod catalog; #[path = "cloud_adapter.rs"] pub mod cloud; -#[path = "cohere_adapter.rs"] -pub mod cohere; mod factory; pub mod noop; -#[path = "ollama_adapter.rs"] -pub mod ollama; -#[path = "openai_adapter.rs"] -pub mod openai; mod provider_trait; pub mod rate_limit { pub use tinyagents::harness::embeddings::{ @@ -42,8 +36,6 @@ pub mod retry_after { } mod rpc; mod schemas; -#[path = "voyage_adapter.rs"] -pub mod voyage; pub use catalog::non_embedding_model_reason; pub use cloud::{ @@ -60,8 +52,6 @@ pub(crate) use factory::model_supports_dimensions; // #002 FR-015: the memory-tree OpenAI-compat embedder reuses the same key // resolution the embeddings RPC uses, so there is one source of truth. pub use noop::NoopEmbedding; -pub use ollama::OllamaEmbedding; -pub use openai::OpenAiEmbedding; pub use provider_trait::{ format_embedding_signature, EmbeddingProvider, TinyAgentsEmbeddingProvider, }; @@ -71,7 +61,9 @@ pub use schemas::{ all_controller_schemas as all_embeddings_controller_schemas, all_registered_controllers as all_embeddings_registered_controllers, }; -pub use tinyagents::harness::embeddings::{DEFAULT_OLLAMA_DIMENSIONS, DEFAULT_OLLAMA_MODEL}; +pub use tinyagents::harness::embeddings::{ + DEFAULT_OLLAMA_DIMENSIONS, DEFAULT_OLLAMA_MODEL, DEFAULT_OLLAMA_URL, +}; #[cfg(test)] #[path = "mod_tests.rs"] diff --git a/src/openhuman/embeddings/mod_tests.rs b/src/openhuman/embeddings/mod_tests.rs index a1151227df..ce8a0d5f7d 100644 --- a/src/openhuman/embeddings/mod_tests.rs +++ b/src/openhuman/embeddings/mod_tests.rs @@ -21,15 +21,13 @@ fn noop_name_and_dims() { async fn noop_embed_returns_empty() { let p = NoopEmbedding; let result = p.embed(&["hello"]).await.unwrap(); - assert!(result.is_empty()); + assert_eq!(result, vec![Vec::::new()]); } #[tokio::test] -async fn noop_embed_one_returns_error() { - // embed returns empty vec → pop() returns None → error from default impl +async fn noop_embed_one_returns_empty_vector() { let p = NoopEmbedding; - let err = p.embed_one("hello").await.unwrap_err(); - assert!(err.to_string().contains("Empty embedding result")); + assert!(p.embed_one("hello").await.unwrap().is_empty()); } #[tokio::test] diff --git a/src/openhuman/embeddings/noop.rs b/src/openhuman/embeddings/noop.rs index 18949a532e..948e538609 100644 --- a/src/openhuman/embeddings/noop.rs +++ b/src/openhuman/embeddings/noop.rs @@ -1,6 +1,7 @@ //! No-op embedding provider for keyword-only search fallback. use async_trait::async_trait; +use tinyagents::harness::embeddings::{EmbeddingModel, NoopEmbeddingModel}; use super::EmbeddingProvider; @@ -22,7 +23,14 @@ impl EmbeddingProvider for NoopEmbedding { 0 } - async fn embed(&self, _texts: &[&str]) -> anyhow::Result>> { - Ok(Vec::new()) + async fn embed(&self, texts: &[&str]) -> anyhow::Result>> { + let texts = texts + .iter() + .map(|text| (*text).to_owned()) + .collect::>(); + NoopEmbeddingModel + .embed(&texts) + .await + .map_err(|error| anyhow::anyhow!(error)) } } diff --git a/src/openhuman/embeddings/ollama_adapter.rs b/src/openhuman/embeddings/ollama_adapter.rs deleted file mode 100644 index 0b8162fe12..0000000000 --- a/src/openhuman/embeddings/ollama_adapter.rs +++ /dev/null @@ -1,61 +0,0 @@ -//! Compatibility wrapper for tinyagents' Ollama model. - -use async_trait::async_trait; -use tinyagents::harness::embeddings::{EmbeddingModel, OllamaEmbeddingModel}; - -use super::EmbeddingProvider; - -pub use tinyagents::harness::embeddings::{ - DEFAULT_OLLAMA_DIMENSIONS, DEFAULT_OLLAMA_MODEL, DEFAULT_OLLAMA_URL, -}; - -#[derive(Default)] -pub struct OllamaEmbedding { - inner: OllamaEmbeddingModel, -} - -impl OllamaEmbedding { - pub fn try_new(base_url: &str, model: &str, dimensions: usize) -> anyhow::Result { - Ok(Self { - inner: OllamaEmbeddingModel::try_new(base_url, model, dimensions)?, - }) - } - - pub fn new(base_url: &str, model: &str, dimensions: usize) -> Self { - Self::try_new(base_url, model, dimensions).expect("invalid Ollama embedding configuration") - } - - pub fn base_url(&self) -> &str { - self.inner.base_url() - } - - pub fn model(&self) -> &str { - self.inner.model() - } -} - -#[async_trait] -impl EmbeddingProvider for OllamaEmbedding { - fn name(&self) -> &str { - self.inner.name() - } - fn model_id(&self) -> &str { - self.inner.model_id() - } - fn dimensions(&self) -> usize { - self.inner.dimensions() - } - fn signature(&self) -> String { - self.inner.signature() - } - async fn embed(&self, texts: &[&str]) -> anyhow::Result>> { - let texts = texts - .iter() - .map(|text| (*text).to_owned()) - .collect::>(); - self.inner - .embed(&texts) - .await - .map_err(|error| anyhow::anyhow!(error)) - } -} diff --git a/src/openhuman/embeddings/openai_adapter.rs b/src/openhuman/embeddings/openai_adapter.rs deleted file mode 100644 index 003cabe783..0000000000 --- a/src/openhuman/embeddings/openai_adapter.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! Compatibility wrapper for tinyagents' OpenAI-compatible model. - -use async_trait::async_trait; -use tinyagents::harness::embeddings::{EmbeddingModel, OpenAiEmbeddingModel}; - -use super::EmbeddingProvider; - -pub struct OpenAiEmbedding { - inner: OpenAiEmbeddingModel, -} - -impl OpenAiEmbedding { - pub fn new(base_url: &str, api_key: &str, model: &str, dimensions: usize) -> Self { - Self { - inner: OpenAiEmbeddingModel::new(api_key) - .with_base_url(base_url) - .with_model(model) - .with_dimensions(dimensions) - .with_send_dimensions(false) - .with_required_api_key(false), - } - } - - pub fn with_send_dimensions(mut self, send: bool) -> Self { - self.inner = self.inner.with_send_dimensions(send); - self - } - - pub fn with_required_api_key(mut self, required: bool) -> Self { - self.inner = self.inner.with_required_api_key(required); - self - } - - pub fn base_url(&self) -> &str { - self.inner.base_url() - } - - pub fn model(&self) -> &str { - self.inner.model() - } - - pub fn embeddings_url(&self) -> String { - self.inner.embeddings_url() - } -} - -#[async_trait] -impl EmbeddingProvider for OpenAiEmbedding { - fn name(&self) -> &str { - self.inner.name() - } - fn model_id(&self) -> &str { - self.inner.model_id() - } - fn dimensions(&self) -> usize { - self.inner.dimensions() - } - fn signature(&self) -> String { - self.inner.signature() - } - async fn embed(&self, texts: &[&str]) -> anyhow::Result>> { - let texts = texts - .iter() - .map(|text| (*text).to_owned()) - .collect::>(); - self.inner - .embed(&texts) - .await - .map_err(|error| anyhow::anyhow!(error)) - } -} diff --git a/src/openhuman/embeddings/voyage_adapter.rs b/src/openhuman/embeddings/voyage_adapter.rs deleted file mode 100644 index 9da55c8f44..0000000000 --- a/src/openhuman/embeddings/voyage_adapter.rs +++ /dev/null @@ -1,69 +0,0 @@ -//! Compatibility wrapper for tinyagents' Voyage model. - -use async_trait::async_trait; -use tinyagents::harness::embeddings::{ - EmbeddingModel, VoyageEmbeddingModel, VOYAGE_API_BASE, VOYAGE_DEFAULT_DIMENSIONS, - VOYAGE_DEFAULT_MODEL, -}; - -use super::EmbeddingProvider; - -pub struct VoyageEmbedding { - inner: VoyageEmbeddingModel, -} - -impl VoyageEmbedding { - pub fn new(api_key: &str, model: &str, dimensions: usize) -> Self { - Self::new_with_base_url(api_key, model, dimensions, VOYAGE_API_BASE) - } - - pub fn new_with_base_url( - api_key: &str, - model: &str, - dimensions: usize, - base_url: &str, - ) -> Self { - Self { - inner: VoyageEmbeddingModel::with_options( - api_key, - if model.is_empty() { - VOYAGE_DEFAULT_MODEL - } else { - model - }, - if dimensions == 0 { - VOYAGE_DEFAULT_DIMENSIONS - } else { - dimensions - }, - base_url, - ), - } - } -} - -#[async_trait] -impl EmbeddingProvider for VoyageEmbedding { - fn name(&self) -> &str { - self.inner.name() - } - fn model_id(&self) -> &str { - self.inner.model_id() - } - fn dimensions(&self) -> usize { - self.inner.dimensions() - } - fn signature(&self) -> String { - self.inner.signature() - } - async fn embed(&self, texts: &[&str]) -> anyhow::Result>> { - let texts = texts - .iter() - .map(|text| (*text).to_owned()) - .collect::>(); - self.inner - .embed(&texts) - .await - .map_err(|error| anyhow::anyhow!(error)) - } -} diff --git a/src/openhuman/inference/local/model_requirements.rs b/src/openhuman/inference/local/model_requirements.rs index a23eb482ea..4fc94b65a3 100644 --- a/src/openhuman/inference/local/model_requirements.rs +++ b/src/openhuman/inference/local/model_requirements.rs @@ -2,7 +2,7 @@ //! //! The memory tree's embedder (`bge-m3`) is requested with //! `num_ctx = 8192` (see -//! [`crate::openhuman::memory_tree::score::embed::ollama::EMBED_NUM_CTX`]) +//! [`tinyagents::harness::embeddings::RECOMMENDED_OLLAMA_CONTEXT_TOKENS`]) //! and the summariser hard-caps its output to fit that 8192-token embed //! ceiling. A local model whose native context window is below this floor //! silently truncates chunks/summaries and corrupts recall, so we refuse @@ -16,12 +16,12 @@ use serde::Serialize; /// Minimum native context window (tokens) a local model must advertise to /// be accepted by the memory layer. /// -/// Re-exported from the embedder's own `EMBED_NUM_CTX` so this gate can +/// Re-exported from TinyAgents' canonical Ollama context setting so this gate can /// never drift from what the memory pipeline actually requests at embed /// time. Changing the embedder's context request automatically moves the /// acceptance floor with it. pub const MIN_CONTEXT_TOKENS: u64 = - crate::openhuman::memory_tree::score::embed::ollama::EMBED_NUM_CTX as u64; + tinyagents::harness::embeddings::RECOMMENDED_OLLAMA_CONTEXT_TOKENS as u64; /// Verdict for a single model's context window against /// [`MIN_CONTEXT_TOKENS`]. Serialized into the diagnostics payload so the @@ -79,7 +79,7 @@ mod tests { // requests; this guards against the two drifting apart. assert_eq!( MIN_CONTEXT_TOKENS, - crate::openhuman::memory_tree::score::embed::ollama::EMBED_NUM_CTX as u64 + tinyagents::harness::embeddings::RECOMMENDED_OLLAMA_CONTEXT_TOKENS as u64 ); assert_eq!(MIN_CONTEXT_TOKENS, 8_192); } diff --git a/src/openhuman/learning/README.md b/src/openhuman/learning/README.md index 17d482c86c..dd8b8ee00d 100644 --- a/src/openhuman/learning/README.md +++ b/src/openhuman/learning/README.md @@ -111,7 +111,7 @@ These are subscriber registrations rather than a single `bus.rs`; subscriptions - `src/core/all.rs` — registers the `learning.*` controllers + schemas. - `src/openhuman/agent/harness/session/{builder,turn}.rs` and `agent_memory/memory_loader.rs` — wire the post-turn hooks, prompt sections, and learned-context loading into the agent loop. - `src/openhuman/channels/runtime/startup.rs` — likely registers schedulers/subscribers at startup. -- `src/openhuman/memory_store/unified/profile.rs`, `memory_sync/composio/providers/profile.rs`, `memory_tools/{capture,mod}.rs`, `tools/impl/system/tool_stats.rs`, `tools/schemas.rs` — consume facet/learning types. +- `src/openhuman/memory_store/namespace_store/profile.rs`, `memory_sync/composio/providers/profile.rs`, `memory_tools/{capture,mod}.rs`, `tools/impl/system/tool_stats.rs`, `tools/schemas.rs` — consume facet/learning types. ## Notes / gotchas diff --git a/src/openhuman/learning/cache.rs b/src/openhuman/learning/cache.rs index 2321fad11b..1c801dbd21 100644 --- a/src/openhuman/learning/cache.rs +++ b/src/openhuman/learning/cache.rs @@ -14,7 +14,7 @@ use crate::openhuman::memory_store::profile::{self, ProfileFacet, UserState}; /// Thin wrapper around the `user_profile` table. /// /// All methods delegate to the standalone helpers in -/// `memory::store::unified::profile`. This type exists so callers +/// `memory_store::namespace_store::profile`. This type exists so callers /// (stability detector, prompt sections, RPCs) share a single typed /// entry-point that can be constructed from any `Arc>`. pub struct FacetCache { diff --git a/src/openhuman/memory/README.md b/src/openhuman/memory/README.md index af91b77641..e45430cdbe 100644 --- a/src/openhuman/memory/README.md +++ b/src/openhuman/memory/README.md @@ -26,7 +26,7 @@ one job. memory orchestrates and routes between them. | ------------------------------------------ | --------------------------------------------------------------------------------------------------- | | [`memory_store`](../memory_store/) | Storage primitives: raw / chunks / entities / trees / vectors / kv / contacts. SQLite + on-disk md. | | [`memory_tree`](../memory_tree/) | Generic tree mechanics: bucket-seal, flush, summarise, and retrieval/traversal backends. | -| [`memory_archivist`](../memory_archivist/) | Chat conversation → clip tool-calls → push to tree (shim over `tinycortex::memory::archivist`). | +| `tinycortex::memory::archivist` | Chat conversation → clip tool-calls → push to tree; called directly by the host harness. | | [`memory_tools`](../memory_tools/) | Tool-scoped rules + agent read/write tools. | | [`memory_sync`](../memory_sync/) | Composio + workspace + MCP sync pipelines. | diff --git a/src/openhuman/memory/mod.rs b/src/openhuman/memory/mod.rs index e1b56dce81..c16a5980d7 100644 --- a/src/openhuman/memory/mod.rs +++ b/src/openhuman/memory/mod.rs @@ -57,6 +57,5 @@ pub use traits::{Memory, MemoryCategory, MemoryEntry, MemoryTaint, NamespaceSumm // Re-export types that external tests and consumers historically imported // from `memory::*`. The definitions moved to sibling crates during the // memory refactor; these aliases keep the public surface stable. -pub use crate::openhuman::memory_queue as jobs; pub use crate::openhuman::memory_store::types::NamespaceDocumentInput; pub use crate::openhuman::memory_store::{MemoryClient, UnifiedMemory}; diff --git a/src/openhuman/memory/read_rpc_tests.rs b/src/openhuman/memory/read_rpc_tests.rs index 0f36905e6f..4302423902 100644 --- a/src/openhuman/memory/read_rpc_tests.rs +++ b/src/openhuman/memory/read_rpc_tests.rs @@ -4,7 +4,7 @@ use crate::openhuman::embeddings::NoopEmbedding; use crate::openhuman::memory::ingest_pipeline::ingest_chat; use crate::openhuman::memory_queue::drain_until_idle; use crate::openhuman::memory_store::content::raw::{write_raw_items, RawItem, RawKind}; -use crate::openhuman::memory_store::unified::UnifiedMemory; +use crate::openhuman::memory_store::namespace_store::UnifiedMemory; use crate::openhuman::memory_sync::canonicalize::chat::{ChatBatch, ChatMessage}; use chrono::{TimeZone, Utc}; use rusqlite::params; diff --git a/src/openhuman/memory_archivist/mod.rs b/src/openhuman/memory_archivist/mod.rs deleted file mode 100644 index 979946d265..0000000000 --- a/src/openhuman/memory_archivist/mod.rs +++ /dev/null @@ -1,44 +0,0 @@ -//! Memory archivist — thin host shim over `tinycortex::memory::archivist` (W7). -//! -//! The archivist engine (clip / compose / tree-writer / episodic store + its -//! types) lives in the crate now. This module keeps the host's -//! `memory_archivist::…` import paths stable and adapts the host [`Config`] to -//! the crate's `MemoryConfig` for the two episodic-store functions host callers -//! use (`store::record_turn` / `store::session_entries`, from the agent -//! archivist hook + recap). -//! -//! On-disk layout is unchanged — the crate writes to the same path the host -//! did: `/memory_tree/content/episodic//.md`. -//! `Config::workspace_dir` maps to the crate `MemoryConfig.workspace`, so -//! `memory_tree_content_root()` (`/memory_tree/content`) resolves -//! identically on both sides. - -pub use tinycortex::memory::archivist::types::{ArchivedTurn, Turn}; - -/// Episodic conversation archive — thin adapters over the crate store that -/// convert the host [`Config`](crate::openhuman::config::Config) into the -/// crate's `MemoryConfig`. -pub mod store { - use anyhow::Result; - - use super::ArchivedTurn; - use crate::openhuman::config::Config; - use crate::openhuman::tinycortex::memory_config_from; - - fn engine_config(config: &Config) -> tinycortex::memory::MemoryConfig { - memory_config_from(config, config.workspace_dir.clone()) - } - - /// Append one turn to its session's episodic archive, assigning the next - /// per-session sequence number. - pub fn record_turn(config: &Config, turn: ArchivedTurn) -> Result { - tinycortex::memory::archivist::store::record_turn(&engine_config(config), turn) - } - - /// All turns recorded for a session, ordered by sequence. - pub fn session_entries(config: &Config, session_id: &str) -> Result> { - tinycortex::memory::archivist::store::session_entries(&engine_config(config), session_id) - } -} - -pub use store::{record_turn, session_entries}; diff --git a/src/openhuman/memory_queue/mod.rs b/src/openhuman/memory_queue/mod.rs index f0414ddfe4..b2cc6b5b8c 100644 --- a/src/openhuman/memory_queue/mod.rs +++ b/src/openhuman/memory_queue/mod.rs @@ -28,7 +28,6 @@ //! This queue used to live under `openhuman::memory::jobs`; it now has a //! dedicated top-level home (`openhuman::memory_queue`) because it is an //! execution/runtime concern rather than a leaf of the memory policy API. -//! `openhuman::memory` re-exports it as `memory::jobs` during the migration. mod ops; pub mod scheduler; diff --git a/src/openhuman/memory_search/mod.rs b/src/openhuman/memory_search/mod.rs index 7a310f2031..ac7dc1321d 100644 --- a/src/openhuman/memory_search/mod.rs +++ b/src/openhuman/memory_search/mod.rs @@ -5,9 +5,7 @@ //! `memory_tree`) provide persistence and tree traversal; this module composes //! them into tools the agent can invoke. -pub mod scoring; pub mod tools; -pub mod vector; // ── Public re-exports ─────────────────────────────────────────────────────── diff --git a/src/openhuman/memory_search/scoring.rs b/src/openhuman/memory_search/scoring.rs deleted file mode 100644 index b64cefd632..0000000000 --- a/src/openhuman/memory_search/scoring.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Scoring weight profiles for hybrid retrieval — thin host shim over -//! `tinycortex::memory::WeightProfile` (W5). -//! -//! The weight profile (graph/vector/keyword/freshness weights + the -//! `BALANCED`/`SEMANTIC`/`LEXICAL`/`GRAPH_FIRST` presets + `by_name`) is the -//! crate's, a byte-identical port. The host keeps only [`compose_score`] — the -//! trivial weighted combination the crate expresses via -//! `retrieval::scoring::hybrid_score` at its own call sites; exposed here as a -//! free function so `memory_search::tools::hybrid_search` keeps its call shape. - -pub use tinycortex::memory::WeightProfile; - -/// Weighted composite of the four retrieval signals under `profile`. -/// -/// `graph·graph_relevance + vector·vector_similarity + keyword·keyword_relevance -/// + freshness·freshness`. -pub fn compose_score( - profile: &WeightProfile, - graph_relevance: f64, - vector_similarity: f64, - keyword_relevance: f64, - freshness: f64, -) -> f64 { - (profile.graph * graph_relevance) - + (profile.vector * vector_similarity) - + (profile.keyword * keyword_relevance) - + (profile.freshness * freshness) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn compose_score_is_weighted_sum() { - let p = WeightProfile::BALANCED; - let s = compose_score(&p, 1.0, 1.0, 1.0, 1.0); - assert!((s - (p.graph + p.vector + p.keyword + p.freshness)).abs() < 1e-9); - } -} diff --git a/src/openhuman/memory_search/tools/hybrid_search.rs b/src/openhuman/memory_search/tools/hybrid_search.rs index 544aa76787..aeb2c38dbc 100644 --- a/src/openhuman/memory_search/tools/hybrid_search.rs +++ b/src/openhuman/memory_search/tools/hybrid_search.rs @@ -12,10 +12,10 @@ use std::sync::Arc; use crate::openhuman::config::rpc as config_rpc; use crate::openhuman::embeddings::{provider_from_config, EmbeddingProvider}; -use crate::openhuman::memory_search::scoring::WeightProfile; use crate::openhuman::memory_store::types::MemoryItemKind; use crate::openhuman::memory_store::UnifiedMemory; use crate::openhuman::tools::traits::{Tool, ToolResult}; +use tinycortex::memory::WeightProfile; pub struct MemoryHybridSearchTool; @@ -178,13 +178,14 @@ impl Tool for MemoryHybridSearchTool { .enumerate() .map(|(i, hit)| { let bd = &hit.score_breakdown; - let score = crate::openhuman::memory_search::scoring::compose_score( + let score = tinycortex::memory::retrieval::scoring::hybrid_score( &profile, bd.graph_relevance, bd.vector_similarity, bd.keyword_relevance, bd.freshness, - ); + ) + .final_score; (i, score) }) .filter(|(_, score)| *score > 0.0) diff --git a/src/openhuman/memory_search/tools/vector_search.rs b/src/openhuman/memory_search/tools/vector_search.rs index 4fa1065512..15b59a1092 100644 --- a/src/openhuman/memory_search/tools/vector_search.rs +++ b/src/openhuman/memory_search/tools/vector_search.rs @@ -11,13 +11,13 @@ use std::fmt::Write; use crate::openhuman::config::rpc as config_rpc; use crate::openhuman::embeddings::provider_from_config; -use crate::openhuman::memory_search::vector::mmr::{mmr_select, MmrCandidate}; use crate::openhuman::memory_store::chunks::store::{ get_chunk_embeddings_for_signature_batch, list_chunks, ListChunksQuery, }; use crate::openhuman::memory_store::chunks::types::SourceKind; use crate::openhuman::memory_store::vectors::cosine_similarity; use crate::openhuman::tools::traits::{Tool, ToolResult}; +use tinycortex::memory::retrieval::mmr::{mmr_select, MmrCandidate}; pub struct MemoryVectorSearchTool; diff --git a/src/openhuman/memory_search/vector/mmr.rs b/src/openhuman/memory_search/vector/mmr.rs deleted file mode 100644 index 6f48a00130..0000000000 --- a/src/openhuman/memory_search/vector/mmr.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! Maximal Marginal Relevance selection — thin host re-export of -//! `tinycortex::memory::retrieval::mmr` (W5). -//! -//! The MMR algorithm (relevance–diversity tradeoff over embeddings) is the -//! crate's, a byte-identical port. Host consumers (`memory_search::tools`) keep -//! their `memory_search::vector::mmr::{MmrCandidate, MmrResult, mmr_select}` -//! import paths unchanged. - -pub use tinycortex::memory::retrieval::mmr::{mmr_select, MmrCandidate, MmrResult}; diff --git a/src/openhuman/memory_search/vector/mod.rs b/src/openhuman/memory_search/vector/mod.rs deleted file mode 100644 index 9ce45fd7de..0000000000 --- a/src/openhuman/memory_search/vector/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -//! Vector diversity algorithms. - -pub mod mmr; diff --git a/src/openhuman/memory_store/README.md b/src/openhuman/memory_store/README.md index 1216184ad1..fe224cdf85 100644 --- a/src/openhuman/memory_store/README.md +++ b/src/openhuman/memory_store/README.md @@ -12,9 +12,8 @@ trees/ summary tree persistence (one table, kind-parameterized) vectors/ local vector DB (cosine, brute-force) kv/ global + namespace key-value (kv_global, kv_namespace) contacts/ [removed] facade over people::store (Person/Handle/Interaction) -unified/ [staging for removal] UnifiedMemory's remaining SQLite surface - (documents/query/segments/events/profile) — replaced as the - Memory trait callers migrate to per-kind backends +namespace_store/ host-retained namespace documents, graph, episodic/event/ + segment/profile tables, and retrieval policy ``` ## Cross-cutting modules @@ -43,7 +42,7 @@ unified/ [staging for removal] UnifiedMemory's remaining SQLite surface | [`vectors/`](vectors/) | Standalone vector store. `VectorStore` over SQLite, byte-codec for f32 vectors, cosine similarity. | | [`kv.rs`](kv.rs) | Global + namespace key-value (`kv_global`, `kv_namespace` tables). | | `contacts/` | Removed. Contact access now lives outside `memory_store` via `people::store`. | -| [`unified/`](unified/) | **Staging for removal.** Shrinking SQLite surface that still backs the `Memory` trait while callers migrate to per-kind modules. Active pieces today: `documents`, `query`, `segments`, `events`, `profile`. The `fts5` episodic surface is replaced by [`memory_archivist`](../memory_archivist/) and `graph` by the crate's `tinycortex::memory::graph` (the host `memory_graph` placeholder was deleted in the W7 migration). See [`unified/README.md`](unified/README.md). | +| [`namespace_store/`](namespace_store/) | Host-retained namespace/document tier over the shared SQLite database: documents, persisted product graph relations, episodic/events, segments, profile facets, and host retrieval policy. TinyCortex owns the generic chunk/vector/tree/queue substrate; this tier remains the stable `Memory` implementation. See [`namespace_store/README.md`](namespace_store/README.md). | ## Layer rules diff --git a/src/openhuman/memory_store/client.rs b/src/openhuman/memory_store/client.rs index 8fa0139f11..8a250b0f0d 100644 --- a/src/openhuman/memory_store/client.rs +++ b/src/openhuman/memory_store/client.rs @@ -17,10 +17,10 @@ use crate::openhuman::memory::ingestion::{ IngestionJob, IngestionQueue, IngestionState, MemoryIngestionConfig, MemoryIngestionRequest, MemoryIngestionResult, }; +use crate::openhuman::memory_store::namespace_store::UnifiedMemory; use crate::openhuman::memory_store::types::{ NamespaceDocumentInput, NamespaceMemoryHit, NamespaceRetrievalContext, }; -use crate::openhuman::memory_store::unified::UnifiedMemory; /// Reference-counted handle to a `MemoryClient`. pub type MemoryClientRef = Arc; @@ -56,7 +56,7 @@ pub struct MemoryClient { impl MemoryClient { /// Returns a handle to the underlying SQLite connection for direct /// profile-facet writes via - /// [`crate::openhuman::memory_store::unified::profile::profile_upsert`]. + /// [`crate::openhuman::memory_store::namespace_store::profile::profile_upsert`]. /// /// Intentionally `pub(crate)` — external consumers should use the /// higher-level `MemoryClient` API; this escape hatch exists so diff --git a/src/openhuman/memory_store/factories.rs b/src/openhuman/memory_store/factories.rs index 594157733a..c13a76bd3d 100644 --- a/src/openhuman/memory_store/factories.rs +++ b/src/openhuman/memory_store/factories.rs @@ -21,7 +21,7 @@ use crate::openhuman::embeddings::{ DEFAULT_CLOUD_EMBEDDING_MODEL, DEFAULT_OLLAMA_DIMENSIONS, DEFAULT_OLLAMA_MODEL, }; use crate::openhuman::memory::traits::Memory; -use crate::openhuman::memory_store::unified::UnifiedMemory; +use crate::openhuman::memory_store::namespace_store::UnifiedMemory; /// One-shot guard so the Ollama health-gate fallback only reports to Sentry /// once per process lifetime. Memory is constructed many times per session diff --git a/src/openhuman/memory_store/kv.rs b/src/openhuman/memory_store/kv.rs index 6afd67c1da..d647de6558 100644 --- a/src/openhuman/memory_store/kv.rs +++ b/src/openhuman/memory_store/kv.rs @@ -2,8 +2,8 @@ use tinycortex::memory::store::kv::KvStore; +use crate::openhuman::memory_store::namespace_store::UnifiedMemory; use crate::openhuman::memory_store::types::MemoryKvRecord; -use crate::openhuman::memory_store::unified::UnifiedMemory; impl UnifiedMemory { fn tinycortex_kv(&self) -> Result { diff --git a/src/openhuman/memory_store/memory_trait.rs b/src/openhuman/memory_store/memory_trait.rs index 2ae6037451..c7810c546e 100644 --- a/src/openhuman/memory_store/memory_trait.rs +++ b/src/openhuman/memory_store/memory_trait.rs @@ -17,11 +17,11 @@ use serde_json::json; use crate::openhuman::memory::traits::{ Memory, MemoryCategory, MemoryEntry, MemoryTaint, NamespaceSummary, RecallOpts, }; +use crate::openhuman::memory_store::namespace_store::fts5; use crate::openhuman::memory_store::types::{NamespaceDocumentInput, GLOBAL_NAMESPACE}; -use crate::openhuman::memory_store::unified::fts5; use anyhow::Context; -use super::unified::UnifiedMemory; +use super::namespace_store::UnifiedMemory; /// Convert a UNIX timestamp (f64) to RFC3339 string. fn timestamp_to_rfc3339(ts: f64) -> String { diff --git a/src/openhuman/memory_store/mod.rs b/src/openhuman/memory_store/mod.rs index e3ab38f240..efa65c84f0 100644 --- a/src/openhuman/memory_store/mod.rs +++ b/src/openhuman/memory_store/mod.rs @@ -11,7 +11,8 @@ //! ## Submodules //! //! - `types`: Common data structures and types used across the memory store. -//! - `unified`: The primary SQLite-based memory implementation. +//! - `namespace_store`: Host-retained SQLite namespace documents, graph, +//! episodic/event/segment/profile tables, and their query policy. //! - `client`: High-level client interface for interacting with the memory system. //! - `factories`: Factory functions for creating and initializing memory instances. //! - `memory_trait`: Defines the `Memory` trait that all implementations must satisfy. @@ -21,13 +22,13 @@ pub mod content; pub mod entities; pub mod kinds; pub mod kv; +pub mod namespace_store; pub mod retrieval; pub mod safety; pub mod tools; pub mod traits; pub mod trees; pub mod types; -pub mod unified; pub mod vectors; mod client; @@ -42,16 +43,16 @@ pub use factories::{ active_embedding_signature, create_memory, create_memory_for_migration, create_memory_with_local_ai, effective_embedding_settings, effective_memory_backend_name, }; +pub use namespace_store::events; +pub use namespace_store::fts5; +pub use namespace_store::profile; +pub use namespace_store::segments; +pub use namespace_store::UnifiedMemory; pub use types::{ GraphRelationRecord, MemoryItemKind, MemoryKvRecord, NamespaceDocumentInput, NamespaceMemoryHit, NamespaceQueryResult, NamespaceRetrievalContext, RetrievalScoreBreakdown, StoredMemoryDocument, }; -pub use unified::events; -pub use unified::fts5; -pub use unified::profile; -pub use unified::segments; -pub use unified::UnifiedMemory; #[cfg(test)] mod tests { diff --git a/src/openhuman/memory_store/unified/README.md b/src/openhuman/memory_store/namespace_store/README.md similarity index 86% rename from src/openhuman/memory_store/unified/README.md rename to src/openhuman/memory_store/namespace_store/README.md index da4f5cad5d..30aca9e70e 100644 --- a/src/openhuman/memory_store/unified/README.md +++ b/src/openhuman/memory_store/namespace_store/README.md @@ -1,6 +1,11 @@ -# Unified memory store +# Namespace memory store -SQLite-backed implementation of the memory store. One `UnifiedMemory` struct owns a WAL-mode connection plus the on-disk markdown sidecar tree and vector storage path; the rest of this directory adds capabilities to it via per-domain `impl` blocks. +Host-retained SQLite namespace/document tier. One `UnifiedMemory` struct owns +the shared connection plus the on-disk markdown sidecar and compatibility +embedding handle; the rest of this directory adds the product-owned document, +graph, episodic, event, segment, profile, and retrieval policy via `impl` +blocks. TinyCortex owns the generic chunk/vector/tree/queue engine beside this +tier; this directory is intentionally not migration staging. ## Files diff --git a/src/openhuman/memory_store/unified/documents.rs b/src/openhuman/memory_store/namespace_store/documents.rs similarity index 100% rename from src/openhuman/memory_store/unified/documents.rs rename to src/openhuman/memory_store/namespace_store/documents.rs diff --git a/src/openhuman/memory_store/unified/documents_tests.rs b/src/openhuman/memory_store/namespace_store/documents_tests.rs similarity index 100% rename from src/openhuman/memory_store/unified/documents_tests.rs rename to src/openhuman/memory_store/namespace_store/documents_tests.rs diff --git a/src/openhuman/memory_store/unified/events.rs b/src/openhuman/memory_store/namespace_store/events.rs similarity index 100% rename from src/openhuman/memory_store/unified/events.rs rename to src/openhuman/memory_store/namespace_store/events.rs diff --git a/src/openhuman/memory_store/unified/events_tests.rs b/src/openhuman/memory_store/namespace_store/events_tests.rs similarity index 100% rename from src/openhuman/memory_store/unified/events_tests.rs rename to src/openhuman/memory_store/namespace_store/events_tests.rs diff --git a/src/openhuman/memory_store/unified/fts5.rs b/src/openhuman/memory_store/namespace_store/fts5.rs similarity index 100% rename from src/openhuman/memory_store/unified/fts5.rs rename to src/openhuman/memory_store/namespace_store/fts5.rs diff --git a/src/openhuman/memory_store/unified/graph.rs b/src/openhuman/memory_store/namespace_store/graph.rs similarity index 100% rename from src/openhuman/memory_store/unified/graph.rs rename to src/openhuman/memory_store/namespace_store/graph.rs diff --git a/src/openhuman/memory_store/unified/helpers.rs b/src/openhuman/memory_store/namespace_store/helpers.rs similarity index 100% rename from src/openhuman/memory_store/unified/helpers.rs rename to src/openhuman/memory_store/namespace_store/helpers.rs diff --git a/src/openhuman/memory_store/unified/init.rs b/src/openhuman/memory_store/namespace_store/init.rs similarity index 100% rename from src/openhuman/memory_store/unified/init.rs rename to src/openhuman/memory_store/namespace_store/init.rs diff --git a/src/openhuman/memory_store/unified/mod.rs b/src/openhuman/memory_store/namespace_store/mod.rs similarity index 100% rename from src/openhuman/memory_store/unified/mod.rs rename to src/openhuman/memory_store/namespace_store/mod.rs diff --git a/src/openhuman/memory_store/unified/profile.rs b/src/openhuman/memory_store/namespace_store/profile.rs similarity index 100% rename from src/openhuman/memory_store/unified/profile.rs rename to src/openhuman/memory_store/namespace_store/profile.rs diff --git a/src/openhuman/memory_store/unified/profile_tests.rs b/src/openhuman/memory_store/namespace_store/profile_tests.rs similarity index 100% rename from src/openhuman/memory_store/unified/profile_tests.rs rename to src/openhuman/memory_store/namespace_store/profile_tests.rs diff --git a/src/openhuman/memory_store/unified/query.rs b/src/openhuman/memory_store/namespace_store/query.rs similarity index 100% rename from src/openhuman/memory_store/unified/query.rs rename to src/openhuman/memory_store/namespace_store/query.rs diff --git a/src/openhuman/memory_store/unified/query_tests.rs b/src/openhuman/memory_store/namespace_store/query_tests.rs similarity index 100% rename from src/openhuman/memory_store/unified/query_tests.rs rename to src/openhuman/memory_store/namespace_store/query_tests.rs diff --git a/src/openhuman/memory_store/unified/segments.rs b/src/openhuman/memory_store/namespace_store/segments.rs similarity index 99% rename from src/openhuman/memory_store/unified/segments.rs rename to src/openhuman/memory_store/namespace_store/segments.rs index 8ad43d2b84..b15adca355 100644 --- a/src/openhuman/memory_store/unified/segments.rs +++ b/src/openhuman/memory_store/namespace_store/segments.rs @@ -27,7 +27,7 @@ CREATE TABLE IF NOT EXISTS conversation_segments ( status TEXT NOT NULL DEFAULT 'open', created_at REAL NOT NULL, updated_at REAL NOT NULL, - -- Per-session sequence numbers from memory_archivist::store, populated + -- Per-session sequence numbers from tinycortex::memory::archivist::store, populated -- alongside start_episodic_id / end_episodic_id during the FTS5 -> md -- migration. Once STM recall switches its segment-span dedup to use -- (session_id, seq) the legacy episodic_id columns can be dropped. @@ -108,7 +108,7 @@ pub struct ConversationSegment { pub status: SegmentStatus, pub created_at: f64, pub updated_at: f64, - /// Per-session seq number assigned by `memory_archivist::store::record_turn` + /// Per-session seq number assigned by the TinyCortex archivist store. /// for the user turn that opened this segment. `None` on legacy rows /// written before the FTS5 -> md migration began. #[serde(default)] diff --git a/src/openhuman/memory_store/unified/segments_tests.rs b/src/openhuman/memory_store/namespace_store/segments_tests.rs similarity index 100% rename from src/openhuman/memory_store/unified/segments_tests.rs rename to src/openhuman/memory_store/namespace_store/segments_tests.rs diff --git a/src/openhuman/memory_sync/README.md b/src/openhuman/memory_sync/README.md index 8825b98423..4f82ddadb0 100644 --- a/src/openhuman/memory_sync/README.md +++ b/src/openhuman/memory_sync/README.md @@ -1,82 +1,28 @@ # memory_sync -Every "pull data from upstream → land it in memory_store" pipeline in -one place, organised by the kind of upstream they talk to. - -## Three pipeline kinds - -| Kind | Submodule | Owns | -| --- | --- | --- | -| **Composio** | [`composio/`](composio/) | Per-provider sync via the Composio Edge API: gmail, slack, github, notion, linear, clickup, … | -| **Workspace** | [`workspace/`](workspace/) | Vault file watch, harness turn capture, dictation transcripts — anything local. | -| **MCP** | [`mcp/`](mcp/) | Third-party MCP servers via `mcp_clients/` transport. | - -## Trait - -Every pipeline implements [`SyncPipeline`]: - -```rust -async fn init(&self, &Config) -> anyhow::Result<()>; -async fn tick(&self, &Config) -> anyhow::Result; -fn id(&self) -> &str; -fn kind(&self) -> SyncPipelineKind; -``` - -`SyncOutcome { records_ingested, more_pending, note }` is the -orchestrator-facing result; pipelines own their own pagination cursors -and retry policy behind that. - -## Layout - -| Path | Role | -| --- | --- | -| [`mod.rs`](mod.rs) | Module root + re-exports. | -| [`traits.rs`](traits.rs) | `SyncPipeline`, `SyncOutcome`, `SyncPipelineKind`. | -| [`composio/`](composio/) | Per-provider pipelines (gmail, slack, github, notion, linear, clickup). | -| [`workspace/`](workspace/) | Vault, harness, dictation pipelines. | -| [`mcp/`](mcp/) | MCP-server pipelines (one per connected server). | - -## Schedulers & self-healing - -Three background loops keep memory synced and compressed without manual -"Sync now" clicks: - -| Loop | Module | Cadence | Covers | -| --- | --- | --- | --- | -| Composio periodic | [`composio/periodic.rs`](composio/periodic.rs) | 20-min tick, per-connection interval | Composio connections (gmail, slack, …) | -| Workspace periodic | [`workspace/periodic.rs`](workspace/periodic.rs) | 20-min tick, `memory_sync_interval_secs` (24h default, `0` = manual only) | `github_repo`, `folder`, `rss_feed`, `web_page` sources | -| Queue scheduler | `memory_queue::scheduler` | 3 h | flush stale L0 buffers + requeue transient-failed jobs | - -**Raw-archive coverage** ([`sources/rebuild.rs`](sources/rebuild.rs)): every -summary batch records its raw files in `mem_tree_ingested_sources` -(`source_kind = "raw_file"`). After each sync, `check_and_rebuild_tree` -diffs disk vs gate and summarises only the uncovered remainder — so an -interrupted sync self-heals on the next pass instead of stranding raw -files forever. Inspect/trigger over RPC with -`openhuman.memory_sources_reconcile`. - -⚠️ A source's **tree scope** and **raw-archive id** slugify to different -directories for GitHub (`github:owner/repo` vs `github.com/owner/repo`) — -always carry both (`memory_sources::sync::SourceScope`). - -## Status - -**Pipelines: scaffold only.** Today's sync code still lives in: - -- `composio/providers//ingest.rs` + `bin/{slack_backfill,gmail_backfill_3d}.rs` -- `vault/sync.rs`, `agent_experience/`, `dictation_hotkeys/` -- `mcp_clients/` (transport only; no drain loop yet) - -Each migrates here as its own per-pipeline PR. The job-queue orchestration -in `memory::jobs` stays put — it just gains the ability to iterate over a -registered `Vec>`. - -## Layer rules - -- Sync writes go through `memory::ingest_pipeline` so every record - lands as raw md → chunks → tree leaves like any other ingest. -- No direct writes into trees or unified. No upstream-specific data - models leak past the pipeline boundary. -- One pipeline per upstream service. Composio's GitHub and MCP's GitHub - are distinct pipelines because they hit different surfaces with - different cadence and auth. +OpenHuman orchestration and product policy around the TinyCortex sync engine. + +TinyCortex owns generic Composio provider fetch/pagination, canonical memory +records, sync budgets/state, workspace reconciliation, and persistence traits. +The live host path calls it through `src/openhuman/tinycortex/sync.rs` from +`composio::run_connection_sync` and the default provider `sync()` method. + +OpenHuman retains: + +- periodic scheduling and connection selection; +- credentials and Composio action execution; +- source-scope and redaction policy; +- translation into host `DomainEvent`s; +- JSON-RPC/status/connect surfaces; +- agent-facing action tools and result post-processing; +- product task/profile projections for GitHub, Notion, Linear, and ClickUp; +- local workspace watching and MCP orchestration. + +The provider directories therefore are not alternate sync engines. Their +remaining `provider.rs`, `tools.rs`, `normalization.rs`, profile, catalog, and +post-processing files implement host product surfaces over the crate-backed +sync path. New generic parsing or persistence behavior belongs in +`vendor/tinycortex/src/memory/sync/`. + +D4.1-D4.4 are closed in `docs/tinycortex-drift-ledger.md`. Gmail's bounded +25-message page is crate-owned and prevents Composio 413 responses. diff --git a/src/openhuman/memory_sync/composio/providers/clickup/mod.rs b/src/openhuman/memory_sync/composio/providers/clickup/mod.rs index bbfd3a69f9..fb4680e93b 100644 --- a/src/openhuman/memory_sync/composio/providers/clickup/mod.rs +++ b/src/openhuman/memory_sync/composio/providers/clickup/mod.rs @@ -6,15 +6,15 @@ //! re-learning a new shape: //! //! - `provider.rs` — `impl ComposioProvider for ClickUpProvider` -//! - `sync.rs` — payload-shape helpers (results extraction, title) +//! - `normalization.rs` — payload-shape helpers (results extraction, title) //! - `ingest.rs` — memory_tree document ingest (issue #2885) //! - `tools.rs` — `CLICKUP_CURATED` whitelist of Composio actions //! - `tests.rs` — unit tests for the helpers + trait metadata //! //! Issue: #2288 (introduction); #2885 (memory_tree migration). +mod normalization; mod provider; -mod sync; #[cfg(test)] mod tests; pub mod tools; diff --git a/src/openhuman/memory_sync/composio/providers/clickup/sync.rs b/src/openhuman/memory_sync/composio/providers/clickup/normalization.rs similarity index 98% rename from src/openhuman/memory_sync/composio/providers/clickup/sync.rs rename to src/openhuman/memory_sync/composio/providers/clickup/normalization.rs index c7000c93bb..fb0629019b 100644 --- a/src/openhuman/memory_sync/composio/providers/clickup/sync.rs +++ b/src/openhuman/memory_sync/composio/providers/clickup/normalization.rs @@ -1,4 +1,4 @@ -//! ClickUp sync helpers — result extraction, task-title extraction, +//! ClickUp host normalization helpers — result extraction, task-title extraction, //! and time utilities. //! //! ClickUp's REST API (and therefore Composio's wrapping of it) returns diff --git a/src/openhuman/memory_sync/composio/providers/clickup/provider.rs b/src/openhuman/memory_sync/composio/providers/clickup/provider.rs index b31536aa26..7637d01594 100644 --- a/src/openhuman/memory_sync/composio/providers/clickup/provider.rs +++ b/src/openhuman/memory_sync/composio/providers/clickup/provider.rs @@ -27,7 +27,7 @@ use async_trait::async_trait; use serde_json::json; -use super::sync; +use super::normalization; use crate::openhuman::memory_sync::composio::providers::{ first_array_str, merge_extra, pick_str, resolve_sync_interval_secs, ComposioProvider, CuratedTool, NormalizedTask, ProviderContext, ProviderUserProfile, TaskFetchFilter, TaskKind, @@ -105,7 +105,7 @@ impl ComposioProvider for ClickUpProvider { let data = &resp.data; let display_name = pick_str(data, &["user.username", "data.user.username", "username"]); let email = pick_str(data, &["user.email", "data.user.email", "email"]); - let username = sync::extract_user_id(data); + let username = normalization::extract_user_id(data); let avatar_url = pick_str( data, &[ @@ -168,7 +168,7 @@ impl ComposioProvider for ClickUpProvider { resp.error.unwrap_or_else(|| "provider failure".into()) )); } - sync::extract_workspace_ids(&resp.data) + normalization::extract_workspace_ids(&resp.data) } }; @@ -188,7 +188,7 @@ impl ComposioProvider for ClickUpProvider { resp.error.unwrap_or_else(|| "provider failure".into()) )); } - let id = sync::extract_user_id(&resp.data).ok_or_else(|| { + let id = normalization::extract_user_id(&resp.data).ok_or_else(|| { "[composio:clickup] CLICKUP_GET_AUTHORIZED_USER returned no user.id".to_string() })?; vec![id] @@ -227,7 +227,7 @@ impl ComposioProvider for ClickUpProvider { )); } - for task in sync::extract_tasks(&resp.data) { + for task in normalization::extract_tasks(&resp.data) { if out.len() >= max { break 'workspaces; } @@ -246,8 +246,8 @@ impl ComposioProvider for ClickUpProvider { /// `None` only when the task has no extractable id (unroutable). fn normalize_clickup_task(task: &serde_json::Value) -> Option { let external_id = pick_str(task, TASK_ID_PATHS)?; - let title = - sync::extract_task_name(task).unwrap_or_else(|| format!("ClickUp task {external_id}")); + let title = normalization::extract_task_name(task) + .unwrap_or_else(|| format!("ClickUp task {external_id}")); Some(NormalizedTask { external_id, source_id: String::new(), @@ -265,7 +265,7 @@ fn normalize_clickup_task(task: &serde_json::Value) -> Option { due: pick_str(task, &["due_date", "data.due_date"]), labels: Vec::new(), priority: pick_str(task, &["priority.priority", "data.priority.priority"]), - updated_at: sync::extract_task_updated(task), + updated_at: normalization::extract_task_updated(task), raw: task.clone(), }) } diff --git a/src/openhuman/memory_sync/composio/providers/clickup/tests.rs b/src/openhuman/memory_sync/composio/providers/clickup/tests.rs index 6552e7faed..e6cd24967a 100644 --- a/src/openhuman/memory_sync/composio/providers/clickup/tests.rs +++ b/src/openhuman/memory_sync/composio/providers/clickup/tests.rs @@ -1,6 +1,6 @@ //! Unit tests for the ClickUp provider. -use super::sync::{ +use super::normalization::{ extract_task_name, extract_task_updated, extract_tasks, extract_user_id, extract_workspace_ids, }; use super::ClickUpProvider; diff --git a/src/openhuman/memory_sync/composio/providers/github/mod.rs b/src/openhuman/memory_sync/composio/providers/github/mod.rs index 94c62d12fb..29755ce55d 100644 --- a/src/openhuman/memory_sync/composio/providers/github/mod.rs +++ b/src/openhuman/memory_sync/composio/providers/github/mod.rs @@ -6,14 +6,14 @@ //! re-learning a new shape: //! //! - `provider.rs` — `impl ComposioProvider for GitHubProvider` -//! - `sync.rs` — payload-shape helpers (result extraction, title, cursor) +//! - `normalization.rs` — payload-shape helpers (result extraction, title, cursor) //! - `tools.rs` — `GITHUB_CURATED` whitelist of Composio actions //! - `tests.rs` — unit tests for the helpers + trait metadata //! //! Issue: #2408. +mod normalization; mod provider; -mod sync; #[cfg(test)] mod tests; pub mod tools; diff --git a/src/openhuman/memory_sync/composio/providers/github/sync.rs b/src/openhuman/memory_sync/composio/providers/github/normalization.rs similarity index 98% rename from src/openhuman/memory_sync/composio/providers/github/sync.rs rename to src/openhuman/memory_sync/composio/providers/github/normalization.rs index e17ad23a90..ccd52770ff 100644 --- a/src/openhuman/memory_sync/composio/providers/github/sync.rs +++ b/src/openhuman/memory_sync/composio/providers/github/normalization.rs @@ -1,4 +1,4 @@ -//! GitHub sync helpers — result extraction, identity helpers, and time utilities. +//! GitHub host normalization helpers — result extraction, identity helpers, and time utilities. //! //! GitHub's REST API (proxied through Composio) returns search results and //! authenticated-user payloads in a small number of shapes. The functions here diff --git a/src/openhuman/memory_sync/composio/providers/github/provider.rs b/src/openhuman/memory_sync/composio/providers/github/provider.rs index caea4a951a..6d1d1b9126 100644 --- a/src/openhuman/memory_sync/composio/providers/github/provider.rs +++ b/src/openhuman/memory_sync/composio/providers/github/provider.rs @@ -23,7 +23,7 @@ use async_trait::async_trait; use serde_json::{json, Value}; use std::time::Duration; -use super::sync; +use super::normalization; use crate::openhuman::memory_sync::composio::providers::{ merge_extra, pick_str, resolve_sync_interval_secs, ComposioProvider, CuratedTool, GithubFetchMode, NormalizedTask, ProviderContext, ProviderUserProfile, TaskFetchFilter, @@ -94,7 +94,7 @@ impl ComposioProvider for GitHubProvider { } let data = &resp.data; - let login = sync::extract_user_login(data); + let login = normalization::extract_user_login(data); let display_name = pick_str(data, &["name", "data.name"]).or_else(|| login.clone()); let email = pick_str(data, &["email", "data.email"]); let avatar_url = pick_str(data, &["avatar_url", "data.avatar_url"]); @@ -157,7 +157,7 @@ impl ComposioProvider for GitHubProvider { }; let mut out: Vec = Vec::new(); - for issue in sync::extract_issues(&data) { + for issue in normalization::extract_issues(&data) { if out.len() >= max { break; } @@ -481,7 +481,7 @@ pub(super) fn normalize_github_repo_filter(raw: &str) -> String { /// not depend on the fetch query), so even if a `closed` item slips through /// the query bias it is dropped here. pub(super) fn normalize_github_issue(issue: &serde_json::Value) -> Option { - let external_id = sync::extract_issue_id(issue)?; + let external_id = normalization::extract_issue_id(issue)?; let status = pick_str(issue, &["state", "data.state"]); if status .as_deref() @@ -494,8 +494,8 @@ pub(super) fn normalize_github_issue(issue: &serde_json::Value) -> Option Option Vec { - let candidates = [ - data.pointer("/data/messages"), - data.pointer("/messages"), - data.pointer("/data/data/messages"), - data.pointer("/data/items"), - data.pointer("/items"), - ]; - for cand in candidates.into_iter().flatten() { - if let Some(arr) = cand.as_array() { - return arr.clone(); - } - } - Vec::new() -} - -/// Try to extract a pagination token from the API response. -pub(crate) fn extract_page_token(data: &Value) -> Option { - let candidates = [ - data.pointer("/data/nextPageToken"), - data.pointer("/nextPageToken"), - data.pointer("/data/data/nextPageToken"), - ]; - for cand in candidates.into_iter().flatten() { - if let Some(s) = cand.as_str() { - let trimmed = s.trim(); - if !trimmed.is_empty() { - return Some(trimmed.to_string()); - } - } - } - None -} - -/// Convert a cursor value (epoch millis or date string) into a Gmail -/// `after:YYYY/MM/DD` filter component. Day-level precision — kept as a -/// last-resort filter for non-numeric cursors and for back-compat with -/// the older day-cursor write path. New code should prefer -/// [`cursor_to_gmail_after_epoch_filter`] which produces a -/// second-precision `after:` filter and so avoids re-fetching -/// large same-day windows on every tick. -pub(crate) fn cursor_to_gmail_after_filter(cursor: &str) -> Option { - let cursor = cursor.trim(); - // Try parsing as epoch millis first (Gmail's internalDate). - if let Ok(millis) = cursor.parse::() { - let secs = millis / 1000; - if let Some(dt) = chrono::DateTime::from_timestamp(secs, 0) { - return Some(dt.format("%Y/%m/%d").to_string()); - } - } - // Try parsing as an ISO date/datetime. - if let Ok(dt) = chrono::NaiveDate::parse_from_str(cursor, "%Y-%m-%d") { - return Some(dt.format("%Y/%m/%d").to_string()); - } - if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(cursor) { - return Some(dt.format("%Y/%m/%d").to_string()); - } - None -} - -/// Convert a cursor value (epoch millis or date string) into a Gmail -/// `after:` filter component. Gmail's search syntax -/// accepts a bare unix-seconds value for `after:` / `before:`, so the -/// filter is second-precision rather than the day-level -/// `after:YYYY/MM/DD` form. Used by the incremental sync path so a -/// same-day re-tick does not re-fetch every message Gmail has filed -/// today. -/// -/// Returns `None` when the cursor cannot be parsed; callers should -/// fall back to the coarse day filter to avoid sending an unbounded -/// query. -pub(crate) fn cursor_to_gmail_after_epoch_filter(cursor: &str) -> Option { - let secs = parse_cursor_to_epoch_secs(cursor)?; - Some(secs.to_string()) -} - -/// Parse a cursor (epoch millis as string, `YYYY-MM-DD`, or RFC3339) -/// into unix-seconds. Shared by the epoch filter and by the adaptive -/// page-cap recency check. -pub(crate) fn parse_cursor_to_epoch_secs(cursor: &str) -> Option { - let cursor = cursor.trim(); - if let Ok(millis) = cursor.parse::() { - return Some(millis / 1000); - } - if let Ok(date) = chrono::NaiveDate::parse_from_str(cursor, "%Y-%m-%d") { - return date.and_hms_opt(0, 0, 0).map(|dt| dt.and_utc().timestamp()); - } - if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(cursor) { - return Some(dt.timestamp()); - } - None -} - -pub(crate) fn now_ms() -> u64 { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn extract_messages_from_data_messages() { - let data = json!({"data": {"messages": [{"id": "1"}, {"id": "2"}]}}); - let msgs = extract_messages(&data); - assert_eq!(msgs.len(), 2); - } - - #[test] - fn extract_messages_from_top_level() { - let data = json!({"messages": [{"id": "1"}]}); - let msgs = extract_messages(&data); - assert_eq!(msgs.len(), 1); - } - - #[test] - fn extract_messages_from_data_items() { - let data = json!({"data": {"items": [{"id": "a"}]}}); - let msgs = extract_messages(&data); - assert_eq!(msgs.len(), 1); - } - - #[test] - fn extract_messages_empty_when_no_match() { - let data = json!({"foo": "bar"}); - assert!(extract_messages(&data).is_empty()); - } - - #[test] - fn extract_page_token_from_data() { - let data = json!({"data": {"nextPageToken": "abc123"}}); - assert_eq!(extract_page_token(&data), Some("abc123".into())); - } - - #[test] - fn extract_page_token_from_top_level() { - let data = json!({"nextPageToken": "tok"}); - assert_eq!(extract_page_token(&data), Some("tok".into())); - } - - #[test] - fn extract_page_token_none_when_empty() { - let data = json!({"data": {"nextPageToken": " "}}); - assert_eq!(extract_page_token(&data), None); - } - - #[test] - fn extract_page_token_none_when_missing() { - let data = json!({"data": {}}); - assert_eq!(extract_page_token(&data), None); - } - - #[test] - fn cursor_to_filter_epoch_millis() { - let filter = cursor_to_gmail_after_filter("1700000000000").unwrap(); - assert!(filter.contains('/')); - assert_eq!(filter, "2023/11/14"); - } - - #[test] - fn cursor_to_filter_iso_date() { - let filter = cursor_to_gmail_after_filter("2024-01-15").unwrap(); - assert_eq!(filter, "2024/01/15"); - } - - #[test] - fn cursor_to_filter_rfc3339() { - let filter = cursor_to_gmail_after_filter("2024-06-01T12:00:00Z").unwrap(); - assert_eq!(filter, "2024/06/01"); - } - - #[test] - fn cursor_to_filter_invalid_returns_none() { - assert!(cursor_to_gmail_after_filter("not-a-date").is_none()); - } - - #[test] - fn cursor_to_filter_trims_whitespace() { - let filter = cursor_to_gmail_after_filter(" 2024-01-15 ").unwrap(); - assert_eq!(filter, "2024/01/15"); - } - - #[test] - fn now_ms_returns_nonzero() { - assert!(now_ms() > 0); - } - - // ── second-precision cursor ────────────────────────────────── - - #[test] - fn epoch_filter_emits_unix_seconds_for_internal_date_millis() { - let filter = cursor_to_gmail_after_epoch_filter("1700000000000").unwrap(); - assert_eq!(filter, "1700000000"); - } - - #[test] - fn epoch_filter_handles_iso_date() { - let filter = cursor_to_gmail_after_epoch_filter("2024-01-15").unwrap(); - // 2024-01-15 00:00:00 UTC == 1705276800. - assert_eq!(filter, "1705276800"); - } - - #[test] - fn epoch_filter_handles_rfc3339() { - let filter = cursor_to_gmail_after_epoch_filter("2024-06-01T12:00:00Z").unwrap(); - assert_eq!(filter, "1717243200"); - } - - #[test] - fn epoch_filter_returns_none_for_garbage() { - assert!(cursor_to_gmail_after_epoch_filter("not-a-date").is_none()); - } - - #[test] - fn epoch_filter_trims_whitespace() { - let filter = cursor_to_gmail_after_epoch_filter(" 2024-01-15 ").unwrap(); - assert_eq!(filter, "1705276800"); - } - - #[test] - fn parse_cursor_round_trip_matches_epoch_filter() { - // The adaptive page-cap relies on parse_cursor_to_epoch_secs - // agreeing with cursor_to_gmail_after_epoch_filter — both must - // emit the same seconds value for any given input. - for cursor in ["1700000000000", "2024-01-15", "2024-06-01T12:00:00Z"] { - let secs = parse_cursor_to_epoch_secs(cursor).unwrap(); - let filter = cursor_to_gmail_after_epoch_filter(cursor).unwrap(); - assert_eq!(filter, secs.to_string(), "cursor `{cursor}`"); - } - } -} diff --git a/src/openhuman/memory_sync/composio/providers/gmail/tests.rs b/src/openhuman/memory_sync/composio/providers/gmail/tests.rs index fe0ab84f30..b040455395 100644 --- a/src/openhuman/memory_sync/composio/providers/gmail/tests.rs +++ b/src/openhuman/memory_sync/composio/providers/gmail/tests.rs @@ -1,301 +1,46 @@ -//! Unit tests for the Gmail provider. +//! Host-owned Gmail provider surface tests. +//! +//! Pagination, cursor, envelope parsing, and ingest behavior are owned and +//! tested by `tinycortex::memory::sync::GmailSyncPipeline`. use super::provider::{BASE_QUERY, SENT_QUERIES}; -use super::sync::{ - cursor_to_gmail_after_epoch_filter, cursor_to_gmail_after_filter, extract_messages, - extract_page_token, now_ms, parse_cursor_to_epoch_secs, -}; use super::GmailProvider; use crate::openhuman::memory_sync::composio::providers::ComposioProvider; -use serde_json::json; - -#[test] -fn extract_messages_finds_data_messages() { - let v = json!({ - "data": { "messages": [{"id": "m1"}, {"id": "m2"}] }, - "successful": true, - }); - assert_eq!(extract_messages(&v).len(), 2); -} - -#[test] -fn extract_messages_finds_top_level_messages() { - let v = json!({ "messages": [{"id": "m1"}] }); - assert_eq!(extract_messages(&v).len(), 1); -} - -#[test] -fn extract_messages_returns_empty_when_missing() { - let v = json!({ "data": { "other": [] } }); - assert_eq!(extract_messages(&v).len(), 0); -} - -#[test] -fn extract_page_token_finds_nested() { - let v = json!({ "data": { "nextPageToken": "tok123" } }); - assert_eq!(extract_page_token(&v), Some("tok123".to_string())); -} - -#[test] -fn extract_page_token_none_when_missing() { - let v = json!({ "data": {} }); - assert_eq!(extract_page_token(&v), None); -} - -#[test] -fn cursor_to_filter_from_epoch_millis() { - // 1774915200000 ms = 2026-03-31 UTC - let millis = "1774915200000"; - assert_eq!( - cursor_to_gmail_after_filter(millis), - Some("2026/03/31".to_string()) - ); -} - -#[test] -fn cursor_to_filter_from_iso_date() { - assert_eq!( - cursor_to_gmail_after_filter("2026-03-15"), - Some("2026/03/15".to_string()) - ); -} - -#[test] -fn cursor_to_filter_from_rfc3339() { - let f = cursor_to_gmail_after_filter("2026-03-15T12:00:00Z"); - assert_eq!(f, Some("2026/03/15".to_string())); -} - -#[test] -fn cursor_to_filter_returns_none_for_garbage() { - assert_eq!(cursor_to_gmail_after_filter("not-a-date"), None); -} #[test] fn provider_metadata_is_stable() { - let p = GmailProvider::new(); - assert_eq!(p.toolkit_slug(), "gmail"); - assert_eq!(p.sync_interval_secs(), Some(15 * 60)); + let provider = GmailProvider::new(); + assert_eq!(provider.toolkit_slug(), "gmail"); + assert_eq!(provider.sync_interval_secs(), Some(15 * 60)); } #[test] fn default_impl_matches_new() { - let _a = GmailProvider::new(); - let _b = GmailProvider::default(); - // Both are unit structs — constructing via Default is the cover target. + let _new = GmailProvider::new(); + let _default = GmailProvider::default(); } -#[test] -fn epoch_filter_is_preferred_over_day_filter_for_typical_internal_date() { - // The provider tries `cursor_to_gmail_after_epoch_filter` first - // and only falls back to the day filter if the parse fails. Both - // helpers must accept the same internalDate (epoch millis) input - // so the fallback path is genuinely a fallback, not a divergence. - let internal_date = "1774915200000"; // 2026-03-31 UTC - let epoch = cursor_to_gmail_after_epoch_filter(internal_date).unwrap(); - let day = cursor_to_gmail_after_filter(internal_date).unwrap(); - assert_eq!(epoch, "1774915200"); - assert_eq!(day, "2026/03/31"); - // Sanity bound: the epoch filter must be after 2020 and before - // year 2100, otherwise we shipped a regression in the cursor - // converter that would silently let queries land on year 1970. - let secs: i64 = epoch.parse().unwrap(); - assert!( - secs > 1_577_836_800, - "epoch filter must be after 2020-01-01" - ); - assert!( - secs < 4_102_444_800, - "epoch filter must be before 2100-01-01" - ); -} - -// ── Adaptive page cap and early-stop helpers (issue#1404, pr#1474) ────────── -// -// The full `sync()` path needs a live ComposioClient + MemoryClient, so -// we test the helper functions that gate the adaptive cap and early-stop -// decisions: -// -// * `parse_cursor_to_epoch_secs` — used to decide whether `last_sync_at_ms` -// falls within `RECENT_SYNC_WINDOW_MS` (5 min) for the adaptive page cap. -// * `now_ms` — sanity check: must not return 0 and must be within a plausible -// range so the adaptive window comparison never produces pathological results. -// * early-stop guard: when `last_seen_id` matches the first page's head id -// the sync loop breaks with `stop_reason = "head_unchanged"`. We pin the -// helper logic that feeds this decision. - -#[test] -fn parse_cursor_to_epoch_secs_handles_epoch_millis() { - // Gmail internalDate is epoch milliseconds as a numeric string. - // 1774915200000 ms = 1774915200 s (2026-03-31 00:00:00 UTC). - assert_eq!( - parse_cursor_to_epoch_secs("1774915200000"), - Some(1774915200) - ); -} - -#[test] -fn parse_cursor_to_epoch_secs_handles_iso_date() { - // YYYY-MM-DD date cursor produced by the older day-cursor write path. - let secs = parse_cursor_to_epoch_secs("2024-01-15").unwrap(); - // 2024-01-15 00:00:00 UTC = 1705276800 - assert_eq!(secs, 1705276800); -} - -#[test] -fn parse_cursor_to_epoch_secs_handles_rfc3339() { - let secs = parse_cursor_to_epoch_secs("2024-01-15T00:00:00Z").unwrap(); - assert_eq!(secs, 1705276800); -} - -#[test] -fn parse_cursor_to_epoch_secs_returns_none_for_garbage() { - assert_eq!(parse_cursor_to_epoch_secs("not-a-timestamp"), None); - assert_eq!(parse_cursor_to_epoch_secs(""), None); - assert_eq!(parse_cursor_to_epoch_secs(" "), None); -} - -/// The adaptive page cap relies on `parse_cursor_to_epoch_secs` and `now_ms` -/// agreeing on a common epoch so the "less than 5 min ago" comparison works. -/// `now_ms()` must return epoch-milliseconds (not zero, not micros). If it -/// returned microseconds, every sync would appear "recent" (delta < 300_000 ms -/// vs delta actually being ~ 1e12 µs); if it returned seconds, every sync -/// would appear "old" (delta > 300_000 ms trivially). -#[test] -fn now_ms_is_in_epoch_milliseconds_range() { - let ms = now_ms(); - // Must be strictly positive. - assert!(ms > 0, "now_ms must not return zero"); - // Must be > 2024-01-01 00:00:00 UTC in milliseconds so it's clearly - // millisecond-epoch and not seconds-epoch (which would be ~1.7e9, much - // smaller than 1.7e12). - let jan_2024_ms: u64 = 1_704_067_200_000; - assert!( - ms > jan_2024_ms, - "now_ms ({ms}) must be above 2024-01-01 in epoch-millisecond scale" - ); - // Must be < year 2100 in milliseconds — rules out microseconds/nanoseconds. - let year_2100_ms: u64 = 4_102_444_800_000; - assert!( - ms < year_2100_ms, - "now_ms ({ms}) must be below year 2100 in epoch-millisecond scale" - ); -} - -/// The early-stop optimisation fires when `last_seen_id` equals the first -/// message id on the first page. We test the helper that extracts message ids -/// — `extract_messages` — to verify it correctly surfaces the `id` field so -/// the comparison in the sync loop gets the right value. -/// -/// The early-stop check uses `messages.first()` with the `MESSAGE_ID_PATHS` -/// extractor. We can't call the private extractor, but we can pin -/// `extract_messages` to return messages with their `id` intact so the -/// sync loop can compare them to `state.last_seen_id`. -#[test] -fn extract_messages_preserves_id_field_for_early_stop() { - // The early-stop check reads `m["id"]` via `extract_item_id`. Verify - // `extract_messages` doesn't strip or transform the field. - let v = json!({ - "data": { - "messages": [ - {"id": "msg_abc", "internalDate": "1774915200000"}, - {"id": "msg_def", "internalDate": "1774915100000"} - ] - }, - "successful": true - }); - let msgs = extract_messages(&v); - assert_eq!(msgs.len(), 2); - assert_eq!( - msgs[0]["id"], "msg_abc", - "first message id must be preserved" - ); - assert_eq!( - msgs[1]["id"], "msg_def", - "second message id must be preserved" - ); -} - -/// Variant: messages embedded in `data.data.messages` (deeper nesting -/// seen in some Composio provider responses) — the extractor must still -/// find them so the early-stop comparison has data to work with. -#[test] -fn extract_messages_handles_deep_nesting() { - let v = json!({ - "data": { - "data": { - "messages": [ - {"id": "deep_msg_1"} - ] - } - } - }); - let msgs = extract_messages(&v); - assert_eq!(msgs.len(), 1); - assert_eq!(msgs[0]["id"], "deep_msg_1"); -} - -// Note: full `sync` / `fetch_user_profile` / `on_trigger` paths require a -// live `ComposioClient` (HTTP) plus the global `MemoryClient` singleton. -// Those go through the integration test suite. Here we just lock in -// the provider's identity surface and helpers. - -// ── Regression tests for issue #1713: sent-mail retrieval ─────────────────── -// -// Before the fix the sync query was `in:inbox -in:spam -in:trash`, which meant -// sent emails (label:SENT) were never fetched or ingested into the memory tree. -// The fix removes `in:inbox` so both inbox and sent mail are fetched. - -/// Guard: the provider source must NOT contain the `in:inbox` restriction. -/// If this test fails, someone reintroduced the inbox-only query that caused -/// issue #1713. #[test] fn provider_source_does_not_restrict_to_inbox() { let source = include_str!("provider.rs"); - // The old restriction started with `"in:inbox`. The double-quote is part - // of the Rust string literal, so this catches the exact regression pattern. assert!( !source.contains("\"in:inbox"), - "provider.rs sync query must NOT start with 'in:inbox' — this \ - restriction prevents sent emails from being ingested (issue #1713). \ - Use '-in:spam -in:trash' to allow both inbox and sent mail." + "provider query must not exclude sent mail" ); } -/// The base sync query must exclude spam and trash but NOT restrict to inbox. -/// Asserts against the canonical `BASE_QUERY` constant from provider.rs so -/// any change to the production value is caught immediately. #[test] -fn sync_base_query_excludes_spam_and_trash_without_inbox_restriction() { - assert!( - BASE_QUERY.contains("-in:spam"), - "BASE_QUERY must exclude spam (got: {BASE_QUERY:?})" - ); - assert!( - BASE_QUERY.contains("-in:trash"), - "BASE_QUERY must exclude trash (got: {BASE_QUERY:?})" - ); - assert!( - !BASE_QUERY.contains("in:inbox"), - "BASE_QUERY must NOT restrict to inbox — omitting this allows sent \ - mail to be fetched and ingested (issue #1713). Got: {BASE_QUERY:?}" - ); +fn base_query_excludes_spam_and_trash_without_inbox_restriction() { + assert!(BASE_QUERY.contains("-in:spam")); + assert!(BASE_QUERY.contains("-in:trash")); + assert!(!BASE_QUERY.contains("in:inbox")); } -/// Sent-mail query strings must be non-empty and must not restrict to inbox. -/// Iterates `SENT_QUERIES` from provider.rs — the canonical list of query -/// strings a user or agent can pass to GMAIL_FETCH_EMAILS for sent mail. #[test] fn sent_mail_query_strings_are_well_formed() { - assert!(!SENT_QUERIES.is_empty(), "SENT_QUERIES must not be empty"); - for q in SENT_QUERIES { - assert!( - !q.is_empty(), - "sent-mail query must not be empty, got empty string in SENT_QUERIES" - ); - assert!( - !q.starts_with("in:inbox"), - "sent-mail query '{q}' must not restrict to inbox" - ); + assert!(!SENT_QUERIES.is_empty()); + for query in SENT_QUERIES { + assert!(!query.is_empty()); + assert!(!query.starts_with("in:inbox")); } } diff --git a/src/openhuman/memory_sync/composio/providers/linear/mod.rs b/src/openhuman/memory_sync/composio/providers/linear/mod.rs index d744dbe6a7..971e9e4dba 100644 --- a/src/openhuman/memory_sync/composio/providers/linear/mod.rs +++ b/src/openhuman/memory_sync/composio/providers/linear/mod.rs @@ -3,8 +3,8 @@ //! //! Issue: #2400. +mod normalization; mod provider; -mod sync; #[cfg(test)] mod tests; pub mod tools; diff --git a/src/openhuman/memory_sync/composio/providers/linear/sync.rs b/src/openhuman/memory_sync/composio/providers/linear/normalization.rs similarity index 99% rename from src/openhuman/memory_sync/composio/providers/linear/sync.rs rename to src/openhuman/memory_sync/composio/providers/linear/normalization.rs index 952943e3b4..59100ff375 100644 --- a/src/openhuman/memory_sync/composio/providers/linear/sync.rs +++ b/src/openhuman/memory_sync/composio/providers/linear/normalization.rs @@ -1,4 +1,4 @@ -//! Linear sync helpers — result extraction, issue-title extraction, +//! Linear host normalization helpers — result extraction, issue-title extraction, //! viewer identity, cursor extraction, and time utilities. //! //! Linear's GraphQL API (and therefore Composio's wrapping of it) returns diff --git a/src/openhuman/memory_sync/composio/providers/linear/provider.rs b/src/openhuman/memory_sync/composio/providers/linear/provider.rs index 41547ad599..5f207e9c46 100644 --- a/src/openhuman/memory_sync/composio/providers/linear/provider.rs +++ b/src/openhuman/memory_sync/composio/providers/linear/provider.rs @@ -21,7 +21,7 @@ use async_trait::async_trait; use serde_json::json; -use super::sync; +use super::normalization; use crate::openhuman::memory_sync::composio::providers::{ merge_extra, pick_str, resolve_sync_interval_secs, ComposioProvider, CuratedTool, NormalizedTask, ProviderContext, ProviderUserProfile, TaskFetchFilter, TaskKind, @@ -86,7 +86,7 @@ impl ComposioProvider for LinearProvider { } let data = &resp.data; - let viewer = sync::extract_viewer(data); + let viewer = normalization::extract_viewer(data); let viewer_ref = viewer.as_ref().unwrap_or(data); let display_name = pick_str(viewer_ref, &["name", "data.name", "displayName"]); @@ -143,7 +143,7 @@ impl ComposioProvider for LinearProvider { resp.error.unwrap_or_else(|| "provider failure".into()) )); } - let viewer_id = sync::extract_viewer_id(&resp.data).ok_or_else(|| { + let viewer_id = normalization::extract_viewer_id(&resp.data).ok_or_else(|| { "[composio:linear] LINEAR_LIST_LINEAR_USERS returned no viewer id".to_string() })?; args["assigneeId"] = json!(viewer_id); @@ -176,7 +176,7 @@ impl ComposioProvider for LinearProvider { .filter(|s| !s.is_empty()); let mut out: Vec = Vec::new(); - for issue in sync::extract_issues(&resp.data) { + for issue in normalization::extract_issues(&resp.data) { if out.len() >= max { break; } @@ -203,8 +203,8 @@ impl ComposioProvider for LinearProvider { /// Map a raw Linear issue payload into a [`NormalizedTask`]. fn normalize_linear_issue(issue: &serde_json::Value) -> Option { let external_id = pick_str(issue, ISSUE_ID_PATHS)?; - let title = - sync::extract_issue_title(issue).unwrap_or_else(|| format!("Linear issue {external_id}")); + let title = normalization::extract_issue_title(issue) + .unwrap_or_else(|| format!("Linear issue {external_id}")); Some(NormalizedTask { external_id, source_id: String::new(), @@ -218,7 +218,7 @@ fn normalize_linear_issue(issue: &serde_json::Value) -> Option { due: pick_str(issue, &["dueDate", "data.dueDate"]), labels: extract_linear_labels(issue), priority: pick_str(issue, &["priorityLabel", "data.priorityLabel"]), - updated_at: sync::extract_issue_updated(issue), + updated_at: normalization::extract_issue_updated(issue), raw: issue.clone(), }) } diff --git a/src/openhuman/memory_sync/composio/providers/linear/tests.rs b/src/openhuman/memory_sync/composio/providers/linear/tests.rs index ed605c720f..d8a805013d 100644 --- a/src/openhuman/memory_sync/composio/providers/linear/tests.rs +++ b/src/openhuman/memory_sync/composio/providers/linear/tests.rs @@ -1,6 +1,6 @@ //! Unit tests for the Linear provider. -use super::sync::{ +use super::normalization::{ extract_issue_title, extract_issue_updated, extract_issues, extract_pagination_cursor, extract_viewer, extract_viewer_id, }; diff --git a/src/openhuman/memory_sync/composio/providers/notion/mod.rs b/src/openhuman/memory_sync/composio/providers/notion/mod.rs index 427b6fb3f2..07d2a73e3b 100644 --- a/src/openhuman/memory_sync/composio/providers/notion/mod.rs +++ b/src/openhuman/memory_sync/composio/providers/notion/mod.rs @@ -1,5 +1,5 @@ +mod normalization; mod provider; -mod sync; #[cfg(test)] mod tests; pub mod tools; diff --git a/src/openhuman/memory_sync/composio/providers/notion/sync.rs b/src/openhuman/memory_sync/composio/providers/notion/normalization.rs similarity index 99% rename from src/openhuman/memory_sync/composio/providers/notion/sync.rs rename to src/openhuman/memory_sync/composio/providers/notion/normalization.rs index 79a8729cdf..a3ff5745e1 100644 --- a/src/openhuman/memory_sync/composio/providers/notion/sync.rs +++ b/src/openhuman/memory_sync/composio/providers/notion/normalization.rs @@ -1,4 +1,4 @@ -//! Notion sync helpers — result extraction, pagination cursor, +//! Notion host normalization helpers — result extraction, pagination cursor, //! page title extraction, and time utilities. use serde_json::Value; diff --git a/src/openhuman/memory_sync/composio/providers/notion/provider.rs b/src/openhuman/memory_sync/composio/providers/notion/provider.rs index 1006a0d6ae..fcbfb6dce9 100644 --- a/src/openhuman/memory_sync/composio/providers/notion/provider.rs +++ b/src/openhuman/memory_sync/composio/providers/notion/provider.rs @@ -18,7 +18,7 @@ use async_trait::async_trait; use serde_json::{json, Value}; -use super::sync; +use super::normalization; use crate::openhuman::memory_sync::composio::providers::{ first_array_str, merge_extra, pick_str, resolve_sync_interval_secs, ComposioProvider, CuratedTool, NormalizedTask, ProviderContext, ProviderUserProfile, TaskContainer, @@ -194,7 +194,7 @@ impl ComposioProvider for NotionProvider { .filter(|s| !s.is_empty()); let mut out: Vec = Vec::new(); - for page in sync::extract_results(&resp.data) { + for page in normalization::extract_results(&resp.data) { if out.len() >= max { break; } @@ -302,8 +302,8 @@ impl ComposioProvider for NotionProvider { /// preserved for enrichment. fn normalize_notion_page(page: &serde_json::Value) -> Option { let external_id = pick_str(page, PAGE_ID_PATHS)?; - let title = - sync::extract_page_title(page).unwrap_or_else(|| format!("Notion page {external_id}")); + let title = normalization::extract_page_title(page) + .unwrap_or_else(|| format!("Notion page {external_id}")); Some(NormalizedTask { external_id, source_id: String::new(), @@ -358,7 +358,7 @@ fn normalize_notion_page(page: &serde_json::Value) -> Option { /// "keep only object==database" check silently dropped every database. /// Pure (no I/O) so it is unit-testable. pub(super) fn parse_database_results(data: &serde_json::Value) -> Vec { - let results = sync::extract_results(data); + let results = normalization::extract_results(data); let mut kinds: std::collections::BTreeMap = std::collections::BTreeMap::new(); let mut out: Vec = Vec::new(); for item in &results { diff --git a/src/openhuman/memory_sync/composio/providers/notion/tests.rs b/src/openhuman/memory_sync/composio/providers/notion/tests.rs index 880b08e419..8771301aa3 100644 --- a/src/openhuman/memory_sync/composio/providers/notion/tests.rs +++ b/src/openhuman/memory_sync/composio/providers/notion/tests.rs @@ -1,6 +1,6 @@ //! Unit tests for the Notion provider. -use super::sync::{extract_notion_cursor, extract_page_title, extract_results}; +use super::normalization::{extract_notion_cursor, extract_page_title, extract_results}; use super::NotionProvider; use crate::openhuman::memory_sync::composio::providers::ComposioProvider; use serde_json::json; diff --git a/src/openhuman/memory_sync/workspace/mod.rs b/src/openhuman/memory_sync/workspace/mod.rs index ea73b43296..557083baef 100644 --- a/src/openhuman/memory_sync/workspace/mod.rs +++ b/src/openhuman/memory_sync/workspace/mod.rs @@ -6,7 +6,7 @@ //! | Submodule | Source | Notes | //! | --- | --- | --- | //! | `folder` | Files under a user-added folder memory source | Watch + diff | -//! | `harness` | Agent harness turns (memory_archivist's caller side) | Push-based | +//! | `harness` | Agent harness turns (TinyCortex archivist caller side) | Push-based | //! | `dictation` | Local audio capture transcripts | Push-based | //! //! ## Status diff --git a/src/openhuman/memory_tools/README.md b/src/openhuman/memory_tools/README.md index 5c70018534..cab74b5683 100644 --- a/src/openhuman/memory_tools/README.md +++ b/src/openhuman/memory_tools/README.md @@ -5,16 +5,15 @@ from generic namespace memory and from `learning::tool_tracker` statistics. ## Namespace convention -Each tool gets its own namespace `tool-{tool_name}`. Build the string via -[`types::tool_memory_namespace`] — never hard-code it. +Each tool gets its own namespace `tool-{tool_name}`. Build the string via the +`tool_memory_namespace` re-export — never hard-code it. ## Layout | Path | Role | | --- | --- | | [`mod.rs`](mod.rs) | Module root + public re-exports. | -| [`types.rs`](types.rs) | **Shim** — re-exports `ToolMemoryRule` / `ToolMemoryPriority` / `ToolMemorySource` / `tool_memory_namespace` from `tinycortex::memory::tool_memory::types` (W7). | -| [`store.rs`](store.rs) | **Shim** — re-exports the crate `ToolMemoryStore` (`put_rule`, `get_rule`, `list_rules`, `delete_rule`, `rules_for_prompt`, `list_tool_names`, `record`, `list_rules_json`) + `tool_memory_store(Arc)`. The trait and engine are both crate-owned. | +| [`mod.rs`](mod.rs) | Re-exports crate types/store and defines `tool_memory_store(Arc)`. | | [`capture.rs`](capture.rs) | `ToolMemoryCaptureHook` — `PostTurnHook` impl that captures user edicts and repeated tool failures into the store (host-retained). | | [`prompt.rs`](prompt.rs) | **Shim** — re-exports the crate `ToolMemoryRulesSection` + `render_tool_memory_rules` + `TOOL_MEMORY_HEADING`, and keeps the host `PromptSection` impl that plugs the section into the system-prompt builder. | | [`tools/`](tools/) | Agent-facing read/write tools: `MemoryToolsListTool` (list rules for a tool), `MemoryToolsPutTool` (upsert a rule). | @@ -35,6 +34,5 @@ The agent harness: - No upward dependencies — only `memory::Memory` trait (via `Arc`) and project-wide primitives (`tools::traits::Tool`, `serde_json`). - `MockMemory` is `#[cfg(test)]`-only — never available outside test builds. -- Re-exports in `mod.rs` are the public surface; the underlying submodules - are `pub` so test code can reach in but consumers should go through the - re-exports. +- Re-exports in `mod.rs` are the public surface. Crate-owned type and store + forwarding files were removed; consumers should use the domain root. diff --git a/src/openhuman/memory_tools/capture.rs b/src/openhuman/memory_tools/capture.rs index b885f86ed5..ce6cd500d8 100644 --- a/src/openhuman/memory_tools/capture.rs +++ b/src/openhuman/memory_tools/capture.rs @@ -33,8 +33,7 @@ use std::sync::Arc; use async_trait::async_trait; -use super::store::{tool_memory_store, ToolMemoryStore}; -use super::types::{ToolMemoryPriority, ToolMemorySource}; +use super::{tool_memory_store, ToolMemoryPriority, ToolMemorySource, ToolMemoryStore}; use crate::openhuman::agent::hooks::{PostTurnHook, ToolCallRecord, TurnContext}; use crate::openhuman::memory::Memory; @@ -291,8 +290,8 @@ fn tool_aliases(tool_name: &str) -> Vec<&'static str> { mod tests { use super::*; use crate::openhuman::agent::hooks::ToolCallRecord; - use crate::openhuman::memory_tools::store::tool_memory_store; use crate::openhuman::memory_tools::test_helpers::MockMemory; + use crate::openhuman::memory_tools::tool_memory_store; fn ctx_with(message: &str, tool_calls: Vec) -> TurnContext { TurnContext { diff --git a/src/openhuman/memory_tools/prompt.rs b/src/openhuman/memory_tools/prompt.rs index df1c22b334..48fb3719a5 100644 --- a/src/openhuman/memory_tools/prompt.rs +++ b/src/openhuman/memory_tools/prompt.rs @@ -46,9 +46,7 @@ mod tests { use crate::openhuman::agent::prompts::types::{ LearnedContextData, PromptContext, ToolCallFormat, }; - use crate::openhuman::memory_tools::types::{ - ToolMemoryPriority, ToolMemoryRule, ToolMemorySource, - }; + use crate::openhuman::memory_tools::{ToolMemoryPriority, ToolMemoryRule, ToolMemorySource}; fn rule(tool: &str, body: &str, priority: ToolMemoryPriority) -> ToolMemoryRule { ToolMemoryRule { diff --git a/src/openhuman/memory_tools/store.rs b/src/openhuman/memory_tools/store.rs index 1cee3a7579..765bbf276f 100644 --- a/src/openhuman/memory_tools/store.rs +++ b/src/openhuman/memory_tools/store.rs @@ -1,16 +1,10 @@ -//! Storage layer for tool-scoped rules — thin host shim over -//! `tinycortex::memory::tool_memory::store` (W7). -//! -//! The store engine (put / get / list / delete / prompt over an -//! `Arc`) and the `Memory` trait are both owned by tinycortex. - use std::sync::Arc; use crate::openhuman::memory::Memory; pub use tinycortex::memory::tool_memory::store::{ToolMemoryStore, TOOL_MEMORY_PROMPT_CAP}; -/// Build a crate [`ToolMemoryStore`] over the shared tinycortex memory trait. +/// Build the crate-owned store over OpenHuman's shared memory object. pub fn tool_memory_store(memory: Arc) -> ToolMemoryStore { ToolMemoryStore::new(memory) } diff --git a/src/openhuman/memory_tools/types.rs b/src/openhuman/memory_tools/types.rs index 2a173548c9..84e6280c22 100644 --- a/src/openhuman/memory_tools/types.rs +++ b/src/openhuman/memory_tools/types.rs @@ -1,10 +1,8 @@ //! Domain types for the tool-scoped memory layer — thin host re-export of -//! `tinycortex::memory::tool_memory::types` (W7). +//! `tinycortex::memory::tool_memory::types`. //! -//! [`ToolMemoryRule`] / [`ToolMemoryPriority`] / [`ToolMemorySource`] and the -//! [`tool_memory_namespace`] helper are the crate's (a byte-identical port, -//! preserving the serde wire strings + `rule/{id}` storage keys). Host consumers -//! keep their `memory_tools::types::*` import paths unchanged. +//! This module preserves the public `memory_tools::types::*` import path while +//! TinyCortex remains the single implementation and wire-format authority. pub use tinycortex::memory::tool_memory::types::{ tool_memory_namespace, ToolMemoryPriority, ToolMemoryRule, ToolMemorySource, diff --git a/src/openhuman/memory_tree/README.md b/src/openhuman/memory_tree/README.md index 35d498dd55..9bc90b16cc 100644 --- a/src/openhuman/memory_tree/README.md +++ b/src/openhuman/memory_tree/README.md @@ -31,8 +31,7 @@ memory_store::trees (persistence: one Tree table, one schema) | [`tree/`](tree/) | `bucket_seal` (append leaf + cascade seal), `flush` (time-based partial seal), `registry` (kind-parameterized `get_or_create_tree` with UNIQUE-race recovery), `mod.rs` (re-exports + `memory_store::trees` shims for legacy paths). | | [`summarise.rs`](summarise.rs) | One function: produce the next-level summary text for a bucket. Wraps the chat model with a fixed prompt and token budget. | | [`retrieval/`](retrieval/) | Agent-facing tools. Read: `walk` (agentic), `drill_down`, `fetch_leaves`, `query_{source,global,topic}`, `search_entities`. Write: `ingest_document` (orchestrator-facing). | -| [`score/`](score/) | Scoring signals, embedding (cloud/ollama/inert), entity extraction (regex/LLM), canonical resolver, entity index store. | -| [`tools.rs`](tools.rs) | Re-exports from `memory::query` for backward compatibility. | +| [`score/`](score/) | Product adapters over TinyCortex scoring and TinyAgents embedding models, plus entity extraction and the entity index store. | ## Layer rules diff --git a/src/openhuman/memory_tree/mod.rs b/src/openhuman/memory_tree/mod.rs index 5f6e579b37..78b9e16d47 100644 --- a/src/openhuman/memory_tree/mod.rs +++ b/src/openhuman/memory_tree/mod.rs @@ -13,7 +13,6 @@ pub mod nlp; pub mod retrieval; pub mod score; pub mod summarise; -pub mod tools; pub mod tree; pub mod tree_runtime; diff --git a/src/openhuman/memory_tree/score/embed/README.md b/src/openhuman/memory_tree/score/embed/README.md index f4d881715a..43b71bd30f 100644 --- a/src/openhuman/memory_tree/score/embed/README.md +++ b/src/openhuman/memory_tree/score/embed/README.md @@ -1,18 +1,17 @@ -# Memory tree — score embed (Phase 4 / #710) +# Memory tree embedding bridge -Vector embedder for chunks and summaries. Produces a fixed-dimension (`EMBEDDING_DIM = 768`) `Vec` per text so retrieval can rerank candidates by semantic similarity. Default backend is local Ollama running `nomic-embed-text`; tests use the deterministic `InertEmbedder` so no network is required. +The memory tree stores fixed 1024-dimensional vectors. Concrete provider +transports live in TinyAgents; this directory keeps only memory-tree policy and +compatibility: -## Public surface +- `factory.rs`: read/write provider resolution, cloud-session policy, and + degraded-state behavior; +- `openai_compat.rs`: OpenHuman config/credential and custom-slug resolution; +- `inert.rs`: deterministic 1024-element zero vectors for opt-out/tests; +- `mod.rs`: the legacy `Embedder` contract, `ProviderEmbedder` bridge, batch + fallback/dimension checks, cosine math, and SQLite f32 packing helpers. -- `pub trait Embedder` — `mod.rs` — `embed(text) -> Vec` contract; impls must return exactly `EMBEDDING_DIM` floats. -- `pub fn build_embedder_from_config` — `factory.rs` — returns `OllamaEmbedder` when configured, otherwise `InertEmbedder` (or bails when `embedding_strict = true`). -- `pub struct OllamaEmbedder` — `ollama.rs` — HTTP client posting to `{endpoint}/api/embeddings`. -- `pub struct InertEmbedder` — `inert.rs` — zero-vector embedder for tests. -- `pub fn cosine_similarity` / `pack_embedding` / `unpack_embedding` / `pack_checked` / `decode_optional_blob` — `mod.rs` — math + SQLite BLOB packing helpers. - -## Files - -- `mod.rs` — trait, `EMBEDDING_DIM`, math + pack/unpack helpers, write-time / read-time semantics. -- `factory.rs` — `Config::memory_tree`-driven embedder selection with `embedding_strict` opt-in. -- `ollama.rs` — Ollama `/api/embeddings` client; defaults at `http://localhost:11434` / `nomic-embed-text` / 10s timeout. -- `inert.rs` — zero-vector embedder; cosine similarity between any two inert vectors is 0.0 (zero-magnitude short-circuit), so retrieval tests that need real reranking should hand-stitch embeddings instead of relying on this path. +Ollama uses TinyAgents `OllamaEmbeddingModel` and `/api/embed`, with the shared +8192-token context and batch window. Managed cloud uses the host credential and +privacy wrapper around TinyAgents `CloudEmbeddingModel`. Do not add provider +HTTP clients in this directory. diff --git a/src/openhuman/memory_tree/score/embed/cloud.rs b/src/openhuman/memory_tree/score/embed/cloud.rs deleted file mode 100644 index c0787cb9bd..0000000000 --- a/src/openhuman/memory_tree/score/embed/cloud.rs +++ /dev/null @@ -1,109 +0,0 @@ -//! Cloud (Voyage-backed) embedder for the memory tree. -//! -//! Adapts the OpenHuman backend's `POST /openai/v1/embeddings` surface -//! (Voyage `voyage-3.5`, 1024 dims) to the memory_tree [`Embedder`] trait -//! so Phase 4 ingest / bucket-seal can vectorize chunks without a local -//! Ollama install. -//! -//! The 1024-dim output matches existing on-disk blobs (which were -//! produced by `bge-m3`, also 1024-dim), so this is a drop-in replacement -//! for the Ollama path — no migration of `mem_tree_chunks.embedding` -//! required. -//! -//! Auth: the cloud embedder resolves the session JWT per call via -//! [`OpenHumanCloudEmbedding`], so a session refresh between batches is -//! picked up transparently. When the user is unauthenticated the first -//! `embed()` returns an error; ingest treats that the same as any other -//! embedder failure (don't persist the row, let job retry). - -use anyhow::{Context, Result}; -use async_trait::async_trait; - -use super::{Embedder, EMBEDDING_DIM}; -use crate::openhuman::config::Config; -use crate::openhuman::embeddings::cloud::{ - OpenHumanCloudEmbedding, DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, DEFAULT_CLOUD_EMBEDDING_MODEL, -}; -use crate::openhuman::embeddings::EmbeddingProvider; - -/// Cloud-backed memory_tree embedder. -/// -/// Wraps [`OpenHumanCloudEmbedding`] (which speaks the OpenAI-compatible -/// `/openai/v1/embeddings` shape backed by Voyage on the OpenHuman -/// backend) and adapts it to the memory_tree [`Embedder`] trait. -pub struct CloudEmbedder { - inner: OpenHumanCloudEmbedding, -} - -impl CloudEmbedder { - /// Build a cloud embedder using the same backend resolution as the - /// main embeddings path: `api_url` falls back to - /// [`effective_api_url`](crate::api::config::effective_api_url) and - /// the workspace dir comes from `config.workspace_dir` so the auth - /// service finds the user's session JWT. - pub fn new(config: &Config) -> Self { - let openhuman_dir = config.config_path.parent().map(std::path::PathBuf::from); - Self { - inner: OpenHumanCloudEmbedding::new( - None, - openhuman_dir, - config.secrets.encrypt, - DEFAULT_CLOUD_EMBEDDING_MODEL, - DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, - ), - } - } -} - -#[async_trait] -impl Embedder for CloudEmbedder { - fn name(&self) -> &'static str { - "cloud" - } - - async fn embed(&self, text: &str) -> Result> { - let v = self - .inner - .embed_one(text) - .await - .context("cloud embeddings failed")?; - if v.len() != EMBEDDING_DIM { - anyhow::bail!( - "cloud embedder returned {} dims, expected {}", - v.len(), - EMBEDDING_DIM - ); - } - Ok(v) - } - - /// Collapse N per-text round-trips into a single batched Voyage request by - /// delegating to the inner provider's native batch `embed`. Falls back to - /// per-text embedding (preserving per-position error attribution) on a - /// whole-batch failure or a length mismatch. - async fn embed_batch(&self, texts: &[&str]) -> Vec>> { - super::embed_batch_via_provider(&self.inner, "cloud", texts).await - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::openhuman::config::Config; - use tempfile::TempDir; - - fn test_config() -> (TempDir, Config) { - let tmp = TempDir::new().unwrap(); - let mut cfg = Config::default(); - cfg.workspace_dir = tmp.path().to_path_buf(); - cfg.config_path = tmp.path().join("config.toml"); - (tmp, cfg) - } - - #[test] - fn name_is_cloud() { - let (_tmp, cfg) = test_config(); - let e = CloudEmbedder::new(&cfg); - assert_eq!(e.name(), "cloud"); - } -} diff --git a/src/openhuman/memory_tree/score/embed/factory.rs b/src/openhuman/memory_tree/score/embed/factory.rs index 7f8601cfb6..00a4b50edd 100644 --- a/src/openhuman/memory_tree/score/embed/factory.rs +++ b/src/openhuman/memory_tree/score/embed/factory.rs @@ -29,11 +29,14 @@ //! - `OPENHUMAN_MEMORY_EMBED_MODEL` //! - `OPENHUMAN_MEMORY_EMBED_TIMEOUT_MS` -use anyhow::Result; +use anyhow::{Context, Result}; -use super::{CloudEmbedder, Embedder, InertEmbedder, OllamaEmbedder}; +use std::time::Duration; + +use super::{Embedder, InertEmbedder, ProviderEmbedder, EMBEDDING_DIM}; use crate::openhuman::config::Config; use crate::openhuman::inference::local::ollama_base_url; +use tinyagents::harness::embeddings::{OllamaEmbeddingModel, RECOMMENDED_OLLAMA_CONTEXT_TOKENS}; /// Cheap heuristic for "is a backend session reachable?" — the cloud /// embedder needs one and bails on first embed call without it. We use @@ -69,7 +72,7 @@ pub fn build_embedder_from_config(config: &Config) -> Result> log::debug!( "[memory_tree::embed::factory] read → Ollama endpoint={endpoint} model={model} timeout_ms={timeout_ms}" ); - Box::new(OllamaEmbedder::new(endpoint, model, timeout_ms)) + Box::new(build_ollama_embedder(&endpoint, &model, timeout_ms)?) } EmbedderChoice::OptOut => { log::info!( @@ -90,7 +93,7 @@ pub fn build_embedder_from_config(config: &Config) -> Result> "[memory_tree::embed::factory] read → cloud (Voyage) — flip \ 'Memory embeddings' in Local AI Settings to switch to local" ); - Box::new(CloudEmbedder::new(config)) + Box::new(build_cloud_embedder(config)) } EmbedderChoice::NoProvider => { log::warn!( @@ -215,7 +218,9 @@ pub fn build_write_embedder(config: &Config) -> Result> timeout_ms, } => { clear_semantic_recall_degraded(); - Some(Box::new(OllamaEmbedder::new(endpoint, model, timeout_ms))) + Some(Box::new(build_ollama_embedder( + &endpoint, &model, timeout_ms, + )?)) } EmbedderChoice::OptOut => { clear_semantic_recall_degraded(); @@ -231,7 +236,7 @@ pub fn build_write_embedder(config: &Config) -> Result> } EmbedderChoice::Cloud => { clear_semantic_recall_degraded(); - Some(Box::new(CloudEmbedder::new(config))) + Some(Box::new(build_cloud_embedder(config))) } EmbedderChoice::NoProvider => { log::warn!( @@ -245,6 +250,36 @@ pub fn build_write_embedder(config: &Config) -> Result> }) } +fn build_ollama_embedder(endpoint: &str, model: &str, timeout_ms: u64) -> Result { + let timeout = Duration::from_millis(if timeout_ms == 0 { 10_000 } else { timeout_ms }); + let client = reqwest::Client::builder() + .connect_timeout(timeout) + .build() + .context("build Ollama embeddings HTTP client")?; + let model = OllamaEmbeddingModel::try_new(endpoint, model, EMBEDDING_DIM)? + .with_context_options( + RECOMMENDED_OLLAMA_CONTEXT_TOKENS, + RECOMMENDED_OLLAMA_CONTEXT_TOKENS, + ) + .with_client(client); + Ok(ProviderEmbedder::new( + crate::openhuman::embeddings::TinyAgentsEmbeddingProvider::boxed(model), + "ollama", + )) +} + +fn build_cloud_embedder(config: &Config) -> ProviderEmbedder { + let openhuman_dir = config.config_path.parent().map(std::path::PathBuf::from); + let provider = crate::openhuman::embeddings::cloud::OpenHumanCloudEmbedding::new( + None, + openhuman_dir, + config.secrets.encrypt, + crate::openhuman::embeddings::cloud::DEFAULT_CLOUD_EMBEDDING_MODEL, + crate::openhuman::embeddings::cloud::DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, + ); + ProviderEmbedder::new(Box::new(provider), "cloud") +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/openhuman/memory_tree/score/embed/mod.rs b/src/openhuman/memory_tree/score/embed/mod.rs index 5c2143d908..a8a745c69d 100644 --- a/src/openhuman/memory_tree/score/embed/mod.rs +++ b/src/openhuman/memory_tree/score/embed/mod.rs @@ -29,16 +29,12 @@ use anyhow::{Context, Result}; use async_trait::async_trait; -pub mod cloud; pub mod factory; pub mod inert; -pub mod ollama; pub mod openai_compat; -pub use cloud::CloudEmbedder; pub use factory::{build_embedder_from_config, build_write_embedder}; pub use inert::InertEmbedder; -pub use ollama::OllamaEmbedder; pub use openai_compat::OpenAiCompatEmbedder; /// Embedding dimensionality used across the memory tree. @@ -89,6 +85,43 @@ pub trait Embedder: Send + Sync { } } +/// Adapts the canonical host embedding-provider contract to the legacy +/// memory-tree embedder shape. Concrete network implementations live in +/// `tinyagents::harness::embeddings`; this bridge owns only dimension checks +/// and the memory tree's per-position batch fallback contract. +pub struct ProviderEmbedder { + inner: Box, + label: &'static str, +} + +impl ProviderEmbedder { + pub fn new( + inner: Box, + label: &'static str, + ) -> Self { + Self { inner, label } + } +} + +#[async_trait] +impl Embedder for ProviderEmbedder { + fn name(&self) -> &'static str { + self.label + } + + async fn embed(&self, text: &str) -> Result> { + self.inner + .embed_one(text) + .await + .with_context(|| format!("{} embeddings failed", self.label)) + .and_then(|vector| check_embed_dim(vector, self.label)) + } + + async fn embed_batch(&self, texts: &[&str]) -> Vec>> { + embed_batch_via_provider(self.inner.as_ref(), self.label, texts).await + } +} + /// Validate that a freshly-produced embedding has exactly [`EMBEDDING_DIM`] /// floats, returning a labelled error otherwise. Shared by the per-text and /// batched provider adapters so the "wrong dims" diagnostic is identical diff --git a/src/openhuman/memory_tree/score/embed/ollama.rs b/src/openhuman/memory_tree/score/embed/ollama.rs deleted file mode 100644 index e9bef395e8..0000000000 --- a/src/openhuman/memory_tree/score/embed/ollama.rs +++ /dev/null @@ -1,384 +0,0 @@ -//! Ollama-backed embedder for Phase 4 (#710). -//! -//! Posts `{model, prompt, options: {num_ctx}}` to -//! `{endpoint}/api/embeddings` and expects -//! `{"embedding": [f32; EMBEDDING_DIM]}` back. Designed for a local -//! `ollama serve` hosting `bge-m3`. -//! -//! This is intentionally a tiny HTTP client — no retry, no pool caching, -//! no streaming. Phase 4 wants the simplest thing that works so we can -//! land embedding end-to-end and iterate once baseline retrieval quality -//! is measurable. Timeouts, parallelism, and caching are explicit -//! follow-ups. - -use std::time::Duration; - -use anyhow::{Context, Result}; -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; - -use super::{Embedder, EMBEDDING_DIM}; - -/// Default Ollama endpoint. Matches the local-install default from the -/// `local_ai` subsystem and the Ollama defaults. -pub const DEFAULT_ENDPOINT: &str = "http://localhost:11434"; - -/// Default embedding model — must output exactly [`EMBEDDING_DIM`] -/// (1024) dims. `bge-m3` is a multilingual BERT-family encoder with -/// native 8192-token context and 1024-dim output. -pub const DEFAULT_MODEL: &str = "bge-m3"; - -/// Default request timeout. Ollama's first-use latency is a few hundred -/// ms on a warm model; 10s absorbs a cold-model load on commodity -/// hardware without stalling ingest on a broken backend. -pub const DEFAULT_TIMEOUT_MS: u64 = 10_000; - -/// HTTP client wrapping a single Ollama endpoint + model pair. -/// -/// Cloneable — `reqwest::Client` shares a connection pool under the hood -/// so cloning the wrapper stays cheap across seal / ingest call sites. -#[derive(Clone)] -pub struct OllamaEmbedder { - endpoint: String, - model: String, - timeout: Duration, - client: reqwest::Client, -} - -impl OllamaEmbedder { - /// Build a new embedder. `endpoint` is trimmed of trailing slashes - /// so callers don't have to worry about mixing `http://host` and - /// `http://host/`. Empty values fall back to the public defaults. - pub fn new(endpoint: String, model: String, timeout_ms: u64) -> Self { - let endpoint = if endpoint.trim().is_empty() { - DEFAULT_ENDPOINT.to_string() - } else { - endpoint.trim().trim_end_matches('/').to_string() - }; - let model = if model.trim().is_empty() { - DEFAULT_MODEL.to_string() - } else { - model.trim().to_string() - }; - let timeout_ms = if timeout_ms == 0 { - DEFAULT_TIMEOUT_MS - } else { - timeout_ms - }; - let timeout = Duration::from_millis(timeout_ms); - // No body-read timeout. Ollama is local — slow responses mean - // the model is genuinely processing, not that the network - // broke. A body-read timeout here would cancel mid-stream and - // force retries against the same slow model. `timeout` is - // repurposed as the TCP connect timeout (fast-fail when - // Ollama is actually down). - let client = reqwest::Client::builder() - .connect_timeout(timeout) - .build() - .unwrap_or_else(|e| { - log::warn!( - "[memory_tree::embed::ollama] failed to build client \ - with connect_timeout — using default: {e}" - ); - reqwest::Client::new() - }); - log::debug!( - "[memory_tree::embed::ollama] created endpoint={endpoint} \ - model={model} timeout_ms={timeout_ms}" - ); - Self { - endpoint, - model, - timeout, - client, - } - } - - /// Convenience constructor using all defaults ([`DEFAULT_ENDPOINT`], - /// [`DEFAULT_MODEL`], [`DEFAULT_TIMEOUT_MS`]). - pub fn default_new() -> Self { - Self::new(String::new(), String::new(), 0) - } - - fn embed_url(&self) -> String { - format!("{}/api/embeddings", self.endpoint) - } -} - -fn is_missing_model_response(status: reqwest::StatusCode, body: &str) -> bool { - if status != reqwest::StatusCode::NOT_FOUND { - return false; - } - - let normalized = body.to_ascii_lowercase(); - normalized.contains("model") && normalized.contains("not found") -} - -fn format_embedding_status_error( - status: reqwest::StatusCode, - body: &str, - endpoint: &str, - model: &str, -) -> String { - let trimmed_body = body.trim(); - - if is_missing_model_response(status, trimmed_body) { - return format!( - "Ollama embedding model `{model}` is not installed at {endpoint}. \ - Run `ollama pull {model}` or choose an installed embedding model in \ - Connections → API keys → Embeddings. status={status} body={trimmed_body}" - ); - } - - format!("ollama embeddings failed status={status} body={trimmed_body}") -} - -/// Override Ollama's per-model `num_ctx` default. Ollama loads -/// embedding models with `num_ctx = 4096` (or whatever default the -/// model's modelfile carries) unless the request explicitly asks for -/// more. `bge-m3` natively supports 8192 tokens, and chunker-token -/// counts undercount BERT-WordPiece tokens by ~1.5-2× for HTML-derived -/// markdown — so a 1500-chunker-token chunk routinely produces 2500+ -/// real tokens at embed time. Asking for 8192 unconditionally avoids -/// silent prompt truncation; on models that natively support less, -/// Ollama clamps `num_ctx` to the model's actual maximum, so this is -/// safe to over-request. -/// -/// This is also the **single source of truth for the memory layer's -/// minimum model context window**: a local model whose native context is -/// below this floor silently truncates chunks/summaries and corrupts -/// recall. `inference::local::model_requirements::MIN_CONTEXT_TOKENS` -/// re-exports this value so the model-acceptance gate can never drift -/// from what the embedder actually requests. -pub(crate) const EMBED_NUM_CTX: u32 = 8192; - -#[derive(Serialize)] -struct EmbedRequest<'a> { - model: &'a str, - prompt: &'a str, - options: EmbedOptions, -} - -#[derive(Serialize)] -struct EmbedOptions { - num_ctx: u32, - // Ollama/llama.cpp evaluates an embedding prompt in a SINGLE batch and - // rejects (HTTP 500 "input length exceeds the context length") any prompt - // longer than `n_batch` — whose default is 2048, far below `num_ctx`. - // Raising `num_ctx` alone does NOT lift this; `num_batch` must match, or - // chunks between ~2k and 8k real tokens fail to embed even though they fit - // the context window. Set equal to `num_ctx` so the batch limit never bites - // before the real context limit. - num_batch: u32, -} - -#[derive(Deserialize)] -struct EmbedResponse { - #[serde(default)] - embedding: Vec, -} - -#[async_trait] -impl Embedder for OllamaEmbedder { - fn name(&self) -> &'static str { - "ollama" - } - - async fn embed(&self, text: &str) -> Result> { - log::debug!( - "[memory_tree::embed::ollama] embed endpoint={} model={} bytes={}", - self.endpoint, - self.model, - text.len() - ); - let req = EmbedRequest { - model: &self.model, - prompt: text, - options: EmbedOptions { - num_ctx: EMBED_NUM_CTX, - num_batch: EMBED_NUM_CTX, - }, - }; - let resp = self - .client - // No per-request body-read timeout — see `OllamaEmbedder::new` - // for rationale. The Client's `connect_timeout` still applies - // and fails fast if Ollama isn't reachable. - .post(self.embed_url()) - .json(&req) - .send() - .await - .with_context(|| { - format!( - "ollama embeddings request failed (is Ollama running at {}?)", - self.endpoint - ) - })?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - let msg = format_embedding_status_error(status, &body, &self.endpoint, &self.model); - // Log at WARN so missing-model failures surface in traces without - // requiring debug-level logging to be enabled. Missing-model 404s - // include the `ollama pull` remediation hint from - // `format_embedding_status_error`. - log::warn!("[embeddings] {msg}"); - anyhow::bail!(msg); - } - - let payload: EmbedResponse = resp - .json() - .await - .context("ollama embeddings response parse failed")?; - - if payload.embedding.len() != EMBEDDING_DIM { - anyhow::bail!( - "ollama embeddings returned {} dims, expected {EMBEDDING_DIM} \ - (check model={})", - payload.embedding.len(), - self.model - ); - } - - Ok(payload.embedding) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use axum::{extract::Json, http::StatusCode, routing::post, Router}; - use std::net::SocketAddr; - - /// Spin up a local axum server and return its base URL. - async fn start_mock(app: Router) -> String { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr: SocketAddr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - format!("http://127.0.0.1:{}", addr.port()) - } - - fn fixed_vec(val: f32) -> Vec { - vec![val; EMBEDDING_DIM] - } - - #[test] - fn defaults_applied_on_empty_input() { - let e = OllamaEmbedder::new(String::new(), String::new(), 0); - assert_eq!(e.endpoint, DEFAULT_ENDPOINT); - assert_eq!(e.model, DEFAULT_MODEL); - assert_eq!(e.timeout, Duration::from_millis(DEFAULT_TIMEOUT_MS)); - } - - #[test] - fn trailing_slash_trimmed() { - let e = OllamaEmbedder::new("http://host:1234/".into(), "m".into(), 1000); - assert_eq!(e.endpoint, "http://host:1234"); - } - - #[test] - fn embed_url_format() { - let e = OllamaEmbedder::default_new(); - assert_eq!(e.embed_url(), "http://localhost:11434/api/embeddings"); - } - - #[test] - fn name_is_ollama() { - assert_eq!(OllamaEmbedder::default_new().name(), "ollama"); - } - - #[tokio::test] - async fn happy_path_returns_embedding() { - let v = fixed_vec(0.25); - let v_clone = v.clone(); - let app = Router::new().route( - "/api/embeddings", - post(move |Json(body): Json| { - let v = v_clone.clone(); - async move { - assert_eq!(body["model"], "bge-m3"); - assert_eq!(body["prompt"], "hello world"); - assert_eq!(body["options"]["num_ctx"], 8192); - assert_eq!(body["options"]["num_batch"], 8192); - Json(serde_json::json!({ "embedding": v })) - } - }), - ); - let url = start_mock(app).await; - let e = OllamaEmbedder::new(url, String::new(), 0); - let out = e.embed("hello world").await.unwrap(); - assert_eq!(out.len(), EMBEDDING_DIM); - assert!((out[0] - 0.25).abs() < 1e-6); - } - - #[tokio::test] - async fn server_error_bubbles_up() { - let app = Router::new().route( - "/api/embeddings", - post(|| async { (StatusCode::INTERNAL_SERVER_ERROR, "model crashed") }), - ); - let url = start_mock(app).await; - let e = OllamaEmbedder::new(url, String::new(), 0); - let err = e.embed("hello").await.unwrap_err().to_string(); - assert!(err.contains("500"), "msg: {err}"); - assert!(err.contains("model crashed"), "msg: {err}"); - } - - #[tokio::test] - async fn missing_model_404_mentions_pull_command() { - let app = Router::new().route( - "/api/embeddings", - post(|| async { (StatusCode::NOT_FOUND, "{\"error\":\"model not found\"}") }), - ); - let url = start_mock(app).await; - let e = OllamaEmbedder::new(url, "custom-embed".into(), 0); - let err = e.embed("hello").await.unwrap_err().to_string(); - - assert!( - err.contains("embedding model `custom-embed` is not installed"), - "msg: {err}" - ); - assert!(err.contains("ollama pull custom-embed"), "msg: {err}"); - } - - #[tokio::test] - async fn dim_mismatch_rejected() { - let app = Router::new().route( - "/api/embeddings", - post(|| async { - // Return a 3-dim vector — must fail validation. - Json(serde_json::json!({ "embedding": [0.1, 0.2, 0.3] })) - }), - ); - let url = start_mock(app).await; - let e = OllamaEmbedder::new(url, String::new(), 0); - let err = e.embed("hi").await.unwrap_err().to_string(); - assert!(err.contains("3 dims"), "msg: {err}"); - assert!(err.contains("expected 1024"), "msg: {err}"); - } - - #[tokio::test] - async fn malformed_json_response_rejected() { - let app = Router::new().route( - "/api/embeddings", - post(|| async { (StatusCode::OK, "not even json") }), - ); - let url = start_mock(app).await; - let e = OllamaEmbedder::new(url, String::new(), 0); - let err = e.embed("hi").await.unwrap_err().to_string(); - assert!(err.contains("parse failed"), "msg: {err}"); - } - - #[tokio::test] - async fn request_failure_is_descriptive() { - let e = OllamaEmbedder::new("http://%".into(), String::new(), 500); - let err = e.embed("hi").await.unwrap_err().to_string(); - assert!( - err.contains("is Ollama running"), - "should mention Ollama: {err}" - ); - } -} diff --git a/src/openhuman/memory_tree/tools.rs b/src/openhuman/memory_tree/tools.rs deleted file mode 100644 index 7612da6ad2..0000000000 --- a/src/openhuman/memory_tree/tools.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! Compatibility shim: high-level memory query tools now live in -//! `crate::openhuman::memory::query`. - -pub use crate::openhuman::memory::query::*; diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 1e4c11734b..57d3e5ae81 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -84,7 +84,6 @@ pub mod medulla_local; pub mod meet; pub mod meet_agent; pub mod memory; -pub mod memory_archivist; pub mod memory_conversations; pub mod memory_diff; pub mod memory_goals; diff --git a/src/openhuman/scheduler_gate/gate.rs b/src/openhuman/scheduler_gate/gate.rs index 728893d1e4..e449e3d9fc 100644 --- a/src/openhuman/scheduler_gate/gate.rs +++ b/src/openhuman/scheduler_gate/gate.rs @@ -24,7 +24,7 @@ use crate::openhuman::scheduler_gate::signals::Signals; /// simultaneous Ollama requests have crashed the user's laptop twice. /// /// Cloud-backend LLM calls bypass this semaphore at the worker layer -/// (see `memory::jobs::worker::run_once`) because they're +/// (see `memory_queue::worker::run_once`) because they're /// bandwidth-bound, not RAM-bound, and the worker pool itself bounds /// concurrency upstream. Keeping this at 1 preserves the laptop-RAM /// contract regardless of backend. diff --git a/src/openhuman/tinycortex/mod.rs b/src/openhuman/tinycortex/mod.rs index 99d5a36d86..72bb914bb5 100644 --- a/src/openhuman/tinycortex/mod.rs +++ b/src/openhuman/tinycortex/mod.rs @@ -5,7 +5,7 @@ //! chunks / tree / retrieval / queue / ingest / score + the long tail). This //! module is the **adapter seam**, mirroring `src/openhuman/tinyagents/`: it //! implements the crate's engine traits over OpenHuman services and derives the -//! engine's [`MemoryConfig`] from the host [`Config`]. Nothing here contains +//! engine's [`tinycortex::memory::MemoryConfig`] from the host [`Config`]. Nothing here contains //! engine logic — that lives in the crate. //! //! ## Ownership boundary (the seam contract) @@ -16,19 +16,20 @@ //! archivist/tool-memory/conversations long tail. //! //! **Product (host, stays in OpenHuman):** JSON-RPC schemas/ops/read_rpc, agent -//! tools + `SecurityPolicy` gating, live sync (`memory_sync`), the event bus, -//! preferences, `source_scope` per-turn allowlist, redaction, the global -//! singleton + background queue worker, embedding/LLM **compute**, and the +//! tools + `SecurityPolicy` gating, sync scheduling/credentials/events, the +//! event bus, preferences, `source_scope` per-turn allowlist, redaction, the +//! global singleton + background queue worker, embedding/LLM **compute**, and the //! host-retained `UnifiedMemory` namespace-document tier (episodic/event/ //! segment/doc/graph/profile tables) plus the `wiki_git`/`obsidian` content //! surfaces the crate deliberately excludes. //! -//! The crate never makes a network call or owns a worker pool: LLM/embedding -//! compute is injected through `EmbeddingBackend`, `ChatProvider`, `Summariser`, -//! and `EntityExtractor`; the job queue is driven by the host worker loop via +//! Network-capable sync providers are feature-gated in the crate; their +//! credentials and product policy stay in the host. LLM/embedding compute is +//! injected through `EmbeddingBackend`, `ChatProvider`, `Summariser`, and +//! `EntityExtractor`; the job queue is driven by the host worker loop via //! `queue::run_once` / `drain_until_idle`. Those adapters live beside this file -//! (`embeddings.rs`, `chat.rs`, `queue_driver.rs`, `sinks.rs`, `bus.rs`) and are -//! added workstream by workstream. +//! (`embeddings.rs`, `chat.rs`, `queue_driver.rs`, `ingest.rs`, `seal.rs`, and +//! `sync.rs`). //! //! See `docs/tinycortex-migration-spec.md` for the full ownership split, //! drift/gap/parity ledgers, and the workstream order. @@ -66,12 +67,3 @@ pub use sync::{ run_gmail_backfill, run_slack_search_backfill, run_source_pipeline, sync_context, HostSyncAdapter, SourcePipelineFailure, HOST_SYNC_STATE_NAMESPACE, }; -// Facade re-exports — the rest of the host imports memory-engine types through -// this one seam so consumer import paths stay stable as the internals flip to -// the crate (the type-unification decision, spec §0.5). `MemoryTaint` is the -// security-critical provenance type; it is proven byte-identical to the host's -// (fail-closed to `ExternalSync`) before re-exporting. -pub use tinycortex::memory::{ - MemoryCategory, MemoryConfig, MemoryEntry, MemoryTaint, NamespaceSummary, RecallOpts, - WeightProfile, -}; diff --git a/src/openhuman/tinycortex/queue_driver.rs b/src/openhuman/tinycortex/queue_driver.rs index 0498122e4d..2b9b67f7b6 100644 --- a/src/openhuman/tinycortex/queue_driver.rs +++ b/src/openhuman/tinycortex/queue_driver.rs @@ -18,12 +18,10 @@ //! reproduces it exactly. It is a pure function so the policy is unit-tested //! without spinning a live loop. //! -//! Still to land in W4 (tracked in the migration spec): the real -//! `HostQueueDelegates` that bridges the 8 delegate methods to the host -//! `memory_tree` engine (paired with W5), the tokio worker-loop + scheduler that -//! applies this policy while driving `run_once`, re-pointing `memory/global.rs` -//! and the enqueue call sites onto `queue::store`, and deleting the legacy -//! `memory_queue` engine. This brick is additive: nothing is flipped yet. +//! The host worker is flipped to `tinycortex::memory::queue::run_once` through +//! `HostQueueDelegates`. The adapter preserves host-owned scheduling, health +//! reporting, event-bus publishing, and product-policy hooks while the crate +//! owns claim/dispatch/settle. use std::time::Duration; diff --git a/tests/agent_retrieval_e2e.rs b/tests/agent_retrieval_e2e.rs index 76eb060884..b1f557f10c 100644 --- a/tests/agent_retrieval_e2e.rs +++ b/tests/agent_retrieval_e2e.rs @@ -22,7 +22,7 @@ use chrono::{TimeZone, Utc}; use openhuman_core::openhuman::config::Config; use openhuman_core::openhuman::memory::ingest_pipeline::{ingest_chat, ingest_email}; -use openhuman_core::openhuman::memory::jobs::drain_until_idle; +use openhuman_core::openhuman::memory_queue::drain_until_idle; use openhuman_core::openhuman::memory_sync::canonicalize::chat::{ChatBatch, ChatMessage}; use openhuman_core::openhuman::memory_sync::canonicalize::email::{EmailMessage, EmailThread}; use openhuman_core::openhuman::tools::{ diff --git a/tests/memory_golden_parity_e2e.rs b/tests/memory_golden_parity_e2e.rs index 64158170a5..2ef033ba14 100644 --- a/tests/memory_golden_parity_e2e.rs +++ b/tests/memory_golden_parity_e2e.rs @@ -10,7 +10,7 @@ //! 1. the **crate-owned substrate** the `tinycortex` chunk DB creates //! (`init_db` → `chunks/schema.rs`), and //! 2. the **host-retained `UnifiedMemory` namespace-document tier** -//! (`memory_store/unified/*`), +//! (`memory_store/namespace_store/*`), //! //! coexisting without collision (parity checklist P3/P5/P11/P12 — the W3 gate). //! A store/tree cutover that reshaped, renamed, or dropped a table would strand @@ -105,7 +105,7 @@ const CRATE_CHUNK_SCHEMA_TABLES: &[&str] = &[ ]; /// The host-retained `UnifiedMemory` namespace-document tier -/// (`memory_store/unified/*`) — stays host, coexists in the shared workspace. +/// (`memory_store/namespace_store/*`) — stays host, coexists in the shared workspace. const HOST_UNIFIED_TABLES: &[&str] = &[ "memory_docs", "graph_global", diff --git a/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs b/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs index 5cade5a55b..e33c05f3f9 100644 --- a/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs +++ b/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs @@ -308,7 +308,7 @@ async fn round15_composio_agent_tools_backend_cache_and_trigger_history_edges() assert_eq!(action_tool.name(), "GMAIL_FETCH_EMAILS"); assert_eq!(action_tool.category().to_string(), "skill"); let contract_result = action_tool - .execute(json!({ "query": "from:me" })) + .execute(json!({ "invented_filter": "from:me" })) .await .expect("per-action contract gate"); assert!(contract_result.is_error); diff --git a/tests/raw_coverage/embeddings_ollama_raw_coverage_e2e.rs b/tests/raw_coverage/embeddings_ollama_raw_coverage_e2e.rs deleted file mode 100644 index e0e9840af4..0000000000 --- a/tests/raw_coverage/embeddings_ollama_raw_coverage_e2e.rs +++ /dev/null @@ -1,828 +0,0 @@ -//! Raw-line oriented E2E coverage for the Ollama embedding provider. -//! -//! These tests use the public embedding provider API against a local mock -//! Ollama HTTP server. They avoid a real daemon while exercising the same -//! request, validation, and NaN-recovery branches used in production. - -use std::net::SocketAddr; -use std::sync::{Arc, Mutex}; - -use axum::extract::Json; -use axum::extract::State; -use axum::http::{HeaderMap, StatusCode}; -use axum::response::IntoResponse; -use axum::routing::post; -use axum::Router; -use serde_json::{json, Value}; - -use openhuman_core::openhuman::credentials::{ - AuthService, APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME, -}; -use openhuman_core::openhuman::embeddings::catalog; -use openhuman_core::openhuman::embeddings::cloud::{ - OpenHumanCloudEmbedding, DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, DEFAULT_CLOUD_EMBEDDING_MODEL, -}; -use openhuman_core::openhuman::embeddings::cohere::CohereEmbedding; -use openhuman_core::openhuman::embeddings::noop::NoopEmbedding; -use openhuman_core::openhuman::embeddings::ollama::DEFAULT_OLLAMA_URL; -use openhuman_core::openhuman::embeddings::openai::OpenAiEmbedding; -use openhuman_core::openhuman::embeddings::retry_after::{ - backoff_ms_for_attempt, parse_retry_after_ms, BASE_BACKOFF_MS, MAX_BACKOFF_MS, -}; -use openhuman_core::openhuman::embeddings::voyage::VoyageEmbedding; -use openhuman_core::openhuman::embeddings::{ - create_embedding_provider, create_embedding_provider_with_credentials, EmbeddingProvider, - OllamaEmbedding, DEFAULT_OLLAMA_DIMENSIONS, DEFAULT_OLLAMA_MODEL, -}; - -async fn serve_mock_ollama(app: Router) -> String { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind mock ollama"); - let addr: SocketAddr = listener.local_addr().expect("mock ollama addr"); - tokio::spawn(async move { - axum::serve(listener, app).await.expect("mock ollama serve"); - }); - format!("http://127.0.0.1:{}", addr.port()) -} - -#[derive(Clone, Copy)] -enum OpenAiMockBehavior { - RetryThenSuccess, - CountMismatch, - BadEmbeddingItem, - MissingEmbedding, - DimensionMismatch, - MissingData, - Non2xx, -} - -#[derive(Clone)] -struct OpenAiMockState { - behavior: OpenAiMockBehavior, - attempts: Arc>, - requests: Arc>>, - auth_headers: Arc>>>, -} - -impl OpenAiMockState { - fn new(behavior: OpenAiMockBehavior) -> Self { - Self { - behavior, - attempts: Arc::new(Mutex::new(0)), - requests: Arc::new(Mutex::new(Vec::new())), - auth_headers: Arc::new(Mutex::new(Vec::new())), - } - } -} - -async fn serve_mock_openai(behavior: OpenAiMockBehavior) -> (String, OpenAiMockState) { - let state = OpenAiMockState::new(behavior); - let app = Router::new() - .route("/v1/embeddings", post(mock_openai_handler)) - .route("/openai/v1/embeddings", post(mock_openai_handler)) - .route("/api/v2/embeddings", post(mock_openai_handler)) - .route("/embeddings", post(mock_openai_handler)) - .with_state(state.clone()); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind mock openai"); - let addr = listener.local_addr().expect("mock openai addr"); - tokio::spawn(async move { - axum::serve(listener, app).await.expect("mock openai serve"); - }); - (format!("http://127.0.0.1:{}", addr.port()), state) -} - -async fn serve_mock_cohere(app: Router) -> String { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind mock cohere"); - let addr = listener.local_addr().expect("mock cohere addr"); - tokio::spawn(async move { - axum::serve(listener, app).await.expect("mock cohere serve"); - }); - format!("http://127.0.0.1:{}", addr.port()) -} - -fn record_openai_request(state: &OpenAiMockState, headers: &HeaderMap, body: Value) -> usize { - let mut attempts = state.attempts.lock().expect("attempts lock"); - *attempts += 1; - let attempt = *attempts; - drop(attempts); - - state.requests.lock().expect("requests lock").push(body); - state.auth_headers.lock().expect("auth headers lock").push( - headers - .get(axum::http::header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .map(ToOwned::to_owned), - ); - attempt -} - -async fn mock_openai_handler( - State(state): State, - headers: HeaderMap, - Json(body): Json, -) -> axum::response::Response { - let attempt = record_openai_request(&state, &headers, body); - - match state.behavior { - OpenAiMockBehavior::RetryThenSuccess if attempt == 1 => ( - StatusCode::TOO_MANY_REQUESTS, - [(axum::http::header::RETRY_AFTER, "0")], - "slow down", - ) - .into_response(), - OpenAiMockBehavior::RetryThenSuccess => Json(json!({ - "data": [ - { "embedding": [1.0, 2.0] }, - { "embedding": [3.0, 4.0] } - ] - })) - .into_response(), - OpenAiMockBehavior::CountMismatch => { - Json(json!({ "data": [{ "embedding": [1.0, 2.0] }] })).into_response() - } - OpenAiMockBehavior::BadEmbeddingItem => { - Json(json!({ "data": [{ "embedding": [1.0, "bad"] }] })).into_response() - } - OpenAiMockBehavior::MissingEmbedding => Json(json!({ "data": [{}] })).into_response(), - OpenAiMockBehavior::DimensionMismatch => { - Json(json!({ "data": [{ "embedding": [1.0, 2.0, 3.0] }] })).into_response() - } - OpenAiMockBehavior::MissingData => Json(json!({ "not_data": [] })).into_response(), - OpenAiMockBehavior::Non2xx => { - (StatusCode::BAD_REQUEST, "bad embedding request").into_response() - } - } -} - -#[tokio::test] -async fn openai_embed_retries_and_round_trips_auth_body_and_vectors() { - let (base_url, state) = serve_mock_openai(OpenAiMockBehavior::RetryThenSuccess).await; - let provider = OpenAiEmbedding::new(&base_url, "test-key", "mock-openai", 2); - - assert_eq!(provider.name(), "openai"); - assert_eq!(provider.model_id(), "mock-openai"); - assert_eq!(provider.dimensions(), 2); - assert_eq!(provider.base_url(), base_url); - assert_eq!(provider.model(), "mock-openai"); - assert_eq!( - provider.embeddings_url(), - format!("{base_url}/v1/embeddings") - ); - - let vectors = provider - .embed(&["first", "second"]) - .await - .expect("openai retry success"); - - assert_eq!(vectors, vec![vec![1.0, 2.0], vec![3.0, 4.0]]); - assert_eq!(*state.attempts.lock().expect("attempts lock"), 2); - - let auth_headers = state.auth_headers.lock().expect("auth headers lock"); - assert_eq!( - auth_headers.as_slice(), - [ - Some("Bearer test-key".to_string()), - Some("Bearer test-key".to_string()) - ] - ); - drop(auth_headers); - - let requests = state.requests.lock().expect("requests lock"); - assert_eq!( - requests[0].get("model").and_then(Value::as_str), - Some("mock-openai") - ); - assert_eq!( - requests[0].pointer("/input/0").and_then(Value::as_str), - Some("first") - ); - assert_eq!( - requests[0].pointer("/input/1").and_then(Value::as_str), - Some("second") - ); -} - -#[tokio::test] -async fn openai_embed_handles_explicit_paths_empty_inputs_and_missing_auth() { - let (base_url, state) = serve_mock_openai(OpenAiMockBehavior::RetryThenSuccess).await; - let api_provider = OpenAiEmbedding::new(&format!("{base_url}/api/v2"), "", "mock-path", 2); - assert_eq!( - api_provider.embeddings_url(), - format!("{base_url}/api/v2/embeddings") - ); - - let vectors = api_provider - .embed(&["first", "second"]) - .await - .expect("explicit api path success"); - assert_eq!(vectors, vec![vec![1.0, 2.0], vec![3.0, 4.0]]); - assert_eq!( - state.auth_headers.lock().expect("auth headers lock").last(), - Some(&None) - ); - - let endpoint_provider = OpenAiEmbedding::new(&format!("{base_url}/embeddings"), "", "m", 2); - assert_eq!( - endpoint_provider.embeddings_url(), - format!("{base_url}/embeddings") - ); - assert_eq!( - endpoint_provider.embed(&[]).await.expect("empty input"), - Vec::>::new() - ); - - let invalid_url_provider = OpenAiEmbedding::new("not-a-url", "", "m", 0); - assert_eq!( - invalid_url_provider.embeddings_url(), - "not-a-url/v1/embeddings" - ); -} - -#[tokio::test] -async fn openai_embed_reports_response_validation_and_http_errors() { - let (count_url, _) = serve_mock_openai(OpenAiMockBehavior::CountMismatch).await; - let count_provider = OpenAiEmbedding::new(&count_url, "k", "m", 2); - assert!(count_provider - .embed(&["a", "b"]) - .await - .expect_err("count mismatch") - .to_string() - .contains("count mismatch")); - - let (bad_item_url, _) = serve_mock_openai(OpenAiMockBehavior::BadEmbeddingItem).await; - let bad_item_provider = OpenAiEmbedding::new(&bad_item_url, "k", "m", 2); - assert!(bad_item_provider - .embed(&["a"]) - .await - .expect_err("non numeric") - .to_string() - .contains("non-numeric")); - - let (missing_item_url, _) = serve_mock_openai(OpenAiMockBehavior::MissingEmbedding).await; - let missing_item_provider = OpenAiEmbedding::new(&missing_item_url, "k", "m", 2); - assert!(missing_item_provider - .embed(&["a"]) - .await - .expect_err("missing embedding") - .to_string() - .contains("missing `embedding`")); - - let (dim_url, _) = serve_mock_openai(OpenAiMockBehavior::DimensionMismatch).await; - let dim_provider = OpenAiEmbedding::new(&dim_url, "k", "m", 2); - assert!(dim_provider - .embed(&["a"]) - .await - .expect_err("dimension mismatch") - .to_string() - .contains("dimension mismatch")); - - let (missing_url, _) = serve_mock_openai(OpenAiMockBehavior::MissingData).await; - let missing_provider = OpenAiEmbedding::new(&missing_url, "k", "m", 2); - assert!(missing_provider - .embed(&["a"]) - .await - .expect_err("missing data") - .to_string() - .contains("missing `data`")); - - let (non_2xx_url, _) = serve_mock_openai(OpenAiMockBehavior::Non2xx).await; - let non_2xx_provider = OpenAiEmbedding::new(&non_2xx_url, "k", "m", 2); - assert!(non_2xx_provider - .embed(&["a"]) - .await - .expect_err("http error") - .to_string() - .contains("returned HTTP")); -} - -#[tokio::test] -async fn cloud_embedding_uses_seeded_session_token_and_reports_missing_auth() { - let (base_url, state) = serve_mock_openai(OpenAiMockBehavior::RetryThenSuccess).await; - let state_dir = tempfile::tempdir().expect("tempdir"); - AuthService::new(state_dir.path(), false) - .store_provider_token( - APP_SESSION_PROVIDER, - DEFAULT_AUTH_PROFILE_NAME, - "cloud-session-token", - Default::default(), - true, - ) - .expect("seed cloud auth"); - - let provider = OpenHumanCloudEmbedding::new( - Some(format!("{base_url}/")), - Some(state_dir.path().to_path_buf()), - false, - "cloud-model", - 2, - ); - assert_eq!(provider.name(), "cloud"); - assert_eq!(provider.model_id(), "cloud-model"); - assert_eq!(provider.dimensions(), 2); - - let vectors = provider - .embed(&["first", "second"]) - .await - .expect("cloud embed"); - assert_eq!(vectors, vec![vec![1.0, 2.0], vec![3.0, 4.0]]); - assert_eq!( - state.auth_headers.lock().expect("auth headers lock").last(), - Some(&Some("Bearer cloud-session-token".to_string())) - ); - - let missing_auth = OpenHumanCloudEmbedding::new( - Some(base_url), - Some( - tempfile::tempdir() - .expect("missing auth dir") - .path() - .to_path_buf(), - ), - false, - "cloud-model", - 2, - ); - assert!(missing_auth - .embed(&["needs-auth"]) - .await - .expect_err("missing backend session") - .to_string() - .contains("No backend session for cloud embeddings")); -} - -#[tokio::test] -async fn cohere_and_voyage_embedding_paths_use_local_compatible_mocks() { - let cohere_requests = Arc::new(Mutex::new(Vec::::new())); - let cohere_auth = Arc::new(Mutex::new(Vec::>::new())); - let cohere_requests_for_route = cohere_requests.clone(); - let cohere_auth_for_route = cohere_auth.clone(); - let cohere_url = serve_mock_cohere(Router::new().route( - "/v2/embed", - post(move |headers: HeaderMap, Json(body): Json| { - let cohere_requests = cohere_requests_for_route.clone(); - let cohere_auth = cohere_auth_for_route.clone(); - async move { - cohere_requests.lock().expect("cohere requests").push(body); - cohere_auth.lock().expect("cohere auth").push( - headers - .get(axum::http::header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .map(ToOwned::to_owned), - ); - Json(json!({ "embeddings": { "float": [[0.1, 0.2], [0.3, 0.4]] } })) - } - }), - )) - .await; - - let cohere = - CohereEmbedding::new("cohere-key", "embed-multilingual-v3.0", 2).with_base_url(cohere_url); - assert_eq!(cohere.name(), "cohere"); - assert_eq!(cohere.model_id(), "embed-multilingual-v3.0"); - assert_eq!(cohere.dimensions(), 2); - assert_eq!( - cohere - .embed(&["alpha", "beta"]) - .await - .expect("cohere embed"), - vec![vec![0.1, 0.2], vec![0.3, 0.4]] - ); - assert_eq!( - cohere_auth.lock().expect("cohere auth").as_slice(), - [Some("Bearer cohere-key".to_string())] - ); - assert_eq!( - cohere_requests.lock().expect("cohere requests")[0].pointer("/texts/0"), - Some(&json!("alpha")) - ); - - let (voyage_url, voyage_state) = serve_mock_openai(OpenAiMockBehavior::RetryThenSuccess).await; - let voyage = VoyageEmbedding::new_with_base_url("voyage-key", "", 2, &voyage_url); - assert_eq!(voyage.name(), "voyage"); - assert_eq!(voyage.model_id(), "voyage-3-large"); - assert_eq!(voyage.dimensions(), 2); - assert_eq!( - voyage - .embed(&["first", "second"]) - .await - .expect("voyage embed"), - vec![vec![1.0, 2.0], vec![3.0, 4.0]] - ); - assert!(voyage_state - .auth_headers - .lock() - .expect("voyage auth") - .iter() - .any(|header| header.as_deref() == Some("Bearer voyage-key"))); -} - -#[tokio::test] -async fn cohere_embedding_reports_parse_count_dimension_and_http_errors() { - let count_url = serve_mock_cohere(Router::new().route( - "/v2/embed", - post(|| async { Json(json!({ "embeddings": { "float": [[1.0, 2.0]] } })) }), - )) - .await; - let count_provider = CohereEmbedding::new("k", "m", 2).with_base_url(count_url); - assert!(count_provider - .embed(&["a", "b"]) - .await - .expect_err("cohere count mismatch") - .to_string() - .contains("count mismatch")); - - let dim_url = serve_mock_cohere(Router::new().route( - "/v2/embed", - post(|| async { Json(json!({ "embeddings": { "float": [[1.0, 2.0, 3.0]] } })) }), - )) - .await; - let dim_provider = CohereEmbedding::new("k", "m", 2).with_base_url(dim_url); - assert!(dim_provider - .embed(&["a"]) - .await - .expect_err("cohere dimension mismatch") - .to_string() - .contains("dimension mismatch")); - - let malformed_url = serve_mock_cohere( - Router::new().route("/v2/embed", post(|| async { (StatusCode::OK, "not-json") })), - ) - .await; - let malformed_provider = CohereEmbedding::new("k", "m", 2).with_base_url(malformed_url); - assert!(malformed_provider - .embed(&["a"]) - .await - .expect_err("cohere parse") - .to_string() - .contains("parse failed")); - - let non_2xx_url = serve_mock_cohere(Router::new().route( - "/v2/embed", - post(|| async { (StatusCode::BAD_REQUEST, "bad cohere request") }), - )) - .await; - let non_2xx_provider = CohereEmbedding::new("k", "m", 2).with_base_url(non_2xx_url); - assert!(non_2xx_provider - .embed(&["a"]) - .await - .expect_err("cohere http error") - .to_string() - .contains("Cohere embed API error")); -} - -#[tokio::test] -async fn embedding_rate_limit_public_paths_cover_disabled_loopback_and_malformed_urls() { - use openhuman_core::openhuman::embeddings::rate_limit::{ - acquire_embedding_slot, embedding_rate_limit, set_embedding_rate_limit, - }; - - let original = embedding_rate_limit(); - set_embedding_rate_limit(0); - assert_eq!(embedding_rate_limit(), 0); - acquire_embedding_slot("https://api.example.invalid/openai/v1").await; - - set_embedding_rate_limit(60_000); - acquire_embedding_slot("http://localhost:11434").await; - acquire_embedding_slot("http://[::1]:11434").await; - acquire_embedding_slot("not-a-url").await; - - set_embedding_rate_limit(original); -} - -#[test] -fn ollama_constructor_normalizes_defaults_and_rejects_runtime_misconfiguration() { - let defaults = OllamaEmbedding::try_new(" ", " ", 0).expect("default ollama config"); - assert_eq!(defaults.base_url(), DEFAULT_OLLAMA_URL); - assert_eq!(defaults.model(), DEFAULT_OLLAMA_MODEL); - assert_eq!(defaults.dimensions(), DEFAULT_OLLAMA_DIMENSIONS); - - let custom = OllamaEmbedding::try_new("http://[::1]:11434/", " nomic-embed-text ", 12) - .expect("custom ollama config"); - assert_eq!(custom.base_url(), "http://[::1]:11434"); - assert_eq!(custom.model(), "nomic-embed-text"); - assert_eq!( - custom.signature(), - "provider=ollama;model=nomic-embed-text;dims=12" - ); - - let explicit = OllamaEmbedding::new("http://127.0.0.1:11434", "mock-model", 3); - assert_eq!(explicit.base_url(), "http://127.0.0.1:11434"); - assert_eq!(explicit.model(), "mock-model"); - - let default = OllamaEmbedding::default(); - assert_eq!(default.base_url(), DEFAULT_OLLAMA_URL); - assert_eq!(default.model(), DEFAULT_OLLAMA_MODEL); - - for bad_url in [ - "ftp://localhost:11434", - "http://user:pass@localhost:11434", - "http://localhost:11434/api", - "http://localhost:11434/v1/chat/completions", - "http://localhost:11434?debug=true", - "http://localhost:11434/#fragment", - ] { - assert!( - OllamaEmbedding::try_new(bad_url, "m", 1).is_err(), - "bad Ollama URL should be rejected: {bad_url}" - ); - } - assert!(OllamaEmbedding::try_new("http://localhost:11434", "local-v1", 1).is_err()); -} - -#[tokio::test] -async fn embedding_catalog_factory_retry_noop_and_cloud_empty_paths_are_reachable() { - let providers = catalog::all_providers(); - assert!(providers.iter().any(|provider| provider.slug == "managed")); - assert!(providers.iter().any(|provider| provider.slug == "cohere")); - assert_eq!( - catalog::find_provider("openai") - .expect("openai provider") - .label, - "OpenAI" - ); - assert!(catalog::find_provider("missing").is_none()); - assert_eq!( - catalog::find_model("voyage", "voyage-3-large") - .expect("voyage model") - .default_dimensions, - 1024 - ); - assert!(catalog::find_model("voyage", "missing").is_none()); - assert_eq!( - catalog::default_model_for("openai") - .expect("default openai model") - .id, - "text-embedding-3-small" - ); - assert!(catalog::default_model_for("none").is_none()); - - assert_eq!(parse_retry_after_ms(Some(" 5 ")), Some(5_000)); - assert_eq!( - parse_retry_after_ms(Some("Wed, 21 Oct 2015 07:28:00 GMT")), - Some(0) - ); - assert_eq!(parse_retry_after_ms(Some("99999")), Some(MAX_BACKOFF_MS)); - assert_eq!(backoff_ms_for_attempt(2, Some("1")), 1_000); - assert_eq!(backoff_ms_for_attempt(1, None), BASE_BACKOFF_MS * 2); - assert_eq!(backoff_ms_for_attempt(10, Some("bad")), MAX_BACKOFF_MS); - - let noop = NoopEmbedding; - assert_eq!(noop.name(), "none"); - assert_eq!(noop.model_id(), "none"); - assert_eq!(noop.dimensions(), 0); - assert_eq!(noop.signature(), "provider=none;model=none;dims=0"); - assert_eq!( - noop.embed(&["ignored"]).await.expect("noop embed"), - Vec::>::new() - ); - assert!(noop - .embed_one("ignored") - .await - .expect_err("noop embed_one") - .to_string() - .contains("Empty embedding result")); - - let cloud = OpenHumanCloudEmbedding::new( - Some("https://api.example.test/".to_string()), - None, - false, - DEFAULT_CLOUD_EMBEDDING_MODEL, - DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, - ); - assert_eq!(cloud.name(), "cloud"); - assert!(cloud - .embed(&[]) - .await - .expect("cloud empty embed") - .is_empty()); - - for (provider, model, dims, expected_name) in [ - ( - "managed", - DEFAULT_CLOUD_EMBEDDING_MODEL, - DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, - "cloud", - ), - ("voyage", "", 0, "voyage"), - ("cohere", "", 0, "cohere"), - ("openai", "text-embedding-3-small", 1536, "openai"), - ("custom:http://127.0.0.1:9", "custom-embedding", 2, "openai"), - ("none", "", 0, "none"), - ] { - let embedder = - create_embedding_provider(provider, model, dims).expect("provider should construct"); - assert_eq!(embedder.name(), expected_name); - } - - match create_embedding_provider("unknown", "m", 1) { - Ok(_) => panic!("unknown provider should fail"), - Err(err) => assert!(err.to_string().contains("unknown embedding provider")), - } - - let default_cloud = openhuman_core::openhuman::embeddings::default_embedding_provider(); - assert_eq!(default_cloud.name(), "cloud"); - let default_local = openhuman_core::openhuman::embeddings::default_local_embedding_provider(); - assert_eq!(default_local.name(), "ollama"); - - for (provider, model, dims, key, endpoint, expected_name) in [ - ( - "managed", - DEFAULT_CLOUD_EMBEDDING_MODEL, - 1024, - "ignored", - None, - "cloud", - ), - ( - "voyage", - "voyage-3-large", - 1024, - "voyage-key", - None, - "voyage", - ), - ("ollama", DEFAULT_OLLAMA_MODEL, 1024, "", None, "ollama"), - ( - "openai", - "text-embedding-3-small", - 1536, - "openai-key", - None, - "openai", - ), - ( - "cohere", - "embed-english-v3.0", - 1024, - "cohere-key", - None, - "cohere", - ), - ( - "custom", - "custom-model", - 768, - "custom-key", - Some("http://127.0.0.1:9"), - "openai", - ), - ( - "custom:http://127.0.0.1:8", - "custom-model", - 768, - "custom-key", - None, - "openai", - ), - ("none", "", 0, "", None, "none"), - ] { - let embedder = - create_embedding_provider_with_credentials(provider, model, dims, key, endpoint) - .expect("provider with credentials should construct"); - assert_eq!(embedder.name(), expected_name); - } - - match create_embedding_provider_with_credentials("bogus", "m", 1, "k", None) { - Ok(_) => panic!("unknown provider with credentials should fail"), - Err(err) => assert!(err.to_string().contains("unknown embedding provider")), - } -} - -#[tokio::test] -async fn ollama_embed_preserves_positions_and_validates_request_and_response() { - let app = Router::new().route( - "/api/embed", - post(|Json(body): Json| async move { - assert_eq!( - body.get("model").and_then(Value::as_str), - Some("mock-ollama") - ); - assert_eq!( - body.pointer("/input/0").and_then(Value::as_str), - Some("alpha") - ); - assert_eq!( - body.pointer("/input/1").and_then(Value::as_str), - Some("beta") - ); - Json(json!({ "embeddings": [[1.0, 2.0], [3.0, 4.0]] })) - }), - ); - let base_url = serve_mock_ollama(app).await; - let provider = OllamaEmbedding::try_new(&base_url, "mock-ollama", 2).expect("provider"); - - let mixed_error = provider - .embed(&[" alpha ", "", "beta", " "]) - .await - .expect_err("mixed blank batch should be rejected"); - assert!(mixed_error.to_string().contains("must not mix blank")); - - let vectors = provider - .embed(&[" alpha ", "beta"]) - .await - .expect("ollama embed"); - assert_eq!(vectors, vec![vec![1.0, 2.0], vec![3.0, 4.0]]); - - let all_blank = provider.embed(&["", " \n\t "]).await.expect("blank embed"); - assert_eq!(all_blank, vec![Vec::::new(), Vec::::new()]); -} - -#[tokio::test] -async fn ollama_embed_reports_malformed_count_dimension_and_transport_errors() { - let count_url = serve_mock_ollama(Router::new().route( - "/api/embed", - post(|| async { Json(json!({ "embeddings": [[1.0]] })) }), - )) - .await; - let count_provider = OllamaEmbedding::try_new(&count_url, "m", 1).expect("count provider"); - assert!(count_provider - .embed(&["a", "b"]) - .await - .expect_err("count mismatch") - .to_string() - .contains("count mismatch")); - - let dim_url = serve_mock_ollama(Router::new().route( - "/api/embed", - post(|| async { Json(json!({ "embeddings": [[1.0, 2.0, 3.0]] })) }), - )) - .await; - let dim_provider = OllamaEmbedding::try_new(&dim_url, "m", 2).expect("dim provider"); - assert!(dim_provider - .embed(&["a"]) - .await - .expect_err("dimension mismatch") - .to_string() - .contains("dimension mismatch")); - - let malformed_url = serve_mock_ollama(Router::new().route( - "/api/embed", - post(|| async { (StatusCode::OK, "not json") }), - )) - .await; - let malformed_provider = - OllamaEmbedding::try_new(&malformed_url, "m", 2).expect("malformed provider"); - assert!(malformed_provider - .embed(&["a"]) - .await - .expect_err("malformed response") - .to_string() - .contains("parse failed")); - - let refused = OllamaEmbedding::try_new("http://127.0.0.1:1", "m", 2).expect("refused provider"); - assert!(refused - .embed(&["a"]) - .await - .expect_err("connection refused") - .to_string() - .contains("is Ollama running")); -} - -#[tokio::test] -async fn ollama_embed_recovers_nan_batch_with_per_text_fallback() { - let app = Router::new().route( - "/api/embed", - post(|Json(body): Json| async move { - let inputs = body - .get("input") - .and_then(Value::as_array) - .expect("input array"); - if inputs.len() > 1 { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - r#"{"error":"failed to encode response: json: unsupported value: NaN"}"# - .to_string(), - ); - } - if inputs.first().and_then(Value::as_str) == Some("bad") { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - "unsupported value: nan".to_string(), - ); - } - ( - StatusCode::OK, - json!({ "embeddings": [[9.0, 8.0]] }).to_string(), - ) - }), - ); - let base_url = serve_mock_ollama(app).await; - let provider = OllamaEmbedding::try_new(&base_url, "mock-ollama", 2).expect("provider"); - - let batch_error = provider - .embed(&["good", "bad"]) - .await - .expect_err("a NaN-producing item should fail the recovered batch"); - assert!(batch_error.to_string().contains("without NaN values")); - - let single_error = provider - .embed(&["bad"]) - .await - .expect_err("single NaN-producing input should fail"); - assert!(single_error.to_string().contains("without NaN values")); -} diff --git a/tests/raw_coverage/memory_sync_providers_raw_coverage_e2e.rs b/tests/raw_coverage/memory_sync_providers_raw_coverage_e2e.rs index fe893a08ba..60241c4e21 100644 --- a/tests/raw_coverage/memory_sync_providers_raw_coverage_e2e.rs +++ b/tests/raw_coverage/memory_sync_providers_raw_coverage_e2e.rs @@ -19,7 +19,7 @@ use openhuman_core::openhuman::credentials::{ AuthService, APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME, }; use openhuman_core::openhuman::memory::global as memory_global; -use openhuman_core::openhuman::memory::jobs::drain_until_idle; +use openhuman_core::openhuman::memory_queue::drain_until_idle; use openhuman_core::openhuman::memory_sync::composio::bus::{ ComposioConfigChangedSubscriber, ComposioConnectionCreatedSubscriber, ComposioTriggerSubscriber, }; diff --git a/tests/raw_coverage/memory_tree_embed_round25_raw_coverage_e2e.rs b/tests/raw_coverage/memory_tree_embed_round25_raw_coverage_e2e.rs index c79c211584..62d2cfcc4d 100644 --- a/tests/raw_coverage/memory_tree_embed_round25_raw_coverage_e2e.rs +++ b/tests/raw_coverage/memory_tree_embed_round25_raw_coverage_e2e.rs @@ -2,8 +2,9 @@ use axum::extract::Json; use axum::http::StatusCode; use axum::routing::post; use axum::Router; -use openhuman_core::openhuman::memory_tree::score::embed::{ - Embedder, OllamaEmbedder, EMBEDDING_DIM, +use openhuman_core::openhuman::memory_tree::score::embed::EMBEDDING_DIM; +use tinyagents::harness::embeddings::{ + EmbeddingModel, OllamaEmbeddingModel, RECOMMENDED_OLLAMA_CONTEXT_TOKENS, }; use serde_json::{json, Value}; @@ -24,38 +25,43 @@ async fn start_embed_server(app: Router) -> String { async fn round25_ollama_embedder_covers_success_and_error_edges_without_real_ollama() { let success_vec = vec![0.125_f32; EMBEDDING_DIM]; let app = Router::new().route( - "/api/embeddings", + "/api/embed", post({ let success_vec = success_vec.clone(); move |Json(body): Json| { let success_vec = success_vec.clone(); async move { assert_eq!(body["model"], "round25-embed"); - assert_eq!(body["prompt"], "memory tree round25"); + assert_eq!(body["input"][0], "memory tree round25"); assert_eq!(body["options"]["num_ctx"], 8192); - Json(json!({ "embedding": success_vec })) + assert_eq!(body["options"]["num_batch"], 8192); + Json(json!({ "embeddings": [success_vec] })) } } }), ); let url = start_embed_server(app).await; - let embedder = OllamaEmbedder::new(format!("{url}/"), "round25-embed".to_string(), 0); + let embedder = OllamaEmbeddingModel::new(&format!("{url}/"), "round25-embed", EMBEDDING_DIM) + .with_context_options( + RECOMMENDED_OLLAMA_CONTEXT_TOKENS, + RECOMMENDED_OLLAMA_CONTEXT_TOKENS, + ); assert_eq!(embedder.name(), "ollama"); let embedding = embedder - .embed("memory tree round25") + .embed(&["memory tree round25".to_string()]) .await .expect("loopback embedding"); - assert_eq!(embedding.len(), EMBEDDING_DIM); - assert!((embedding[0] - 0.125).abs() < f32::EPSILON); + assert_eq!(embedding[0].len(), EMBEDDING_DIM); + assert!((embedding[0][0] - 0.125).abs() < f32::EPSILON); let missing_model_url = start_embed_server(Router::new().route( - "/api/embeddings", + "/api/embed", post(|| async { (StatusCode::NOT_FOUND, "{\"error\":\"model not found\"}") }), )) .await; - let missing = OllamaEmbedder::new(missing_model_url, "missing-round25".to_string(), 500); + let missing = OllamaEmbeddingModel::new(&missing_model_url, "missing-round25", EMBEDDING_DIM); let missing_err = missing - .embed("text") + .embed(&["text".to_string()]) .await .expect_err("missing model should fail") .to_string(); @@ -63,27 +69,27 @@ async fn round25_ollama_embedder_covers_success_and_error_edges_without_real_oll assert!(missing_err.contains("ollama pull missing-round25")); let dim_url = start_embed_server(Router::new().route( - "/api/embeddings", - post(|| async { Json(json!({ "embedding": [0.1, 0.2, 0.3] })) }), + "/api/embed", + post(|| async { Json(json!({ "embeddings": [[0.1, 0.2, 0.3]] })) }), )) .await; - let dim_mismatch = OllamaEmbedder::new(dim_url, String::new(), 0); + let dim_mismatch = OllamaEmbeddingModel::new(&dim_url, "", EMBEDDING_DIM); let dim_err = dim_mismatch - .embed("text") + .embed(&["text".to_string()]) .await .expect_err("wrong dimensions should fail") .to_string(); - assert!(dim_err.contains("3 dims")); + assert!(dim_err.contains("got 3")); assert!(dim_err.contains("expected 1024")); let bad_json_url = start_embed_server(Router::new().route( - "/api/embeddings", + "/api/embed", post(|| async { (StatusCode::OK, "not-json") }), )) .await; - let bad_json = OllamaEmbedder::new(bad_json_url, String::new(), 0); + let bad_json = OllamaEmbeddingModel::new(&bad_json_url, "", EMBEDDING_DIM); let parse_err = bad_json - .embed("text") + .embed(&["text".to_string()]) .await .expect_err("invalid json should fail") .to_string(); diff --git a/vendor/tinyagents b/vendor/tinyagents index 2583fccc21..07467c34a6 160000 --- a/vendor/tinyagents +++ b/vendor/tinyagents @@ -1 +1 @@ -Subproject commit 2583fccc213a00f2a3d94744ff1e0d1541368f97 +Subproject commit 07467c34a69bb21409732b739aa84b161dd4cd07 diff --git a/vendor/tinycortex b/vendor/tinycortex index daaaf6ba5f..55bf0661d0 160000 --- a/vendor/tinycortex +++ b/vendor/tinycortex @@ -1 +1 @@ -Subproject commit daaaf6ba5f02635c08deae2b2b2ed7fcc8c06b6a +Subproject commit 55bf0661d003dd38dc33e8da45b46dd675710c9c From 81d09b6aa7fb7ed6ea28a688582870cb823fd63f Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Thu, 23 Jul 2026 05:50:40 +0300 Subject: [PATCH 54/56] feat(tui): add logs-first tabbed CLI experience (#5131) --- AGENTS.md | 6 +- Cargo.toml | 6 +- Dockerfile | 2 + docs/TEST-COVERAGE-MATRIX.md | 8 +- docs/plans/tui-chat-plan.md | 5 + src/core/cli.rs | 52 +++- src/core/cli_tests.rs | 76 +++++- src/core/logging.rs | 122 ++++++++- src/openhuman/about_app/catalog_data.rs | 19 +- src/openhuman/credentials/ops.rs | 39 ++- src/openhuman/tui/app.rs | 168 ++++++++++-- src/openhuman/tui/controls.rs | 338 ++++++++++++++++++++++++ src/openhuman/tui/mod.rs | 10 +- src/openhuman/tui/render.rs | 313 +++++++++++++++++----- src/openhuman/tui/runner.rs | 28 +- src/openhuman/tui/state.rs | 2 +- src/openhuman/tui/stub.rs | 2 +- src/openhuman/tui/terminal.rs | 18 +- src/openhuman/tui/ui_state.rs | 179 +++++++++++++ 19 files changed, 1261 insertions(+), 132 deletions(-) create mode 100644 src/openhuman/tui/controls.rs create mode 100644 src/openhuman/tui/ui_state.rs diff --git a/AGENTS.md b/AGENTS.md index b4625e091d..c29e73a176 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -243,7 +243,7 @@ GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml \ | `skills` | ON | `openhuman::skills` + `openhuman::skill_runtime` + `openhuman::skill_registry` domains — SKILL.md discovery/parse/install, workflow execution + run logs, remote catalogs, the `skill_setup` / `skill_executor` builtin agents, and the 16 skill agent tools | none (see below) | | `flows` | ON | `openhuman::flows` (saved automation graphs — create/run/schedule, the `workflow_builder` + `flow_discovery` agents), `openhuman::tinyflows` (engine seam), `openhuman::rhai_workflows` (`.ragsh` language-workflow tool) | `tinyflows`, `jaq-core`, `jaq-std`, `jaq-json`, `rhai` | | `mcp` | ON | `openhuman::mcp_server` (the `openhuman mcp` stdio/HTTP server), `openhuman::mcp_registry` (dynamic Smithery installs — `mcp_clients` RPC namespace, SQLite, boot spawn, supervisor, OAuth), `openhuman::mcp_audit` (write-audit log), and the static config-declared server set in `openhuman::mcp_client`. ~19 agent tools, ~20k LOC | **none** (see scope note) | -| `tui` | ON | `openhuman::tui` — the `openhuman tui` (alias `chat`) CLI subcommand: a ratatui/crossterm terminal chat UI onto the `web_chat` surface, running the core in-process. No controllers, no agent tools. **Intentionally NOT forwarded to the desktop shell** (allowlisted in `check-feature-forwarding.mjs`). | `ratatui`, `crossterm` | +| `tui` | ON | `openhuman::tui` — the tabbed ratatui/crossterm CLI UI (Logs, Chat, Config, Settings), auto-opened by bare `openhuman` on interactive non-container hosts and forced with `openhuman tui` (alias `chat`). Runs the core in-process. No controllers, no agent tools. **Intentionally NOT forwarded to the desktop shell** (allowlisted in `check-feature-forwarding.mjs`). | `ratatui`, `crossterm` | | `channels` | ON | `openhuman::channels` (external-messaging providers — Telegram/Discord/Slack/Signal/WhatsApp/iMessage/IRC/… — plus the channel runtime, controllers, host, proactive messaging + inbound dispatch) and the `webview_accounts` / `webview_apis` / `webview_notifications` / `whatsapp_data` webview-bridge domains (incl. the 3 `whatsapp_data_*` agent tools). **Carve-outs `channels::{traits, cli}` stay ungated.** | **none** (`tinychannels` is load-bearing) | **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. @@ -310,10 +310,10 @@ Follows the voice facade+stub pattern for `mcp_server` / `mcp_registry` / `mcp_a #### The `tui` gate -The terminal chat UI (`openhuman tui` / alias `chat`) lives in `src/openhuman/tui/` and follows the **`mcp`/`voice` facade+stub** pattern: `pub mod tui;` is always compiled; the behavioural submodules (`app`, `render`, `state`, `terminal`, `runner`) are `#[cfg(feature = "tui")]`; and `#[cfg(not(feature = "tui"))] mod stub;` re-exposes the one symbol an always-compiled caller reaches — `run_from_cli` — with a build-fact error body (`"tui feature disabled at compile time … --features tui"`). +The tabbed terminal UI (`openhuman`, or explicitly `openhuman tui` / alias `chat`) lives in `src/openhuman/tui/` and follows the **`mcp`/`voice` facade+stub** pattern: `pub mod tui;` is always compiled; the behavioural submodules (`app`, `render`, `state`, `terminal`, `runner`) are `#[cfg(feature = "tui")]`; and `#[cfg(not(feature = "tui"))] mod stub;` re-exposes the one symbol an always-compiled caller reaches — `run_from_cli` — with a build-fact error body (`"tui feature disabled at compile time … --features tui"`). Bare-command auto-launch requires terminal stdin/stdout and `HostKind::Cli`; Docker, CI, pipes, and `--no-tui` retain the non-TUI CLI path. - **The `"tui" | "chat"` CLI arm in `src/core/cli.rs` is un-`#[cfg]`'d on purpose.** In a slim build it resolves to `tui::stub::run_from_cli`, which bails with the disabled-error rather than falling through to `unknown namespace: tui` (which reads like a typo, not a build fact). Same reasoning as the `mcp` arm. Pinned by `tui_subcommand_reports_disabled_build_when_gate_off` / `chat_alias_reports_disabled_build_when_gate_off` in `src/core/cli_tests.rs` (both `#[cfg(not(feature = "tui"))]`). `"tui" | "chat"` is also added to the banner-suppression `matches!` (a TUI owns the terminal — a banner would corrupt it). -- **No controllers, no agent tools, no `all.rs` changes.** The TUI is a pure *client* of the existing `web_chat` surface — it boots the core in-process (`CoreBuilder::new(HostKind::Cli).domains(DomainSet::full()).services(ServiceSet::none())`), sends turns via `runtime.invoke("openhuman.channel_web_chat", …)`, and streams by draining `web_chat::subscribe_web_channel_events()` filtered by its own `client_id`. `openhuman.channel_web_chat` needs `DomainGroup::Channels`, so `DomainSet::full()` (not `harness()`) is required. +- **No controllers, no agent tools, no `all.rs` changes.** The TUI is a pure *client* of existing registered controllers — it boots the core in-process (`CoreBuilder::new(HostKind::detect_standalone()).domains(DomainSet::full()).services(ServiceSet::none())`), sends chat turns through `web_chat`, reads a bounded in-memory copy of the file-only core log stream, edits only curated safe config getters/updaters, and invokes auth controllers for account/status actions. Never render `config.get` wholesale because the full snapshot can contain secrets. - **Terminal hygiene is load-bearing.** `logging::init_for_tui` installs a **file-only** subscriber (never stderr) — a single core boot log on stdout/stderr would corrupt the alternate-screen UI. `terminal::TerminalGuard` restores raw mode + the main screen on `Drop`, and a panic hook chains a restore ahead of the default hook. All `[tui]` state-transition logs go to the file, never `println!`. - **Intentionally NOT forwarded to the desktop shell** (the app ships its own Tauri UI). It carries the only current entry in `INTENTIONALLY_NOT_FORWARDED` in `scripts/ci/check-feature-forwarding.mjs`; the pure reducer lives in `src/openhuman/tui/state.rs` (`TranscriptState::apply_event`) with unit tests, so most behaviour is testable without a terminal. diff --git a/Cargo.toml b/Cargo.toml index 6b023d3904..17575b1c3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -585,9 +585,9 @@ crash-reporting = ["dep:sentry"] # NOTE: only `uiautomation` is exclusive and thus shed. `enigo` is shared with the # `voice` domain; `rdev` / `arboard` are voice-owned — none of those are dropped here. desktop-automation = ["dep:uiautomation"] -# Terminal chat UI: the `openhuman tui` (alias `chat`) CLI subcommand, a -# ratatui/crossterm terminal front-end onto the same `web_chat` surface the -# desktop app drives. Default-ON for the standalone `openhuman-core` binary, but +# Tabbed terminal UI: the `openhuman tui` (alias `chat`) CLI subcommand, a +# ratatui/crossterm front-end for logs, orchestrator chat, curated configuration, +# and account settings. Default-ON for the standalone `openhuman-core` binary, but # INTENTIONALLY NOT forwarded to the desktop shell (the desktop app ships its own # Tauri UI and never needs a terminal one) — see the allowlist entry in # `scripts/ci/check-feature-forwarding.mjs`. Slim / headless builds opt out via diff --git a/Dockerfile b/Dockerfile index d7884f3278..a34fae653b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -117,6 +117,8 @@ ENV OPENHUMAN_WORKSPACE=/home/openhuman/.openhuman # Bind to all interfaces so the container is reachable ENV OPENHUMAN_CORE_HOST=0.0.0.0 ENV OPENHUMAN_CORE_PORT=7788 +# Stable first-party signal for CLI launch policy; containers default headless. +ENV OPENHUMAN_DOCKER=1 ENV RUST_LOG=info # AgentBox marketplace mode — off by default for desktop builds. The # AgentBox console flips this on per deployment, along with GMI_MAAS_*. diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index 717434d05a..4c027dd302 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -600,7 +600,7 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an --- -## 14. Terminal Chat UI (`openhuman tui`) +## 14. Tabbed Terminal UI (`openhuman` / `openhuman tui`) ### 14.1 TUI Subcommand (feature-gated `tui`) @@ -608,9 +608,11 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an | ------ | ------------------------------------------------- | ----- | ----------------------------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------- | | 14.1.1 | Transcript reducer (deltas, done, error, tools) | RU | `src/openhuman/tui/state.rs` | ✅ | Pure `TranscriptState::apply_event` — client-id filtering, text/thinking split, `chat_done` finalization | | 14.1.2 | Runner flags + thread resolution + RPC name pins | RU | `src/openhuman/tui/runner.rs`, `src/openhuman/tui/app.rs` | ✅ | `--thread`/`--new` parsing; canonical `openhuman.*` method names resolve via registry | -| 14.1.3 | Render layout (viewport, input, status) | RU | `src/openhuman/tui/render.rs` | ✅ | ratatui TestBackend | +| 14.1.3 | Tab order + render layout + persistent footer | RU | `src/openhuman/tui/ui_state.rs`, `src/openhuman/tui/render.rs` | ✅ | Logs-first tab state and ratatui TestBackend | | 14.1.4 | Disabled-build stub (`--no-default-features`) | RU | `src/core/cli_tests.rs` (`tui`/`chat` `*_reports_disabled_build_when_gate_off`) | ✅ | Build-fact error, not `unknown namespace` | -| 14.1.5 | Interactive terminal session (raw mode, streaming) | MS | manual smoke | 🚫 | Needs a real TTY; not driver-automatable | +| 14.1.5 | Bare-command launch policy | RU | `src/core/cli_tests.rs` | ✅ | TTY + CLI auto-launch; Docker, pipes, feature-off, and explicit commands stay headless | +| 14.1.6 | Bounded live core-log buffer | RU | `src/core/logging.rs` | ✅ | Preserves ordering; bounds both line count and individual line length | +| 14.1.7 | Interactive terminal session (raw mode, streaming) | MS | manual PTY smoke | 🚫 | Requires a real PTY to verify key input and terminal restoration | ## Summary diff --git a/docs/plans/tui-chat-plan.md b/docs/plans/tui-chat-plan.md index 76c71356c1..a863e9fcfc 100644 --- a/docs/plans/tui-chat-plan.md +++ b/docs/plans/tui-chat-plan.md @@ -1,5 +1,10 @@ # Plan: `openhuman tui` — feature-gated terminal chat UI +> Historical v1 plan. The shipped CLI now extends this foundation into a Logs-first four-tab UI +> (Logs, Chat, Config, Settings). Bare `openhuman` auto-launches only with terminal stdin/stdout on +> a non-container host; `--no-tui` suppresses that default and explicit `openhuman tui` still forces +> the UI. Config uses curated safe getters/updaters, and Settings uses registered auth controllers. + ## Goal Running `openhuman-core tui` (alias `chat`) opens a ratatui-based terminal UI that is an diff --git a/src/core/cli.rs b/src/core/cli.rs index 78fa79390d..4549b53851 100644 --- a/src/core/cli.rs +++ b/src/core/cli.rs @@ -7,6 +7,7 @@ use anyhow::Result; use serde_json::{Map, Value}; use std::collections::BTreeMap; +use std::io::IsTerminal; use crate::core::all; use crate::core::autocomplete_cli_adapter; @@ -44,6 +45,25 @@ Contribute & Star us on GitHub: https://github.com/tinyhumansai/openhuman /// Returns an error if the command fails, parameters are invalid, or if /// the subcommand/namespace is unknown. pub fn run_from_cli_args(args: &[String]) -> Result<()> { + load_dotenv_for_cli()?; + + let host = crate::core::types::HostKind::detect_standalone(); + if args == ["--tui"] + || should_auto_launch_tui( + args, + std::io::stdin().is_terminal(), + std::io::stdout().is_terminal(), + host, + cfg!(feature = "tui"), + ) + { + return crate::openhuman::tui::run_from_cli(&[]); + } + + // `--no-tui` is a global opt-out, not a synthetic subcommand. Strip it + // before normal dispatch so `openhuman --no-tui --help` and + // `openhuman --no-tui run ...` retain their ordinary CLI meaning. + let args = strip_no_tui(args); // Print the welcome banner to stderr to keep stdout clean for JSON output. // `mcp`/`mcp-server` speak JSON-RPC on stdout; `tui`/`chat` own the whole // terminal (alternate screen + raw mode) — a banner on either would corrupt @@ -56,8 +76,6 @@ pub fn run_from_cli_args(args: &[String]) -> Result<()> { eprint!("{CLI_BANNER}"); } - load_dotenv_for_cli()?; - let grouped = grouped_schemas(); if args.is_empty() || is_help(&args[0]) { print_general_help(&grouped); @@ -101,6 +119,31 @@ pub fn run_from_cli_args(args: &[String]) -> Result<()> { } } +/// Pure launch policy for the bare `openhuman` command. Explicit subcommands +/// are never rewritten. Docker and redirected/CI sessions keep the headless +/// CLI behavior; `openhuman tui` remains an explicit override everywhere. +fn should_auto_launch_tui( + args: &[String], + stdin_is_terminal: bool, + stdout_is_terminal: bool, + host: crate::core::types::HostKind, + tui_compiled: bool, +) -> bool { + args.is_empty() + && stdin_is_terminal + && stdout_is_terminal + && host == crate::core::types::HostKind::Cli + && tui_compiled +} + +fn strip_no_tui(args: &[String]) -> &[String] { + if args.first().map(String::as_str) == Some("--no-tui") { + &args[1..] + } else { + args + } +} + /// Handles the `sentry-test` subcommand used to verify Sentry wiring end-to-end. /// /// Captures an Error-level event against the currently initialized Sentry @@ -588,12 +631,15 @@ fn grouped_schemas() -> BTreeMap> { fn print_general_help(grouped: &BTreeMap>) { println!("OpenHuman core CLI\n"); println!("Usage:"); + println!(" openhuman [--no-tui] (tabbed terminal UI on interactive hosts)"); println!(" openhuman run [--host ] [--port ] [--jsonrpc-only] [--verbose]"); println!(" openhuman call --method [--params '']"); println!( " openhuman mcp [-v|--verbose] (stdio MCP server; read-only memory tools)" ); - println!(" openhuman tui [--thread |--new] (terminal chat UI, alias: chat)"); + println!( + " openhuman tui [--thread |--new] (force tabbed terminal UI, alias: chat)" + ); println!(" openhuman skills [options] (skill development runtime)"); println!(" openhuman agent [options] (inspect agent definitions & prompts)"); println!(" openhuman voice [--hotkey ] [--mode ] (voice dictation server)"); diff --git a/src/core/cli_tests.rs b/src/core/cli_tests.rs index 3354a333be..35a1dfe7e1 100644 --- a/src/core/cli_tests.rs +++ b/src/core/cli_tests.rs @@ -1,10 +1,84 @@ -use super::{grouped_schemas, load_dotenv_for_cli, parse_function_params, parse_input_value}; +use super::{ + grouped_schemas, load_dotenv_for_cli, parse_function_params, parse_input_value, + should_auto_launch_tui, strip_no_tui, +}; +use crate::core::types::HostKind; use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; use std::sync::{Mutex, OnceLock}; use tempfile::tempdir; static CLI_ENV_LOCK: OnceLock> = OnceLock::new(); +#[test] +fn bare_cli_auto_launches_tui_only_for_interactive_non_container_hosts() { + let none: Vec = vec![]; + assert!(should_auto_launch_tui( + &none, + true, + true, + HostKind::Cli, + true + )); + assert!(!should_auto_launch_tui( + &none, + false, + true, + HostKind::Cli, + true + )); + assert!(!should_auto_launch_tui( + &none, + true, + false, + HostKind::Cli, + true + )); + assert!(!should_auto_launch_tui( + &none, + true, + true, + HostKind::Docker, + true + )); + assert!(!should_auto_launch_tui( + &none, + true, + true, + HostKind::Cli, + false + )); +} + +#[test] +fn explicit_args_never_trigger_bare_cli_auto_launch() { + for args in [ + vec!["--no-tui".to_string()], + vec!["run".to_string()], + vec!["tui".to_string()], + ] { + assert!(!should_auto_launch_tui( + &args, + true, + true, + HostKind::Cli, + true + )); + } +} + +#[test] +fn no_tui_is_stripped_before_normal_cli_dispatch() { + let args = vec![ + "--no-tui".to_string(), + "run".to_string(), + "--jsonrpc-only".to_string(), + ]; + assert_eq!(strip_no_tui(&args), &args[1..]); + + let ordinary = vec!["run".to_string()]; + assert_eq!(strip_no_tui(&ordinary), ordinary.as_slice()); +} + fn env_lock() -> std::sync::MutexGuard<'static, ()> { CLI_ENV_LOCK .get_or_init(|| Mutex::new(())) diff --git a/src/core/logging.rs b/src/core/logging.rs index fe26d0ccb4..82662a07da 100644 --- a/src/core/logging.rs +++ b/src/core/logging.rs @@ -11,8 +11,9 @@ //! calls and core `tracing::*` calls funnel into the same file via //! [`tracing_log::LogTracer`]. +use std::collections::VecDeque; use std::fmt; -use std::io::{self, IsTerminal}; +use std::io::{self, IsTerminal, Write}; use std::path::{Path, PathBuf}; use std::sync::{Mutex, Once, OnceLock}; @@ -21,6 +22,7 @@ use tracing::{Event, Level}; use tracing_appender::non_blocking::WorkerGuard; use tracing_subscriber::fmt::format::{FormatEvent, FormatFields, Writer}; use tracing_subscriber::fmt::FmtContext; +use tracing_subscriber::fmt::MakeWriter; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::registry::LookupSpan; use tracing_subscriber::util::SubscriberInitExt; @@ -47,6 +49,67 @@ static FILE_GUARD: Mutex> = Mutex::new(None); /// it without re-deriving the data dir. static LOG_DIR: OnceLock = OnceLock::new(); +const TUI_LOG_CAPACITY: usize = 2_000; +const TUI_LOG_LINE_MAX_CHARS: usize = 4_096; +static TUI_LOG_BUFFER: OnceLock>>> = OnceLock::new(); + +#[derive(Clone)] +struct TuiLogMakeWriter { + buffer: std::sync::Arc>>, +} + +struct TuiLogWriter { + buffer: std::sync::Arc>>, + pending: Vec, +} + +impl<'a> MakeWriter<'a> for TuiLogMakeWriter { + type Writer = TuiLogWriter; + + fn make_writer(&'a self) -> Self::Writer { + TuiLogWriter { + buffer: self.buffer.clone(), + pending: Vec::new(), + } + } +} + +impl Write for TuiLogWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.pending.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + self.publish(); + Ok(()) + } +} + +impl TuiLogWriter { + fn publish(&mut self) { + if self.pending.is_empty() { + return; + } + let rendered = String::from_utf8_lossy(&self.pending); + if let Ok(mut lines) = self.buffer.lock() { + for line in rendered.lines().filter(|line| !line.is_empty()) { + if lines.len() == TUI_LOG_CAPACITY { + lines.pop_front(); + } + lines.push_back(line.chars().take(TUI_LOG_LINE_MAX_CHARS).collect()); + } + } + self.pending.clear(); + } +} + +impl Drop for TuiLogWriter { + fn drop(&mut self) { + self.publish(); + } +} + /// Default `RUST_LOG` when it is unset: either global levels or only the inline autocomplete module tree. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CliLogDefault { @@ -377,15 +440,25 @@ pub fn init_for_tui(data_dir: &Path, verbose: bool) -> Option { })) }); + let tui_buffer = std::sync::Arc::new(Mutex::new(VecDeque::new())); + let tui_layer = tracing_subscriber::fmt::layer() + .with_ansi(false) + .event_format(CleanCliFormat) + .with_writer(TuiLogMakeWriter { + buffer: tui_buffer.clone(), + }); + // NOTE: no stderr layer here — that is the whole point of this entry // point. Only the file layer + Sentry are attached. if tracing_subscriber::registry() .with(filter) .with(file_layer) + .with(tui_layer) .with(sentry_tracing_layer()) .try_init() .is_ok() { + let _ = TUI_LOG_BUFFER.set(tui_buffer); if let Some((_, guard, dir)) = pending_file { if let Ok(mut slot) = FILE_GUARD.lock() { *slot = Some(guard); @@ -400,6 +473,16 @@ pub fn init_for_tui(data_dir: &Path, verbose: bool) -> Option { log_directory().map(Path::to_path_buf) } +/// Snapshot the bounded in-memory log stream rendered by the terminal Logs tab. +/// The file appender remains authoritative for long-term retention. +pub fn tui_log_lines() -> Vec { + TUI_LOG_BUFFER + .get() + .and_then(|buffer| buffer.lock().ok()) + .map(|lines| lines.iter().cloned().collect()) + .unwrap_or_default() +} + /// Path to the active log directory (set by [`init_for_embedded`]). Returns /// `None` if logging hasn't been initialized in embedded mode (e.g. bare /// CLI runs). @@ -671,4 +754,41 @@ mod tests { } } } + + #[test] + fn tui_log_writer_keeps_a_bounded_ordered_ring() { + let buffer = std::sync::Arc::new(Mutex::new(VecDeque::new())); + let mut writer = TuiLogWriter { + buffer: buffer.clone(), + pending: Vec::new(), + }; + for index in 0..=TUI_LOG_CAPACITY { + writeln!(writer, "line-{index}").expect("write log line"); + writer.flush().expect("flush log line"); + } + let lines = buffer.lock().expect("buffer lock"); + assert_eq!(lines.len(), TUI_LOG_CAPACITY); + assert_eq!(lines.front().map(String::as_str), Some("line-1")); + let expected_last = format!("line-{TUI_LOG_CAPACITY}"); + assert_eq!( + lines.back().map(String::as_str), + Some(expected_last.as_str()) + ); + } + + #[test] + fn tui_log_writer_caps_individual_lines() { + let buffer = std::sync::Arc::new(Mutex::new(VecDeque::new())); + let mut writer = TuiLogWriter { + buffer: buffer.clone(), + pending: Vec::new(), + }; + writeln!(writer, "{}", "x".repeat(TUI_LOG_LINE_MAX_CHARS + 50)).expect("write long line"); + writer.flush().expect("flush long line"); + let lines = buffer.lock().expect("buffer lock"); + assert_eq!( + lines.front().map(|line| line.chars().count()), + Some(TUI_LOG_LINE_MAX_CHARS) + ); + } } diff --git a/src/openhuman/about_app/catalog_data.rs b/src/openhuman/about_app/catalog_data.rs index cc08c6003a..74dae91590 100644 --- a/src/openhuman/about_app/catalog_data.rs +++ b/src/openhuman/about_app/catalog_data.rs @@ -241,18 +241,17 @@ pub(super) const CAPABILITIES: &[Capability] = &[ }, Capability { id: "conversation.terminal_chat", - name: "Terminal Chat (TUI)", + name: "Tabbed Terminal UI", domain: "tui", category: CapabilityCategory::Conversation, - description: "Chat with the assistant from a terminal instead of the desktop UI. \ - `openhuman tui` (alias `chat`) opens a ratatui full-screen chat onto the \ - same conversation surface the app uses, running the core in-process. \ - Streams replies, thinking, and tool activity live; supports scrollback, \ - cancelling a turn, and starting a new thread. Ships only in the standalone \ - `openhuman-core` binary (the desktop app has its own UI).", - how_to: "Run `openhuman tui` (or `openhuman chat`) from a terminal. Flags: \ - `--thread ` to resume a thread, `--new` for a fresh one. Keys: Enter send, \ - Esc cancel, Ctrl+N new thread, PgUp/PgDn scroll, Ctrl+C quit.", + description: "Operate OpenHuman from a terminal through four tabs: live core logs, \ + orchestrator chat, safe configuration, and account settings. Bare \ + `openhuman` opens it on an interactive non-container host; `openhuman tui` \ + (alias `chat`) forces it. The chat streams replies, thinking, and tools live.", + how_to: "Run `openhuman`, or `openhuman tui` to force the UI. Use Tab/Shift+Tab or Alt+1-4 \ + to switch Logs, Chat, Config, and Settings. `--thread ` resumes a chat and \ + `--new` starts one. Settings accepts a one-time login token and supports account \ + refresh and logout. Use `openhuman --no-tui` to suppress automatic launch.", status: CapabilityStatus::Beta, privacy: DERIVED_TO_BACKEND, }, diff --git a/src/openhuman/credentials/ops.rs b/src/openhuman/credentials/ops.rs index 98203bbeb6..06d19e78d0 100644 --- a/src/openhuman/credentials/ops.rs +++ b/src/openhuman/credentials/ops.rs @@ -739,6 +739,7 @@ fn normalize_local_session_user(user: serde_json::Value, local_user_id: &str) -> } pub async fn clear_session(config: &Config) -> Result, String> { + let mut logs = Vec::new(); // Flip the scheduler-gate override first so any background worker that // is mid-iteration (or wakes up while we tear down) stalls at its next // `wait_for_capacity()` call instead of firing requests at a backend @@ -769,15 +770,45 @@ pub async fn clear_session(config: &Config) -> Result { + let workspace = signed_out_config.workspace_dir.clone(); + if let Err(error) = crate::openhuman::memory::global::init(workspace.clone()) { + tracing::warn!(%error, "failed to rebind memory after logout"); + } + if let Err(error) = + crate::core::runtime::context::CoreContext::rebind_default_workspace_dir(&workspace) + { + tracing::warn!(%error, "failed to rebind core context after logout"); + } + if let Err(error) = crate::openhuman::people::store::init_from_workspace(&workspace) { + tracing::warn!(%error, "failed to rebind people store after logout"); + } + crate::openhuman::memory_conversations::register_conversation_persistence_subscriber( + workspace.clone(), + ); + logs.push(format!( + "process globals rebound to signed-out workspace {}", + workspace.display() + )); + } + Err(error) => { + tracing::warn!(%error, "failed to resolve signed-out workspace after logout"); + logs.push(format!("signed-out workspace rebind warning: {error}")); + } + } + // Drop the Sentry scope user so events surfaced during/after teardown // (and before the next login) are no longer attributed to the // signed-out account — issue #3135. super::sentry_scope::clear(); - Ok(RpcOutcome::single_log( - json!({ "removed": removed }), - "session cleared", - )) + logs.push("session cleared".to_string()); + Ok(RpcOutcome::new(json!({ "removed": removed }), logs)) } pub async fn auth_get_state( diff --git a/src/openhuman/tui/app.rs b/src/openhuman/tui/app.rs index a1c944d5a5..a1edd2481c 100644 --- a/src/openhuman/tui/app.rs +++ b/src/openhuman/tui/app.rs @@ -24,11 +24,12 @@ use crate::core::runtime::CoreRuntime; use crate::core::socketio::WebChannelEvent; use crate::openhuman::web_chat; -use super::render::{self, UiState}; +use super::render; use super::state::TranscriptState; use super::terminal::TerminalGuard; +use super::ui_state::{AppTab, UiState}; -/// Run the terminal chat loop until the user quits (Ctrl+C / Ctrl+D) or the +/// Run the tabbed terminal loop until the user quits (Ctrl+C / Ctrl+D) or the /// web-channel bus closes. The [`TerminalGuard`] restores the terminal on every /// exit path, including panics. pub async fn run( @@ -37,13 +38,16 @@ pub async fn run( thread_id: String, mut web_rx: broadcast::Receiver, ) -> anyhow::Result<()> { - let mut guard = TerminalGuard::enter()?; - let mut state = TranscriptState::new(client_id.clone()); state.push_system(format!( "Connected · thread {thread_id}. Type a message and press Enter. Ctrl+C to quit." )); let mut ui = UiState::new(thread_id, client_id.clone()); + super::controls::refresh_config(&runtime, &mut ui).await; + super::controls::refresh_auth(&runtime, &mut ui).await; + // Resolve local startup state before taking over the terminal. A slow or + // locked config must never strand the user on a blank raw-mode screen. + let mut guard = TerminalGuard::enter()?; // Blocking crossterm reader → async channel. let (input_tx, mut input_rx) = tokio::sync::mpsc::unbounded_channel::(); @@ -77,8 +81,12 @@ pub async fn run( Some(Event::Key(key)) => { if handle_key(key, &runtime, &client_id, &mut state, &mut ui).await { quit = true; + } else if ui.identity_changed { + ui.identity_changed = false; + new_thread(&runtime, &mut state, &mut ui).await; } } + Some(Event::Paste(text)) => handle_paste(&text, &mut ui), Some(_) => {} // resize / mouse / paste — redraw next iteration None => quit = true, // reader thread gone }, @@ -118,29 +126,103 @@ async fn handle_key( } let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - match key.code { - KeyCode::Char('c') if ctrl => { - log::info!("[tui] quit via Ctrl+C"); - return true; - } - KeyCode::Char('d') if ctrl => { - log::info!("[tui] quit via Ctrl+D"); - return true; - } - KeyCode::Char('n') if ctrl => new_thread(runtime, state, ui).await, - KeyCode::Esc => cancel_turn(runtime, client_id, &ui.thread_id, state), - KeyCode::PageUp => { - ui.scroll_from_bottom = ui.scroll_from_bottom.saturating_add(5); + if matches!(key.code, KeyCode::Char('c')) && ctrl { + log::info!("[tui] quit via Ctrl+C"); + return true; + } + if matches!(key.code, KeyCode::Char('d')) && ctrl { + log::info!("[tui] quit via Ctrl+D"); + return true; + } + + if !ui.is_editing() { + if let Some(tab) = tab_shortcut(key, ui.active_tab) { + ui.active_tab = tab; + } else { + return handle_tab_key(key, runtime, client_id, state, ui).await; } - KeyCode::PageDown => { - ui.scroll_from_bottom = ui.scroll_from_bottom.saturating_sub(5); + return false; + } + + handle_tab_key(key, runtime, client_id, state, ui).await +} + +fn tab_shortcut(key: KeyEvent, current: AppTab) -> Option { + let shift = key.modifiers.contains(KeyModifiers::SHIFT); + let alt = key.modifiers.contains(KeyModifiers::ALT); + match key.code { + KeyCode::Tab if shift => Some(current.previous()), + KeyCode::Tab => Some(current.next()), + KeyCode::BackTab => Some(current.previous()), + KeyCode::Char('1') if alt => Some(AppTab::Logs), + KeyCode::Char('2') if alt => Some(AppTab::Chat), + KeyCode::Char('3') if alt => Some(AppTab::Config), + KeyCode::Char('4') if alt => Some(AppTab::Settings), + _ => None, + } +} + +fn handle_paste(text: &str, ui: &mut UiState) { + match ui.active_tab { + AppTab::Chat => ui.input.push_str(text), + AppTab::Config => { + if let Some(input) = &mut ui.config_edit { + input.push_str(text); + } } - KeyCode::Enter => send_message(runtime, client_id, state, ui), - KeyCode::Backspace => { - ui.input.pop(); + AppTab::Settings => { + if let Some(token) = &mut ui.login_token { + token.push_str(text); + } } - KeyCode::Char(c) if !ctrl => ui.input.push(c), - _ => {} + AppTab::Logs => {} + } +} + +async fn handle_tab_key( + key: KeyEvent, + runtime: &Arc, + client_id: &str, + state: &mut TranscriptState, + ui: &mut UiState, +) -> bool { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match ui.active_tab { + AppTab::Logs => match key.code { + KeyCode::PageUp | KeyCode::Up => { + ui.log_scroll_from_bottom = ui.log_scroll_from_bottom.saturating_add(5) + } + KeyCode::PageDown | KeyCode::Down => { + ui.log_scroll_from_bottom = ui.log_scroll_from_bottom.saturating_sub(5) + } + _ => {} + }, + AppTab::Chat => match key.code { + KeyCode::Char('c') if ctrl => { + log::info!("[tui] quit via Ctrl+C"); + return true; + } + KeyCode::Char('d') if ctrl => { + log::info!("[tui] quit via Ctrl+D"); + return true; + } + KeyCode::Char('n') if ctrl => new_thread(runtime, state, ui).await, + KeyCode::Esc => cancel_turn(runtime, client_id, &ui.thread_id, state), + KeyCode::PageUp => { + ui.scroll_from_bottom = ui.scroll_from_bottom.saturating_add(5); + } + KeyCode::PageDown => { + ui.scroll_from_bottom = ui.scroll_from_bottom.saturating_sub(5); + } + KeyCode::Enter => send_message(runtime, client_id, state, ui), + KeyCode::Backspace => { + ui.input.pop(); + } + KeyCode::Char(c) if !ctrl => ui.input.push(c), + _ => {} + }, + AppTab::Config => super::controls::handle_config_key(key, runtime, ui).await, + AppTab::Settings => super::controls::handle_settings_key(key, runtime, ui).await, } false } @@ -227,6 +309,8 @@ async fn new_thread(runtime: &Arc, state: &mut TranscriptState, ui: .and_then(|v| super::runner::extract_thread_id(&v)) { Some(new_id) => { + let client_id = state.client_id().to_string(); + *state = TranscriptState::new(client_id); ui.thread_id = new_id.clone(); ui.scroll_from_bottom = 0; state.push_system(format!("Started a new thread · {new_id}")); @@ -238,3 +322,39 @@ async fn new_thread(runtime: &Arc, state: &mut TranscriptState, ui: } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent::new(code, modifiers) + } + + #[test] + fn plain_digits_remain_chat_input_and_alt_digits_switch_tabs() { + for digit in ['1', '2', '3', '4'] { + assert_eq!( + tab_shortcut(key(KeyCode::Char(digit), KeyModifiers::NONE), AppTab::Chat), + None + ); + } + assert_eq!( + tab_shortcut(key(KeyCode::Char('3'), KeyModifiers::ALT), AppTab::Chat), + Some(AppTab::Config) + ); + } + + #[test] + fn paste_routes_only_to_the_active_editable_surface() { + let mut ui = UiState::new("thread".into(), "client".into()); + ui.active_tab = AppTab::Chat; + handle_paste("model-4", &mut ui); + assert_eq!(ui.input, "model-4"); + + ui.active_tab = AppTab::Settings; + ui.login_token = Some(String::new()); + handle_paste("one-time-token", &mut ui); + assert_eq!(ui.login_token.as_deref(), Some("one-time-token")); + } +} diff --git a/src/openhuman/tui/controls.rs b/src/openhuman/tui/controls.rs new file mode 100644 index 0000000000..bc8ebec852 --- /dev/null +++ b/src/openhuman/tui/controls.rs @@ -0,0 +1,338 @@ +//! Config and account actions for the tabbed terminal UI. + +use std::sync::Arc; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use serde_json::json; +use zeroize::Zeroize; + +use crate::core::runtime::CoreRuntime; + +use super::ui_state::{ConfigKey, SettingsAction, UiState}; + +pub async fn handle_config_key(key: KeyEvent, runtime: &Arc, ui: &mut UiState) { + if let Some(input) = ui.config_edit.as_mut() { + match key.code { + KeyCode::Esc => ui.config_edit = None, + KeyCode::Backspace => { + input.pop(); + } + KeyCode::Enter => save_config(runtime, ui).await, + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => input.push(c), + _ => {} + } + return; + } + + match key.code { + KeyCode::Up => ui.config_selected = ui.config_selected.saturating_sub(1), + KeyCode::Down => { + ui.config_selected = (ui.config_selected + 1).min(ui.config_items.len() - 1) + } + KeyCode::Enter => { + ui.config_edit = Some(ui.config_items[ui.config_selected].value.clone()); + } + _ => {} + } +} + +async fn save_config(runtime: &Arc, ui: &mut UiState) { + let Some(value) = ui.config_edit.take() else { + return; + }; + let key = ui.config_items[ui.config_selected].key; + let (method, params) = config_update(key, value); + ui.config_status = "Saving…".to_string(); + match runtime.invoke(method, params).await { + Ok(_) => { + ui.config_status = "Saved.".to_string(); + refresh_config(runtime, ui).await; + } + Err(err) => ui.config_status = format!("Save failed: {err}"), + } +} + +fn config_update(key: ConfigKey, value: String) -> (&'static str, serde_json::Value) { + match key { + ConfigKey::ApiUrl => ( + "openhuman.config_update_model_settings", + json!({"api_url": value}), + ), + ConfigKey::InferenceUrl => ( + "openhuman.config_update_model_settings", + json!({"inference_url": value}), + ), + ConfigKey::DefaultModel => ( + "openhuman.config_update_model_settings", + json!({"default_model": value}), + ), + ConfigKey::AutonomyLevel => ( + "openhuman.config_update_autonomy_settings", + json!({"level": value}), + ), + ConfigKey::PrivacyMode => ("openhuman.config_set_privacy_mode", json!({"mode": value})), + } +} + +pub async fn refresh_config(runtime: &Arc, ui: &mut UiState) { + let client = runtime + .invoke("openhuman.config_get_client_config", json!({})) + .await; + let autonomy = runtime + .invoke("openhuman.config_get_autonomy_settings", json!({})) + .await; + let privacy = runtime + .invoke("openhuman.config_get_privacy_mode", json!({})) + .await; + match (client, autonomy, privacy) { + (Ok(client), Ok(autonomy), Ok(privacy)) => { + let client = rpc_payload(&client); + let autonomy = rpc_payload(&autonomy); + let privacy = rpc_payload(&privacy); + for item in &mut ui.config_items { + item.value = match item.key { + ConfigKey::ApiUrl => string_at(client, &["api_url"]), + ConfigKey::InferenceUrl => string_at(client, &["inference_url"]), + ConfigKey::DefaultModel => string_at(client, &["default_model"]), + ConfigKey::AutonomyLevel => string_at(autonomy, &["level"]), + ConfigKey::PrivacyMode => string_at(privacy, &["mode"]), + }; + } + ui.config_status = "Select a field and press Enter to edit.".to_string(); + } + _ => ui.config_status = "Could not load one or more config sections; see Logs.".to_string(), + } +} + +pub async fn handle_settings_key(key: KeyEvent, runtime: &Arc, ui: &mut UiState) { + if let Some(token) = ui.login_token.as_mut() { + match key.code { + KeyCode::Esc => { + token.zeroize(); + ui.login_token = None; + } + KeyCode::Backspace => { + token.pop(); + } + KeyCode::Enter => login_with_token(runtime, ui).await, + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => token.push(c), + _ => {} + } + return; + } + if ui.logout_confirm { + match key.code { + KeyCode::Esc | KeyCode::Char('n') => ui.logout_confirm = false, + KeyCode::Char('y') | KeyCode::Enter => logout(runtime, ui).await, + _ => {} + } + return; + } + match key.code { + KeyCode::Up => ui.settings_selected = ui.settings_selected.saturating_sub(1), + KeyCode::Down => { + ui.settings_selected = (ui.settings_selected + 1).min(SettingsAction::ALL.len() - 1) + } + KeyCode::Enter => match SettingsAction::ALL[ui.settings_selected] { + SettingsAction::ViewAccount => view_account(runtime, ui).await, + SettingsAction::Login => ui.login_token = Some(String::new()), + SettingsAction::Logout => ui.logout_confirm = true, + }, + _ => {} + } +} + +pub async fn refresh_auth(runtime: &Arc, ui: &mut UiState) { + match runtime.invoke("openhuman.auth_get_state", json!({})).await { + Ok(value) => { + let state = rpc_payload(&value); + if state + .get("isAuthenticated") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + let identity = string_at(state, &["userId"]); + ui.auth_summary = if identity.is_empty() { + "Signed in".to_string() + } else { + format!("Signed in · {identity}") + }; + } else { + ui.auth_summary = "Signed out".to_string(); + ui.account_detail.clear(); + } + } + Err(err) => ui.auth_summary = format!("Account status unavailable: {err}"), + } +} + +async fn view_account(runtime: &Arc, ui: &mut UiState) { + ui.settings_status = "Refreshing account…".to_string(); + match runtime.invoke("openhuman.auth_get_me", json!({})).await { + Ok(value) => { + let user = rpc_payload(&value); + ui.account_detail = account_detail(user); + ui.settings_status = "Account refreshed.".to_string(); + refresh_auth(runtime, ui).await; + } + Err(err) => ui.settings_status = format!("Account refresh failed: {err}"), + } +} + +async fn login_with_token(runtime: &Arc, ui: &mut UiState) { + let mut token = ui.login_token.take().unwrap_or_default(); + if token.trim().is_empty() { + ui.settings_status = "Login token cannot be empty.".to_string(); + token.zeroize(); + return; + } + ui.settings_status = "Signing in…".to_string(); + let consumed = runtime + .invoke( + "openhuman.auth_consume_login_token", + json!({"loginToken": token.trim()}), + ) + .await; + token.zeroize(); + let result = match consumed { + Ok(value) => value, + Err(err) => { + ui.settings_status = format!("Login failed: {err}"); + return; + } + }; + let mut jwt = string_at(rpc_payload(&result), &["jwtToken"]); + if jwt.is_empty() { + ui.settings_status = "Login failed: backend returned no session token.".to_string(); + return; + } + let stored = runtime + .invoke("openhuman.auth_store_session", json!({"token": jwt})) + .await; + jwt.zeroize(); + match stored { + Ok(_) => { + ui.settings_status = "Signed in.".to_string(); + refresh_auth(runtime, ui).await; + ui.identity_changed = true; + } + Err(err) => ui.settings_status = format!("Could not store session: {err}"), + } +} + +async fn logout(runtime: &Arc, ui: &mut UiState) { + ui.logout_confirm = false; + match runtime + .invoke("openhuman.auth_clear_session", json!({})) + .await + { + Ok(_) => { + ui.settings_status = "Signed out.".to_string(); + refresh_auth(runtime, ui).await; + ui.identity_changed = true; + } + Err(err) => ui.settings_status = format!("Logout failed: {err}"), + } +} + +fn rpc_payload(value: &serde_json::Value) -> &serde_json::Value { + value + .get("result") + .or_else(|| value.get("data")) + .map(rpc_payload) + .unwrap_or(value) +} + +fn string_at(value: &serde_json::Value, path: &[&str]) -> String { + path.iter() + .try_fold(value, |current, key| current.get(*key)) + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string() +} + +fn account_detail(user: &serde_json::Value) -> String { + let name = [ + string_at(user, &["firstName"]), + string_at(user, &["lastName"]), + ] + .into_iter() + .filter(|s| !s.is_empty()) + .collect::>() + .join(" "); + let identity = [string_at(user, &["email"]), string_at(user, &["username"])] + .into_iter() + .find(|value| !value.is_empty()) + .unwrap_or_default(); + [name, identity] + .into_iter() + .filter(|s| !s.is_empty()) + .collect::>() + .join(" · ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rpc_payload_unwraps_runtime_and_api_envelopes() { + let value = json!({"result": {"data": {"mode": "standard"}}, "logs": []}); + assert_eq!(rpc_payload(&value), &json!({"mode": "standard"})); + assert_eq!(string_at(rpc_payload(&value), &["mode"]), "standard"); + } + + #[test] + fn string_at_never_coerces_non_string_or_missing_values() { + let value = json!({"enabled": true}); + assert_eq!(string_at(&value, &["enabled"]), ""); + assert_eq!(string_at(&value, &["missing"]), ""); + } + + #[test] + fn account_detail_uses_canonical_backend_user_fields() { + let user = json!({ + "firstName": "Ada", + "lastName": "Lovelace", + "email": "ada@example.test", + "username": "ada" + }); + assert_eq!(account_detail(&user), "Ada Lovelace · ada@example.test"); + } + + #[test] + fn curated_config_fields_map_to_safe_specific_updates() { + let cases = [ + ( + ConfigKey::ApiUrl, + "openhuman.config_update_model_settings", + json!({"api_url": "value"}), + ), + ( + ConfigKey::InferenceUrl, + "openhuman.config_update_model_settings", + json!({"inference_url": "value"}), + ), + ( + ConfigKey::DefaultModel, + "openhuman.config_update_model_settings", + json!({"default_model": "value"}), + ), + ( + ConfigKey::AutonomyLevel, + "openhuman.config_update_autonomy_settings", + json!({"level": "value"}), + ), + ( + ConfigKey::PrivacyMode, + "openhuman.config_set_privacy_mode", + json!({"mode": "value"}), + ), + ]; + for (key, expected_method, expected_params) in cases { + let (method, params) = config_update(key, "value".to_string()); + assert_eq!(method, expected_method); + assert_eq!(params, expected_params); + } + } +} diff --git a/src/openhuman/tui/mod.rs b/src/openhuman/tui/mod.rs index c684ddfdb3..c26cc71c5d 100644 --- a/src/openhuman/tui/mod.rs +++ b/src/openhuman/tui/mod.rs @@ -1,7 +1,7 @@ -//! Terminal chat UI — the `openhuman tui` (alias `chat`) CLI subcommand. +//! Tabbed terminal UI — bare `openhuman` or the explicit `tui` / `chat` subcommand. //! -//! A [ratatui]-based terminal front-end onto the **same `web_chat` surface** -//! the desktop app drives (`openhuman.channel_web_chat` / +//! A [ratatui]-based terminal front-end with Logs, Chat, Config, and Settings. +//! Chat uses the **same `web_chat` surface** the desktop app drives (`openhuman.channel_web_chat` / //! `openhuman.channel_web_cancel` + //! [`web_chat::subscribe_web_channel_events`](crate::openhuman::web_chat::subscribe_web_channel_events)). //! It boots the core in-process — no HTTP, no sockets — via @@ -26,6 +26,8 @@ #[cfg(feature = "tui")] mod app; #[cfg(feature = "tui")] +mod controls; +#[cfg(feature = "tui")] mod render; #[cfg(feature = "tui")] mod runner; @@ -33,6 +35,8 @@ mod runner; mod state; #[cfg(feature = "tui")] mod terminal; +#[cfg(feature = "tui")] +mod ui_state; #[cfg(feature = "tui")] pub use runner::run_from_cli; diff --git a/src/openhuman/tui/render.rs b/src/openhuman/tui/render.rs index b9e11609b4..55504f41bd 100644 --- a/src/openhuman/tui/render.rs +++ b/src/openhuman/tui/render.rs @@ -1,4 +1,4 @@ -//! Ratatui rendering for the terminal chat UI — pure view over +//! Ratatui rendering for the tabbed terminal UI — pure view over //! [`TranscriptState`] + [`UiState`]. No state mutation happens here. //! //! Layout (top → bottom): @@ -9,57 +9,207 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span, Text}; -use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap}; use ratatui::Frame; use unicode_width::UnicodeWidthStr; use super::state::{EntryKind, TranscriptState}; +use super::ui_state::{AppTab, SettingsAction, UiState}; /// Ocean accent from the design tokens (`#4A83DD`), kept terminal-native. const OCEAN: Color = Color::Rgb(0x4A, 0x83, 0xDD); const SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; -/// View-only UI state owned by the event loop and read by [`draw`]. -pub struct UiState { - /// Current input line contents. - pub input: String, - /// Lines scrolled up from the tail. `0` follows the newest content. - pub scroll_from_bottom: u16, - /// Monotonic tick used to animate the streaming spinner. - pub spinner_tick: usize, - /// The thread id shown in the status bar. - pub thread_id: String, - /// The client stream id (for the status bar, abbreviated). - pub client_id: String, -} - -impl UiState { - pub fn new(thread_id: String, client_id: String) -> Self { - Self { - input: String::new(), - scroll_from_bottom: 0, - spinner_tick: 0, - thread_id, - client_id, - } - } -} - /// Draw one frame. pub fn draw(frame: &mut Frame, state: &TranscriptState, ui: &UiState) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Min(1), // transcript - Constraint::Length(3), // input box - Constraint::Length(1), // status bar + Constraint::Length(3), + Constraint::Min(1), + Constraint::Length(1), ]) .split(frame.area()); + draw_tabs(frame, chunks[0], ui); + match ui.active_tab { + AppTab::Logs => draw_logs(frame, chunks[1], ui), + AppTab::Chat => draw_chat(frame, chunks[1], state, ui), + AppTab::Config => draw_config(frame, chunks[1], ui), + AppTab::Settings => draw_settings(frame, chunks[1], ui), + } + draw_footer(frame, chunks[2], state, ui); +} + +fn draw_tabs(frame: &mut Frame, area: Rect, ui: &UiState) { + let selected = AppTab::ALL + .iter() + .position(|tab| *tab == ui.active_tab) + .unwrap_or(0); + let titles = AppTab::ALL + .iter() + .enumerate() + .map(|(idx, tab)| Line::from(format!(" {} {} ", idx + 1, tab.title()))) + .collect::>(); + let tabs = Tabs::new(titles) + .select(selected) + .block( + Block::default() + .borders(Borders::ALL) + .title(" OpenHuman CLI "), + ) + .style(Style::default().fg(Color::DarkGray)) + .highlight_style(Style::default().fg(OCEAN).add_modifier(Modifier::BOLD)); + frame.render_widget(tabs, area); +} + +fn draw_chat(frame: &mut Frame, area: Rect, state: &TranscriptState, ui: &UiState) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(3)]) + .split(area); draw_transcript(frame, chunks[0], state, ui); draw_input(frame, chunks[1], ui); - draw_status(frame, chunks[2], state, ui); +} + +fn draw_logs(frame: &mut Frame, area: Rect, ui: &UiState) { + let lines = crate::core::logging::tui_log_lines(); + let text = if lines.is_empty() { + Text::from("Core logs will appear here as OpenHuman starts.") + } else { + Text::from(lines.join("\n")) + }; + let inner_height = area.height.saturating_sub(2).max(1); + let inner_width = area.width.saturating_sub(2).max(1); + let total_rows = text + .lines + .iter() + .map(|line| u32::from(wrapped_line_count(line, inner_width))) + .sum::(); + let max_scroll = total_rows + .saturating_sub(u32::from(inner_height)) + .min(u32::from(u16::MAX)) as u16; + let top = max_scroll.saturating_sub(ui.log_scroll_from_bottom.min(max_scroll)); + let paragraph = Paragraph::new(text) + .block(Block::default().borders(Borders::ALL).title(" Core logs ")) + .style(Style::default().fg(Color::Gray)) + .wrap(Wrap { trim: false }) + .scroll((top, 0)); + frame.render_widget(paragraph, area); +} + +fn draw_config(frame: &mut Frame, area: Rect, ui: &UiState) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(5), Constraint::Length(4)]) + .split(area); + let items = ui + .config_items + .iter() + .map(|item| { + ListItem::new(Line::from(vec![ + Span::styled( + format!("{:<18}", item.label), + Style::default().fg(Color::Gray), + ), + Span::styled( + if item.value.is_empty() { + "(not set)" + } else { + &item.value + }, + Style::default().fg(Color::White), + ), + ])) + }) + .collect::>(); + let mut list_state = ListState::default().with_selected(Some(ui.config_selected)); + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Safe configuration "), + ) + .highlight_symbol("› ") + .highlight_style(Style::default().fg(OCEAN).add_modifier(Modifier::BOLD)); + frame.render_stateful_widget(list, chunks[0], &mut list_state); + + let selected = &ui.config_items[ui.config_selected.min(ui.config_items.len() - 1)]; + let detail = if let Some(input) = &ui.config_edit { + let visible = tail_to_width(input, chunks[1].width.saturating_sub(5) as usize); + format!("Editing {}\n> {}▏", selected.label, visible) + } else { + format!("{}\n{}", selected.hint, ui.config_status) + }; + frame.render_widget( + Paragraph::new(detail) + .block(Block::default().borders(Borders::ALL).title(" Edit ")) + .wrap(Wrap { trim: false }), + chunks[1], + ); +} + +fn draw_settings(frame: &mut Frame, area: Rect, ui: &UiState) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(5), + Constraint::Length(5), + Constraint::Min(3), + ]) + .split(area); + frame.render_widget( + Paragraph::new(vec![ + Line::from(Span::styled( + &ui.auth_summary, + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )), + Line::from(ui.account_detail.clone()), + ]) + .block(Block::default().borders(Borders::ALL).title(" Account ")) + .wrap(Wrap { trim: false }), + chunks[0], + ); + + let actions = SettingsAction::ALL + .iter() + .map(|action| ListItem::new(action.label())) + .collect::>(); + let mut action_state = ListState::default().with_selected(Some(ui.settings_selected)); + frame.render_stateful_widget( + List::new(actions) + .block(Block::default().borders(Borders::ALL).title(" Actions ")) + .highlight_symbol("› ") + .highlight_style(Style::default().fg(OCEAN).add_modifier(Modifier::BOLD)), + chunks[1], + &mut action_state, + ); + + let detail = if let Some(token) = &ui.login_token { + let visible = "•".repeat( + token + .chars() + .count() + .min(chunks[2].width.saturating_sub(5) as usize), + ); + format!( + "Paste a one-time login token, then press Enter.\n> {}▏", + visible + ) + } else if ui.logout_confirm { + "Log out and stop account-bound services? Press y to confirm or Esc to cancel.".to_string() + } else { + ui.settings_status.clone() + }; + frame.render_widget( + Paragraph::new(detail) + .block(Block::default().borders(Borders::ALL).title(" Status ")) + .wrap(Wrap { trim: false }), + chunks[2], + ); } fn draw_transcript(frame: &mut Frame, area: Rect, state: &TranscriptState, ui: &UiState) { @@ -106,21 +256,43 @@ fn draw_input(frame: &mut Frame, area: Rect, ui: &UiState) { frame.render_widget(paragraph, area); } -fn draw_status(frame: &mut Frame, area: Rect, state: &TranscriptState, ui: &UiState) { - let turn = if state.is_streaming() { +fn draw_footer(frame: &mut Frame, area: Rect, state: &TranscriptState, ui: &UiState) { + let context = match ui.active_tab { + AppTab::Logs => "PgUp/PgDn scroll", + AppTab::Chat => "Enter send · Esc cancel · Ctrl+N new · PgUp/PgDn scroll", + AppTab::Config => { + if ui.config_edit.is_some() { + "Enter save · Esc cancel" + } else { + "↑↓ navigate · Enter edit" + } + } + AppTab::Settings => { + if ui.is_editing() { + "Enter confirm · Esc cancel" + } else { + "↑↓ navigate · Enter select" + } + } + }; + let turn = if ui.active_tab == AppTab::Chat && state.is_streaming() { let frame_ch = SPINNER_FRAMES[ui.spinner_tick % SPINNER_FRAMES.len()]; format!("{frame_ch} streaming") } else { - "idle".to_string() + ui.active_tab.title().to_string() }; - let thread_short = abbreviate(&ui.thread_id, 24); let left = Span::styled( - format!(" thread {thread_short} · {turn} "), + format!(" {turn} "), Style::default().fg(Color::Black).bg(OCEAN), ); + let navigation = if ui.is_editing() { + "Finish or Esc before switching tabs" + } else { + "Tab/Shift+Tab switch · Alt+1-4 tabs" + }; let hints = Span::styled( - " Enter send · Esc cancel · Ctrl+N new · PgUp/PgDn scroll · Ctrl+C quit", + format!(" {navigation} · {context} · Ctrl+C quit"), Style::default().fg(Color::DarkGray), ); let paragraph = Paragraph::new(Line::from(vec![left, hints])); @@ -201,27 +373,48 @@ fn tail_to_width(s: &str, width: usize) -> String { out.into_iter().rev().collect() } -/// Middle-truncate an id to `max` columns (`abc…xyz`). -fn abbreviate(s: &str, max: usize) -> String { - if s.chars().count() <= max { - return s.to_string(); - } - let keep = max.saturating_sub(1) / 2; - let head: String = s.chars().take(keep).collect(); - let tail: String = s - .chars() - .rev() - .take(keep) - .collect::>() - .into_iter() - .rev() - .collect(); - format!("{head}…{tail}") -} - #[cfg(test)] mod tests { use super::*; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + fn rendered(ui: &UiState) -> String { + let backend = TestBackend::new(100, 24); + let mut terminal = Terminal::new(backend).expect("test terminal"); + let transcript = TranscriptState::new("test-client"); + terminal + .draw(|frame| draw(frame, &transcript, ui)) + .expect("draw"); + terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::() + } + + #[test] + fn tab_bar_and_navigation_footer_are_always_rendered() { + let ui = UiState::new("thread-1".into(), "client-1".into()); + let output = rendered(&ui); + for title in ["1 Logs", "2 Chat", "3 Config", "4 Settings"] { + assert!(output.contains(title), "missing tab {title}"); + } + assert!(output.contains("Tab/Shift+Tab switch")); + assert!(output.contains("PgUp/PgDn scroll")); + } + + #[test] + fn editing_footer_explains_that_tab_switching_is_paused() { + let mut ui = UiState::new("thread-1".into(), "client-1".into()); + ui.active_tab = AppTab::Config; + ui.config_edit = Some("value".to_string()); + let output = rendered(&ui); + assert!(output.contains("Finish or Esc before switching tabs")); + assert!(!output.contains("Alt+1-4 tabs")); + } #[test] fn tail_to_width_keeps_the_end() { @@ -230,14 +423,6 @@ mod tests { assert_eq!(tail_to_width("anything", 0), ""); } - #[test] - fn abbreviate_middle_truncates_long_ids() { - let out = abbreviate("thread-0123456789abcdef", 11); - assert!(out.contains('…')); - assert!(out.chars().count() <= 11); - assert_eq!(abbreviate("short", 24), "short"); - } - #[test] fn wrapped_line_count_divides_by_width() { let line = Line::from("a".repeat(25)); diff --git a/src/openhuman/tui/runner.rs b/src/openhuman/tui/runner.rs index 9fcc88648b..dde0219471 100644 --- a/src/openhuman/tui/runner.rs +++ b/src/openhuman/tui/runner.rs @@ -1,4 +1,4 @@ -//! CLI entry point for the terminal chat UI (`openhuman tui` / `chat`). +//! CLI entry point for the tabbed terminal UI (`openhuman` / `tui` / `chat`). //! //! Parses flags, initializes **file-only** logging (the TUI owns the terminal — //! see `logging::init_for_tui`), boots the core in-process with no transport and @@ -11,7 +11,7 @@ use std::sync::Arc; use serde_json::{json, Value}; use crate::core::runtime::{ - CoreBuilder, CoreRuntime, DomainSet, ServiceSet, AGENT_WORKER_STACK_BYTES, + CoreBuilder, CoreRuntime, DomainSet, ServiceSet, AGENT_WORKER_STACK_BYTES, MAX_BLOCKING_THREADS, }; use crate::core::types::HostKind; @@ -57,7 +57,7 @@ pub fn run_from_cli(args: &[String]) -> anyhow::Result<()> { let data_dir = resolve_data_dir(); let log_dir = crate::core::logging::init_for_tui(&data_dir, verbose); log::info!( - "[tui] starting terminal chat UI (thread={:?} new={} logs={:?})", + "[tui] starting tabbed terminal UI (thread={:?} new={} logs={:?})", thread_id, force_new, log_dir @@ -69,6 +69,7 @@ pub fn run_from_cli(args: &[String]) -> anyhow::Result<()> { let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .thread_stack_size(AGENT_WORKER_STACK_BYTES) + .max_blocking_threads(MAX_BLOCKING_THREADS) .build()?; rt.block_on(async_main(thread_id, force_new)) } @@ -77,7 +78,7 @@ async fn async_main(thread_flag: Option, force_new: bool) -> anyhow::Res // In-process core: full domains (channel.web_chat needs DomainGroup::Channels, // so harness() is not enough), no transport, no background services. let runtime = Arc::new( - CoreBuilder::new(HostKind::Cli) + CoreBuilder::new(HostKind::detect_standalone()) .domains(DomainSet::full()) .services(ServiceSet::none()) .build() @@ -153,14 +154,14 @@ fn print_help() { println!("Usage: openhuman tui [--thread ] [--new] [-v|--verbose]"); println!(" openhuman chat [--thread ] [--new] [-v|--verbose]"); println!(); - println!("Open a terminal chat UI onto the general-chat surface (the same one the"); - println!("desktop app uses). Runs the core in-process — no server, no ports."); + println!("Open the tabbed terminal UI for core logs, orchestrator chat, configuration,"); + println!("and account settings. Runs the core in-process — no server, no ports."); println!(); println!(" --thread Attach to an existing conversation thread."); println!(" --new Force a new thread (default when --thread is omitted)."); println!(" -v, --verbose Debug-level logging (written to the log file, never the UI)."); println!(); - println!("Keys: Enter send · Esc cancel turn · Ctrl+N new thread · PgUp/PgDn scroll ·"); + println!("Keys: Tab/Shift+Tab or Alt+1-4 switch tabs · arrows navigate · Enter select ·"); println!(" Ctrl+C / Ctrl+D quit."); } @@ -213,11 +214,22 @@ mod tests { "openhuman.channel_web_chat", "openhuman.channel_web_cancel", "openhuman.threads_create_new", + "openhuman.config_get_client_config", + "openhuman.config_update_model_settings", + "openhuman.config_get_autonomy_settings", + "openhuman.config_update_autonomy_settings", + "openhuman.config_get_privacy_mode", + "openhuman.config_set_privacy_mode", + "openhuman.auth_get_state", + "openhuman.auth_get_me", + "openhuman.auth_consume_login_token", + "openhuman.auth_store_session", + "openhuman.auth_clear_session", ] { assert!( crate::core::all::schema_for_rpc_method(method).is_some(), "TUI invokes `{method}`, but it is not a registered RPC method — \ - the terminal chat UI would fail with `unknown method: {method}`" + the tabbed terminal UI would fail with `unknown method: {method}`" ); } } diff --git a/src/openhuman/tui/state.rs b/src/openhuman/tui/state.rs index 1758ec4135..ce25eb9d96 100644 --- a/src/openhuman/tui/state.rs +++ b/src/openhuman/tui/state.rs @@ -1,4 +1,4 @@ -//! Pure, terminal-free transcript reducer for the terminal chat UI. +//! Pure, terminal-free transcript reducer for the tabbed terminal UI's Chat tab. //! //! [`TranscriptState`] is a plain data structure with **no ratatui / crossterm / //! IO dependencies** — the renderer ([`super::render`]) reads it and the event diff --git a/src/openhuman/tui/stub.rs b/src/openhuman/tui/stub.rs index 2e0355c5e7..81ff5ee38a 100644 --- a/src/openhuman/tui/stub.rs +++ b/src/openhuman/tui/stub.rs @@ -1,4 +1,4 @@ -//! Disabled-`tui` facade for [`super`] (the terminal chat UI). +//! Disabled-`tui` facade for [`super`] (the tabbed terminal UI). //! //! Compiled only when the `tui` Cargo feature is OFF (see the gate in //! [`super`]). It mirrors the one public symbol always-compiled callers reach — diff --git a/src/openhuman/tui/terminal.rs b/src/openhuman/tui/terminal.rs index e2c85c0c55..90606fe6c0 100644 --- a/src/openhuman/tui/terminal.rs +++ b/src/openhuman/tui/terminal.rs @@ -9,7 +9,9 @@ use std::io::{self, Stdout}; -use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; +use crossterm::event::{ + DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, +}; use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, @@ -33,7 +35,12 @@ impl TerminalGuard { install_panic_hook(); enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + execute!( + stdout, + EnterAlternateScreen, + EnableMouseCapture, + EnableBracketedPaste + )?; let backend = CrosstermBackend::new(io::stdout()); let terminal = Terminal::new(backend)?; log::debug!("[tui] terminal: entered alternate screen + raw mode"); @@ -63,7 +70,12 @@ impl Drop for TerminalGuard { /// down as far as possible. fn restore() -> io::Result<()> { let mut stdout = io::stdout(); - let _ = execute!(stdout, LeaveAlternateScreen, DisableMouseCapture); + let _ = execute!( + stdout, + DisableBracketedPaste, + LeaveAlternateScreen, + DisableMouseCapture + ); disable_raw_mode() } diff --git a/src/openhuman/tui/ui_state.rs b/src/openhuman/tui/ui_state.rs new file mode 100644 index 0000000000..9020f78475 --- /dev/null +++ b/src/openhuman/tui/ui_state.rs @@ -0,0 +1,179 @@ +//! Pure navigation and form state for the four terminal pages. + +use zeroize::Zeroize; + +/// Top-level terminal pages. The order is part of the CLI UX contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppTab { + Logs, + Chat, + Config, + Settings, +} + +impl AppTab { + pub const ALL: [Self; 4] = [Self::Logs, Self::Chat, Self::Config, Self::Settings]; + + pub fn title(self) -> &'static str { + match self { + Self::Logs => "Logs", + Self::Chat => "Chat", + Self::Config => "Config", + Self::Settings => "Settings", + } + } + + pub fn next(self) -> Self { + Self::ALL[(self as usize + 1) % Self::ALL.len()] + } + + pub fn previous(self) -> Self { + Self::ALL[(self as usize + Self::ALL.len() - 1) % Self::ALL.len()] + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfigKey { + ApiUrl, + InferenceUrl, + DefaultModel, + AutonomyLevel, + PrivacyMode, +} + +#[derive(Debug, Clone)] +pub struct ConfigItem { + pub key: ConfigKey, + pub label: &'static str, + pub value: String, + pub hint: &'static str, +} + +impl ConfigItem { + fn new(key: ConfigKey, label: &'static str, hint: &'static str) -> Self { + Self { + key, + label, + value: String::new(), + hint, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsAction { + ViewAccount, + Login, + Logout, +} + +impl SettingsAction { + pub const ALL: [Self; 3] = [Self::ViewAccount, Self::Login, Self::Logout]; + + pub fn label(self) -> &'static str { + match self { + Self::ViewAccount => "View account", + Self::Login => "Log in with one-time token", + Self::Logout => "Log out", + } + } +} + +/// UI-only state owned by the event loop and read by the renderer. +pub struct UiState { + pub active_tab: AppTab, + pub input: String, + pub scroll_from_bottom: u16, + pub spinner_tick: usize, + pub thread_id: String, + pub log_scroll_from_bottom: u16, + pub config_items: Vec, + pub config_selected: usize, + pub config_edit: Option, + pub config_status: String, + pub settings_selected: usize, + pub auth_summary: String, + pub account_detail: String, + pub login_token: Option, + pub logout_confirm: bool, + pub settings_status: String, + pub identity_changed: bool, +} + +impl UiState { + pub fn new(thread_id: String, _client_id: String) -> Self { + Self { + active_tab: AppTab::Logs, + input: String::new(), + scroll_from_bottom: 0, + spinner_tick: 0, + thread_id, + log_scroll_from_bottom: 0, + config_items: vec![ + ConfigItem::new( + ConfigKey::ApiUrl, + "Backend URL", + "OpenHuman auth and billing backend", + ), + ConfigItem::new( + ConfigKey::InferenceUrl, + "Inference URL", + "Custom OpenAI-compatible endpoint", + ), + ConfigItem::new( + ConfigKey::DefaultModel, + "Default model", + "Model id used when no route overrides it", + ), + ConfigItem::new( + ConfigKey::AutonomyLevel, + "Agent access", + "readonly, supervised, or full", + ), + ConfigItem::new( + ConfigKey::PrivacyMode, + "Privacy mode", + "local_only, standard, or sensitive", + ), + ], + config_selected: 0, + config_edit: None, + config_status: "Loading safe configuration…".to_string(), + settings_selected: 0, + auth_summary: "Checking account…".to_string(), + account_detail: String::new(), + login_token: None, + logout_confirm: false, + settings_status: "Select an account action and press Enter.".to_string(), + identity_changed: false, + } + } + + pub fn is_editing(&self) -> bool { + self.config_edit.is_some() || self.login_token.is_some() || self.logout_confirm + } +} + +impl Drop for UiState { + fn drop(&mut self) { + if let Some(token) = &mut self.login_token { + token.zeroize(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ui_starts_on_logs_and_tabs_wrap_in_product_order() { + let ui = UiState::new("thread".into(), "client".into()); + assert_eq!(ui.active_tab, AppTab::Logs); + assert_eq!(AppTab::Logs.next(), AppTab::Chat); + assert_eq!(AppTab::Chat.next(), AppTab::Config); + assert_eq!(AppTab::Config.next(), AppTab::Settings); + assert_eq!(AppTab::Settings.next(), AppTab::Logs); + assert_eq!(AppTab::Logs.previous(), AppTab::Settings); + } +} From 5041454f89e87f24603345f17f96d90592ec8db4 Mon Sep 17 00:00:00 2001 From: mwakidenis Date: Thu, 23 Jul 2026 06:07:02 +0300 Subject: [PATCH 55/56] docs: Add CITATION.cff for software citation metadata (#5142) --- CITATION.cff | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 CITATION.cff diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000000..d49bdf0ef3 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,27 @@ +cff-version: 1.2.0 +message: "If you use this software, please cite it using these metadata." +title: "OpenHuman" +type: "software" +authors: + - name: "tinyhumansai" + - name: "OpenHuman Contributors" +repository-code: "https://github.com/tinyhumansai/openhuman" +url: "https://github.com/tinyhumansai/openhuman" +abstract: "OpenHuman is an open‑source AI platform for building and orchestrating autonomous agents and workflows." +license: "GPL-3.0" +version: "0.63.1" +date-released: "2026-07-21" +keywords: + - "ai" + - "agents" + - "workflows" + - "orchestration" + - "openhuman" +preferred-citation: + type: "software" + title: "OpenHuman" + authors: + - name: "tinyhumansai" + year: "2026" + url: "https://github.com/tinyhumansai/openhuman" + version: "0.63.1" From a3efbc7e08b98e9107ddfa4d957e68330331e41c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 23 Jul 2026 05:51:29 +0000 Subject: [PATCH 56/56] fix: address PR #5141 review comments - ProcessingTranscriptView: fix div-in-span nesting for subagent blocks (CodeRabbit). Changed outer wrapper from to
    and inner subagent wrapper from to
    . - ProcessingTranscriptView: render raw tool result output for failed entries so tool output stays reachable in transcript mode (Codex). Matches the legacy row renderer's failure-only rule. - ChatThreadView: add supersededInterimIndexes filtering for backward- compat with old persisted interim narration messages. - payload_summarizer: add regression test for fall-through behaviour when no parent context is available, exercising the code path through invoke_with_events. Event contract (no TextDelta/ThinkingDelta) is pinned by invoke_with_events_emits_lifecycle_on_shared_sink in tinyagents crate. --- .../components/ChatThreadView.tsx | 9 ++++- .../components/ProcessingTranscriptView.tsx | 31 ++++++++++++++-- .../tinyagents/payload_summarizer.rs | 37 +++++++++++++++++++ 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/app/src/features/conversations/components/ChatThreadView.tsx b/app/src/features/conversations/components/ChatThreadView.tsx index 3fcded12b2..a85a4db0c8 100644 --- a/app/src/features/conversations/components/ChatThreadView.tsx +++ b/app/src/features/conversations/components/ChatThreadView.tsx @@ -25,6 +25,7 @@ import { splitAgentMessageIntoBubbles } from '../../../utils/agentMessageBubbles import { formatTimelineEntry } from '../../../utils/toolTimelineFormatting'; import { ShareMessageButton } from '../../share/ShareMessageButton'; import { buildThreadTimeline } from '../timeline/selectors'; +import { supersededInterimIndexes } from '../utils/interimNarration'; import { type AgentBubblePosition, formatRelativeTime } from '../utils/format'; import { AgentMessageBubble, AgentMessageText, BubbleMarkdown } from './AgentMessageBubble'; import { AgentProcessSourcePanel } from './AgentProcessSourcePanel'; @@ -265,7 +266,13 @@ export const ChatThreadView = forwardRef entry.subagent?.taskId === openSubagentTaskId) : undefined; - const visibleMessages = messages.filter(msg => !msg.extraMetadata?.hidden); + // Backward-compat: hide old interim narration messages once their turn has a + // final answer. New turns no longer persist narration as messages (see + // ChatRuntimeProvider), so this only affects threads from before the switch. + const supersededInterim = useMemo(() => supersededInterimIndexes(messages), [messages]); + const visibleMessages = messages.filter( + (msg, index) => !msg.extraMetadata?.hidden && !supersededInterim.has(index) + ); const hasVisibleMessages = visibleMessages.length > 0; const latestVisibleMessage = visibleMessages[visibleMessages.length - 1] ?? null; const latestVisibleAgentMessage = [...visibleMessages] diff --git a/app/src/features/conversations/components/ProcessingTranscriptView.tsx b/app/src/features/conversations/components/ProcessingTranscriptView.tsx index 85ed163539..593847370b 100644 --- a/app/src/features/conversations/components/ProcessingTranscriptView.tsx +++ b/app/src/features/conversations/components/ProcessingTranscriptView.tsx @@ -13,6 +13,17 @@ import { } from '../../../utils/toolTimelineFormatting'; import { ToolFailureLines } from './ToolFailureLines'; +/** Normalise a tool result/args body string: skip empty, whitespace-only, and + * trivial JSON literals. Mirrors the identically-named helper in + * `ToolTimelineBlock` so the two paths never disagree on the display threshold. */ +function normalizeToolBody(value?: string): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + if (trimmed.length === 0) return undefined; + if (trimmed === '{}' || trimmed === '[]' || trimmed === 'null') return undefined; + return value; +} + /** * The Hermes-style "View processing" body: the agent's narration and hidden * reasoning flow inline as prose, while runs of consecutive tool calls @@ -150,12 +161,13 @@ function ToolRow({ renderSubagent?: (subagent: NonNullable) => React.ReactNode; }) { const { title, detail } = formatTimelineEntry(entry); + const resultContent = normalizeToolBody(entry.result); return (
  • - +
    {title} {detail ? ( @@ -165,14 +177,25 @@ function ToolRow({ {entry.status === 'error' && entry.failure ? ( ) : null} + {/* A failure is the case where the answer is least trustworthy (or may + not mention the failure at all), so the raw tool output earns its + space in the transcript. Successful output is kept in the process + source panel. */} + {resultContent && entry.status === 'error' ? ( +
    +            {resultContent}
    +          
    + ) : null} {/* A delegated sub-agent's own tool calls hang off the parent entry, so without this the whole child run collapsed into this single line. */} {entry.subagent && renderSubagent ? ( - +
    {renderSubagent(entry.subagent)} - +
    ) : null} -
    +
  • ); } diff --git a/src/openhuman/tinyagents/payload_summarizer.rs b/src/openhuman/tinyagents/payload_summarizer.rs index 6b610e479c..68818cfe4f 100644 --- a/src/openhuman/tinyagents/payload_summarizer.rs +++ b/src/openhuman/tinyagents/payload_summarizer.rs @@ -634,4 +634,41 @@ mod tests { summarizer.record_failure(); assert!(!summarizer.breaker_tripped()); } + + /// When a payload lands in the summarization window ([threshold, cap]) and + /// no parent context is available, the dispatcher must fall through to the + /// raw payload rather than panicking. This exercises the code path through + /// `summarize_in_parent → invoke_tinyagents_summarizer_in_parent → + /// current_parent() → Err` and verifies the error-handling contract: + /// `handle_summarizer_result` records a failure and returns `Ok(None)`. + /// + /// In a real run `current_parent()` is set by the agent harness, so the + /// `invoke_with_events` → `run_child(.., streaming = false)` unary path is + /// exercised end-to-end. The event contract (no `TextDelta`/`ThinkingDelta` + /// on the parent sink, child lifecycle events still emitted) is pinned by + /// `invoke_with_events_emits_lifecycle_on_shared_sink` in the tinyagents + /// crate's `subagent::test` module. + #[tokio::test] + async fn maybe_summarize_falls_through_when_no_parent_context() { + let summarizer = SubagentPayloadSummarizer::new( + dummy_definition(), + 1, // threshold of 1 token — anything passes + TEST_MAX_TOKENS, + ); + // 8 chars → ~2 tokens, inside the [1, 2M] window. + let raw = "abcd1234"; + let outcome = summarizer + .maybe_summarize_in_parent(&dummy_parent_ctx(), "test_tool", None, raw) + .await + .expect("fall-through should not error"); + assert!( + outcome.is_none(), + "no parent context available — must fall through to raw payload" + ); + // The failure was recorded (one tick toward the circuit breaker). + assert!( + !summarizer.breaker_tripped(), + "single failure must not trip the breaker" + ); + } }