From 143512ca05b915323dd97ea57cd01d19e6a297dd Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Thu, 16 Jul 2026 19:54:06 +0530 Subject: [PATCH 01/86] fix(tinyplace): wire handle transfer end-to-end (registry.transfer) The SDK exposed registry.transfer (POST /registry/names/{name}/transfer) but nothing above it reached the method: no core controller, no client method, no UI. Handle transfer was unreachable from the app (#4929). Bridge every layer: - core: handle_tinyplace_registry_transfer resolves the recipient @handle to its cryptoId + publicKey via registry.get, calls registry.transfer, and read-back-confirms the returned identity is now owned by the recipient (fails closed on unresolved recipient / missing key material / mismatch). Registered via schemas.rs + the controller registry. No PII logged (status-only debug lines). - client: apiClient.registry.transfer(name, recipient) + TransferHandleResult. - UI: a per-handle Transfer action on non-primary owned handles in the Profiles card opens TransferHandleModal, a destructive-confirm dialog that requires a recipient + explicit confirm and fails closed on error. - i18n: seven agentWorld.transferHandle.* keys across en + all 13 locales (no em dashes). - tests: TransferHandleModal (wiring, recipient-gating, fail-closed), ProfilesSection (Transfer opens the modal), and a Rust handler test for the name/recipient validation branches. --- .../components/TransferHandleModal.test.tsx | 76 ++++++++++++ .../components/TransferHandleModal.tsx | 113 ++++++++++++++++++ .../agentworld/pages/ProfilesSection.test.tsx | 28 ++++- app/src/agentworld/pages/ProfilesSection.tsx | 24 ++++ app/src/lib/agentworld/invokeApiClient.ts | 16 +++ app/src/lib/i18n/ar.ts | 8 ++ app/src/lib/i18n/bn.ts | 8 ++ app/src/lib/i18n/de.ts | 8 ++ app/src/lib/i18n/en.ts | 8 ++ app/src/lib/i18n/es.ts | 8 ++ app/src/lib/i18n/fr.ts | 8 ++ app/src/lib/i18n/hi.ts | 8 ++ app/src/lib/i18n/id.ts | 8 ++ app/src/lib/i18n/it.ts | 8 ++ app/src/lib/i18n/ko.ts | 8 ++ app/src/lib/i18n/pl.ts | 8 ++ app/src/lib/i18n/pt.ts | 8 ++ app/src/lib/i18n/ru.ts | 8 ++ app/src/lib/i18n/zh-CN.ts | 7 ++ src/openhuman/tinyplace/manifest.rs | 103 ++++++++++++++++ src/openhuman/tinyplace/schemas.rs | 32 +++++ 21 files changed, 502 insertions(+), 1 deletion(-) 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..38d0ff4acf --- /dev/null +++ b/app/src/agentworld/components/TransferHandleModal.test.tsx @@ -0,0 +1,76 @@ +/** + * 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('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'); + 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.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..f573485e21 --- /dev/null +++ b/app/src/agentworld/components/TransferHandleModal.tsx @@ -0,0 +1,113 @@ +/** + * 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 and an explicit confirm click, and + * 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'; + +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; +} + +export default function TransferHandleModal({ + handle, + onClose, + onTransferred, +}: TransferHandleModalProps) { + const { t } = useT(); + const [recipient, setRecipient] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const submit = useCallback(async () => { + const target = recipient.trim().replace(/^@+/, ''); + if (!target) { + setError(t('agentWorld.transferHandle.recipientRequired')); + return; + } + setSubmitting(true); + setError(null); + // Never log the handle or recipient — both identify a user. + debug('[agentworld:identity] handle transfer requested'); + try { + await apiClient.registry.transfer(handle, target); + debug('[agentworld:identity] handle transfer confirmed'); + onTransferred(); + onClose(); + } catch (err) { + // Fail closed: keep the dialog open, show why, report no success. + debug('[agentworld:identity] handle transfer failed: %s', String(err)); + setError(String(err)); + setSubmitting(false); + } + }, [recipient, handle, t, onTransferred, onClose]); + + return ( + undefined : onClose}> +
+

@{handle.replace(/^@+/, '')}

+

+ {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" + /> + + {error && ( +

+ {error} +

+ )} + +
+ + +
+
+
+ ); +} diff --git a/app/src/agentworld/pages/ProfilesSection.test.tsx b/app/src/agentworld/pages/ProfilesSection.test.tsx index 5f8a449a7f..5e7ca7389c 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() }, }, })); @@ -339,6 +339,32 @@ function makeProfile(overrides: Partial = {}): GqlProfile { }; } +// GH-4929: a non-primary owned handle offers a Transfer action that opens the +// destructive-confirm modal. (A primary handle is locked from transfer, so it +// shows no Transfer button.) +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(); + + // The non-primary handle exposes a Transfer action; the primary one does not. + const transferButton = await screen.findByRole('button', { name: 'Transfer' }); + fireEvent.click(transferButton); + + // 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(); + }); +}); + 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 e98d315220..a7225cbd0a 100644 --- a/app/src/agentworld/pages/ProfilesSection.tsx +++ b/app/src/agentworld/pages/ProfilesSection.tsx @@ -18,8 +18,10 @@ import { type IdentityExport, PaymentRequiredError, } from '../../lib/agentworld/invokeApiClient'; +import { useT } from '../../lib/i18n/I18nContext'; import { fetchWalletStatus } from '../../services/walletApi'; import { apiClient } from '../AgentWorldShell'; +import TransferHandleModal from '../components/TransferHandleModal'; /** A handle registered to the wallet (subset of the directory.reverse identity). */ interface OwnedIdentity { @@ -169,6 +171,7 @@ function useMyIdentity(reloadKey: number): ProfileState { // ── Sub-components ──────────────────────────────────────────────────────────── function AgentProfileCard({ data, onSwitched }: { data: ProfileData; onSwitched?: () => void }) { + const { t } = useT(); const [followStats, setFollowStats] = useState(null); const [exportData, setExportData] = useState(null); const [exportLoading, setExportLoading] = useState(false); @@ -176,6 +179,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'; @@ -371,6 +376,17 @@ function AgentProfileCard({ data, onSwitched }: { data: ProfileData; onSwitched? : 'Make active'} )} + {/* Transfer is destructive/irreversible — the modal confirms + intent and fails closed. A primary handle is locked from + sale/transfer, so only non-primary handles offer it. */} + {!id.primary && ( + + )} {id.status} @@ -384,6 +400,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 52703f1dc2..29c60b47ed 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -446,6 +446,14 @@ const messages: TranslationMap = { 'agentWorld.directory': 'الدليل', '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.marketplace': 'السوق', 'agentWorld.messaging': 'الرسائل', 'agentWorld.walletNotConfigured': 'المحفظة غير مُهيَّأة', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 22ac86f6d9..4c81f52033 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -462,6 +462,14 @@ const messages: TranslationMap = { 'agentWorld.directory': 'ডিরেক্টরি', '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.marketplace': 'মার্কেটপ্লেস', 'agentWorld.messaging': 'বার্তা', 'agentWorld.walletNotConfigured': 'ওয়ালেট সেট আপ হয়নি', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 541f755f2c..e96c6b0ee1 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -486,6 +486,14 @@ const messages: TranslationMap = { 'agentWorld.directory': 'Verzeichnis', '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.marketplace': 'Marktplatz', 'agentWorld.messaging': 'Nachrichten', 'agentWorld.walletNotConfigured': 'Wallet nicht eingerichtet', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 73ab4fa04a..6e68542313 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -191,6 +191,14 @@ const en: TranslationMap = { 'agentWorld.directory': 'Directory', '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.marketplace': 'Marketplace', 'agentWorld.messaging': 'Messages', 'agentWorld.walletNotConfigured': 'Wallet not set up', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index ff6d916360..f7acf2ef60 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -472,6 +472,14 @@ const messages: TranslationMap = { 'agentWorld.directory': 'Directorio', '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.marketplace': 'Mercado', 'agentWorld.messaging': 'Mensajes', 'agentWorld.walletNotConfigured': 'Cartera no configurada', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index e7555e5681..c1288d484d 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -482,6 +482,14 @@ const messages: TranslationMap = { 'agentWorld.directory': 'Annuaire', '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.marketplace': 'Marché', 'agentWorld.messaging': 'Messages', 'agentWorld.walletNotConfigured': 'Portefeuille non configuré', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 88b8c8b624..e4bf155482 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -462,6 +462,14 @@ const messages: TranslationMap = { 'agentWorld.directory': 'डायरेक्टरी', '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.marketplace': 'मार्केटप्लेस', 'agentWorld.messaging': 'संदेश', 'agentWorld.walletNotConfigured': 'वॉलेट सेट नहीं है', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index fefb2dbc58..b3d52756ce 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -468,6 +468,14 @@ const messages: TranslationMap = { 'agentWorld.directory': 'Direktori', '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.marketplace': 'Pasar', 'agentWorld.messaging': 'Pesan', 'agentWorld.walletNotConfigured': 'Dompet belum diatur', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 8c48eefb30..a7746956ff 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -475,6 +475,14 @@ const messages: TranslationMap = { 'agentWorld.directory': 'Directory', '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.marketplace': 'Mercato', 'agentWorld.messaging': 'Messaggi', 'agentWorld.walletNotConfigured': 'Wallet non configurato', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 71b5452a7b..bc4776af05 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -455,6 +455,14 @@ const messages: TranslationMap = { 'agentWorld.directory': '디렉토리', '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.marketplace': '마켓플레이스', 'agentWorld.messaging': '메시지', 'agentWorld.walletNotConfigured': '지갑이 설정되지 않음', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index cfe0e77a5b..295af1d749 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -473,6 +473,14 @@ const messages: TranslationMap = { 'agentWorld.directory': 'Katalog', 'agentWorld.identities': 'Tożsamości', 'agentWorld.profiles': 'Profile', + 'agentWorld.transferHandle.action': 'Przenieś', + 'agentWorld.transferHandle.title': 'Przenieś handle', + 'agentWorld.transferHandle.warning': + 'Przeniesienie handle 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': 'Przenieś handle', + 'agentWorld.transferHandle.submitting': 'Przenoszenie…', + 'agentWorld.transferHandle.recipientRequired': 'Podaj handle odbiorcy.', 'agentWorld.marketplace': 'Rynek', 'agentWorld.messaging': 'Wiadomości', 'agentWorld.walletNotConfigured': 'Portfel nie jest skonfigurowany', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 6c199906a6..c0b55f6319 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -467,6 +467,14 @@ const messages: TranslationMap = { 'agentWorld.directory': 'Diretório', '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.marketplace': 'Mercado', 'agentWorld.messaging': 'Mensagens', 'agentWorld.walletNotConfigured': 'Carteira não configurada', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index c4cf037bc8..abd0add68a 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -467,6 +467,14 @@ const messages: TranslationMap = { 'agentWorld.directory': 'Каталог', '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.marketplace': 'Маркетплейс', 'agentWorld.messaging': 'Сообщения', 'agentWorld.walletNotConfigured': 'Кошелёк не настроен', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 727630da1b..3b429c18d5 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -431,6 +431,13 @@ const messages: TranslationMap = { 'agentWorld.directory': '目录', '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.marketplace': '市场', 'agentWorld.messaging': '消息', 'agentWorld.walletNotConfigured': '钱包未设置', diff --git a/src/openhuman/tinyplace/manifest.rs b/src/openhuman/tinyplace/manifest.rs index 5a6a488d2b..123873b652 100644 --- a/src/openhuman/tinyplace/manifest.rs +++ b/src/openhuman/tinyplace/manifest.rs @@ -2496,6 +2496,87 @@ pub(crate) fn handle_tinyplace_registry_assign_primary( }) } +/// 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. +/// +/// Fails closed: if the recipient can't be resolved, carries no public key, or +/// the post-transfer read-back doesn't show the new owner, this returns an +/// error rather than reporting a success that may not have landed. +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()); + } + // Never log the handle or recipient — both are user-identifying. + log::debug!("{LOG_PREFIX} registry_transfer requested"); + + let client = global_state().client().await?; + + // Resolve the recipient handle to its identity so we have a verified + // cryptoId + publicKey. An unknown recipient fails closed here. 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_identity = availability + .identity + .ok_or("recipient handle is not registered on tiny.place")?; + let recipient_crypto_id = recipient_identity.crypto_id.trim().to_string(); + let recipient_public_key = recipient_identity.public_key.trim().to_string(); + if recipient_crypto_id.is_empty() || recipient_public_key.is_empty() { + return Err( + "recipient identity is missing key material; cannot transfer safely".to_string(), + ); + } + log::debug!("{LOG_PREFIX} registry_transfer recipient resolved"); + + let request = tinyplace::types::IdentityTransferRequest { + crypto_id: recipient_crypto_id.clone(), + public_key: recipient_public_key, + ..Default::default() + }; + let identity = client + .registry + .transfer(&name, request) + .await + .map_err(map_err)?; + + // Read-back confirmation: the returned identity must now be owned by the + // recipient. If it isn't, treat the transfer as failed rather than + // reporting a success the backend didn't actually apply. + if identity.crypto_id.trim() != recipient_crypto_id { + log::warn!("{LOG_PREFIX} registry_transfer read-back mismatch; treating as failed"); + return Err( + "handle transfer could not be confirmed; the handle was not reassigned".to_string(), + ); + } + log::debug!("{LOG_PREFIX} registry_transfer confirmed"); + to_value(serde_json::json!({ "identity": identity })) + }) +} + // ── Users email verification ──────────────────────────────────────────────── pub(crate) fn handle_tinyplace_users_start_email_verification( @@ -5278,6 +5359,28 @@ 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}"); + + // 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}"); + } + /// 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..0512c69485 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,32 @@ 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 +2754,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 +3008,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 2c089f22bcb6d333b50936076b957dc0b0655a44 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 17 Jul 2026 19:05:37 +0530 Subject: [PATCH 02/86] address review: fmt, PII-safe log, primary-transfer guard, i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - schemas.rs: restore rustfmt wrapping of the registry_transfer 'name' input (fixes the Rust Quality + Frontend Checks fmt lanes). - TransferHandleModal: drop String(err) from the failure debug log so no backend/SDK detail is logged (the UI still shows it via setError). - manifest.rs: reject transferring a PRIMARY handle server-side, not just in the UI — a direct JSON-RPC caller could otherwise move the wallet's active identity. Also cover the '@' name-normalization case in the test. - i18n: fix the Bengali warning grammar; use ownership-transfer wording (Przekaż/Przekazanie) in Polish. --- .../components/TransferHandleModal.tsx | 4 ++- app/src/lib/i18n/bn.ts | 2 +- app/src/lib/i18n/pl.ts | 10 ++++---- src/openhuman/tinyplace/manifest.rs | 25 +++++++++++++++++++ src/openhuman/tinyplace/schemas.rs | 5 +++- 5 files changed, 38 insertions(+), 8 deletions(-) diff --git a/app/src/agentworld/components/TransferHandleModal.tsx b/app/src/agentworld/components/TransferHandleModal.tsx index f573485e21..c494d58ff6 100644 --- a/app/src/agentworld/components/TransferHandleModal.tsx +++ b/app/src/agentworld/components/TransferHandleModal.tsx @@ -54,7 +54,9 @@ export default function TransferHandleModal({ onClose(); } catch (err) { // Fail closed: keep the dialog open, show why, report no success. - debug('[agentworld:identity] handle transfer failed: %s', String(err)); + // Log only the status (no raw error — it can carry backend/SDK detail); + // the raw message still surfaces in the UI via setError. + debug('[agentworld:identity] handle transfer failed'); setError(String(err)); setSubmitting(false); } diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 58ff7465b2..21b54d8db0 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -471,7 +471,7 @@ const messages: TranslationMap = { 'agentWorld.transferHandle.action': 'স্থানান্তর', 'agentWorld.transferHandle.title': 'হ্যান্ডেল স্থানান্তর', 'agentWorld.transferHandle.warning': - 'হ্যান্ডেল স্থানান্তর স্থায়ী এবং এটি ফেরানো যায় না। প্রাপক এর একমাত্র মালিক হয়ে যান।', + 'হ্যান্ডেল স্থানান্তর স্থায়ী এবং এটি ফেরানো যায় না। প্রাপক একমাত্র মালিক হয়ে যাবেন।', 'agentWorld.transferHandle.recipientPlaceholder': 'প্রাপকের @handle', 'agentWorld.transferHandle.confirm': 'হ্যান্ডেল স্থানান্তর', 'agentWorld.transferHandle.submitting': 'স্থানান্তর করা হচ্ছে…', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index f6386c11b8..0df9395498 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -480,13 +480,13 @@ const messages: TranslationMap = { 'agentWorld.directory': 'Katalog', 'agentWorld.identities': 'Tożsamości', 'agentWorld.profiles': 'Profile', - 'agentWorld.transferHandle.action': 'Przenieś', - 'agentWorld.transferHandle.title': 'Przenieś handle', + 'agentWorld.transferHandle.action': 'Przekaż', + 'agentWorld.transferHandle.title': 'Przekaż handle', 'agentWorld.transferHandle.warning': - 'Przeniesienie handle jest trwałe i nie można go cofnąć. Odbiorca staje się jego jedynym właścicielem.', + "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': 'Przenieś handle', - 'agentWorld.transferHandle.submitting': 'Przenoszenie…', + 'agentWorld.transferHandle.confirm': 'Przekaż handle', + 'agentWorld.transferHandle.submitting': 'Przekazywanie…', 'agentWorld.transferHandle.recipientRequired': 'Podaj handle odbiorcy.', 'agentWorld.marketplace': 'Rynek', 'agentWorld.messaging': 'Wiadomości', diff --git a/src/openhuman/tinyplace/manifest.rs b/src/openhuman/tinyplace/manifest.rs index 123873b652..4af654cba4 100644 --- a/src/openhuman/tinyplace/manifest.rs +++ b/src/openhuman/tinyplace/manifest.rs @@ -2531,6 +2531,23 @@ pub(crate) fn handle_tinyplace_registry_transfer(params: Map) -> let client = global_state().client().await?; + // Defense-in-depth for a destructive action: a primary (active) handle + // must not be transferable. The UI hides the action for primary handles, + // but a direct JSON-RPC caller bypasses that — so reject it server-side + // too. Transferring the wallet's active identity would orphan it, and a + // primary handle is locked from sale, so this keeps the two consistent. + let own = client + .registry + .get(&format!("@{name}")) + .await + .map_err(map_err)?; + if own.identity.as_ref().and_then(|i| i.primary) == Some(true) { + log::warn!("{LOG_PREFIX} registry_transfer rejected: handle is primary"); + return Err( + "cannot transfer your primary handle; unassign it as primary first".to_string(), + ); + } + // Resolve the recipient handle to its identity so we have a verified // cryptoId + publicKey. An unknown recipient fails closed here. We query // with the `@`-prefixed form, matching the proven availability path @@ -5367,6 +5384,14 @@ mod tests { 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())); diff --git a/src/openhuman/tinyplace/schemas.rs b/src/openhuman/tinyplace/schemas.rs index 0512c69485..fe2a83af0e 100644 --- a/src/openhuman/tinyplace/schemas.rs +++ b/src/openhuman/tinyplace/schemas.rs @@ -1480,7 +1480,10 @@ fn schema_registry_transfer() -> ControllerSchema { 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( + "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 \ From 8fc1e71e3460fcda823e2f231eb76307fed20d18 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Sat, 18 Jul 2026 01:02:54 +0530 Subject: [PATCH 03/86] harden transfer confirm UX + drop redundant log prefix (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the maintainer reviews on #4998: - oxoxDev (confirmation-UX hardening for the irreversible action): add a 'type the handle to confirm' step to TransferHandleModal — the danger button stays disabled until the user re-types the exact handle (case- and @-insensitive), and submit() fails closed if it doesn't match. Adds agentWorld.transferHandle.confirmLabel / confirmMismatch across en + all 13 locales, plus test coverage for the gate. - graycyrus (cosmetic): the debug() calls already run under the 'agentworld:identity' namespace, so drop the redundant '[agentworld:identity]' prefix baked into each message string. --- .../components/TransferHandleModal.test.tsx | 19 +++++++ .../components/TransferHandleModal.tsx | 56 ++++++++++++++++--- 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 + 16 files changed, 94 insertions(+), 9 deletions(-) diff --git a/app/src/agentworld/components/TransferHandleModal.test.tsx b/app/src/agentworld/components/TransferHandleModal.test.tsx index 38d0ff4acf..be9f250d58 100644 --- a/app/src/agentworld/components/TransferHandleModal.test.tsx +++ b/app/src/agentworld/components/TransferHandleModal.test.tsx @@ -42,12 +42,30 @@ describe('TransferHandleModal', () => { 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. @@ -63,6 +81,7 @@ describe('TransferHandleModal', () => { 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(() => diff --git a/app/src/agentworld/components/TransferHandleModal.tsx b/app/src/agentworld/components/TransferHandleModal.tsx index c494d58ff6..d0fba0738d 100644 --- a/app/src/agentworld/components/TransferHandleModal.tsx +++ b/app/src/agentworld/components/TransferHandleModal.tsx @@ -3,9 +3,10 @@ * * 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 and an explicit confirm click, and - * 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 + * 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. */ @@ -17,6 +18,7 @@ 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 { @@ -27,6 +29,11 @@ export interface TransferHandleModalProps { 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, @@ -34,33 +41,44 @@ export default function TransferHandleModal({ }: 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('[agentworld:identity] handle transfer requested'); + debug('handle transfer requested'); try { await apiClient.registry.transfer(handle, target); - debug('[agentworld:identity] handle transfer confirmed'); + 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('[agentworld:identity] handle transfer failed'); + debug('handle transfer failed'); setError(String(err)); setSubmitting(false); } - }, [recipient, handle, t, onTransferred, onClose]); + }, [recipient, confirmMatches, handle, t, onTransferred, onClose]); return ( undefined : onClose}>
-

@{handle.replace(/^@+/, '')}

+

@{handleClean}

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

@@ -87,6 +105,26 @@ export default function TransferHandleModal({ 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} @@ -102,7 +140,7 @@ export default function TransferHandleModal({ size="sm" tone="danger" onClick={() => void submit()} - disabled={submitting || !recipient.trim()} + disabled={submitting || !recipient.trim() || !confirmMatches} data-testid="transfer-handle-confirm"> {submitting ? t('agentWorld.transferHandle.submitting') diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 06bcb3adbd..a257dce4dc 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -460,6 +460,8 @@ const messages: TranslationMap = { 'agentWorld.transferHandle.confirm': 'نقل المعرّف', 'agentWorld.transferHandle.submitting': 'جارٍ النقل…', 'agentWorld.transferHandle.recipientRequired': 'أدخل معرّف المستلم.', + 'agentWorld.transferHandle.confirmLabel': 'اكتب المعرّف للتأكيد', + 'agentWorld.transferHandle.confirmMismatch': 'المعرّف المكتوب غير مطابق.', 'agentWorld.marketplace': 'السوق', 'agentWorld.messaging': 'الرسائل', 'agentWorld.walletNotConfigured': 'المحفظة غير مُهيَّأة', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 21b54d8db0..daa8f5296c 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -476,6 +476,8 @@ const messages: TranslationMap = { 'agentWorld.transferHandle.confirm': 'হ্যান্ডেল স্থানান্তর', 'agentWorld.transferHandle.submitting': 'স্থানান্তর করা হচ্ছে…', 'agentWorld.transferHandle.recipientRequired': 'প্রাপকের হ্যান্ডেল লিখুন।', + 'agentWorld.transferHandle.confirmLabel': 'নিশ্চিত করতে হ্যান্ডেলটি টাইপ করুন', + 'agentWorld.transferHandle.confirmMismatch': 'টাইপ করা হ্যান্ডেল মিলছে না।', 'agentWorld.marketplace': 'মার্কেটপ্লেস', 'agentWorld.messaging': 'বার্তা', 'agentWorld.walletNotConfigured': 'ওয়ালেট সেট আপ হয়নি', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 2044205cae..29bdc9c2fd 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -502,6 +502,8 @@ const messages: TranslationMap = { '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.marketplace': 'Marktplatz', 'agentWorld.messaging': 'Nachrichten', 'agentWorld.walletNotConfigured': 'Wallet nicht eingerichtet', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 262fb52614..bf5abb1e8d 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -205,6 +205,8 @@ const en: TranslationMap = { '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.marketplace': 'Marketplace', 'agentWorld.messaging': 'Messages', 'agentWorld.walletNotConfigured': 'Wallet not set up', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index bdbb9f62d0..fa6eb1d8af 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -486,6 +486,8 @@ const messages: TranslationMap = { '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.marketplace': 'Mercado', 'agentWorld.messaging': 'Mensajes', 'agentWorld.walletNotConfigured': 'Cartera no configurada', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 42644a5161..f7f98b4eca 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -496,6 +496,8 @@ const messages: TranslationMap = { '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.marketplace': 'Marché', 'agentWorld.messaging': 'Messages', 'agentWorld.walletNotConfigured': 'Portefeuille non configuré', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index b998f313e5..f6445f59b3 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -476,6 +476,8 @@ const messages: TranslationMap = { 'agentWorld.transferHandle.confirm': 'हैंडल स्थानांतरित करें', 'agentWorld.transferHandle.submitting': 'स्थानांतरित किया जा रहा है…', 'agentWorld.transferHandle.recipientRequired': 'प्राप्तकर्ता का हैंडल दर्ज करें।', + 'agentWorld.transferHandle.confirmLabel': 'पुष्टि के लिए हैंडल टाइप करें', + 'agentWorld.transferHandle.confirmMismatch': 'टाइप किया गया हैंडल मेल नहीं खाता।', 'agentWorld.marketplace': 'मार्केटप्लेस', 'agentWorld.messaging': 'संदेश', 'agentWorld.walletNotConfigured': 'वॉलेट सेट नहीं है', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 6cf7fe6680..6c75523bb6 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -482,6 +482,8 @@ const messages: TranslationMap = { '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.marketplace': 'Pasar', 'agentWorld.messaging': 'Pesan', 'agentWorld.walletNotConfigured': 'Dompet belum diatur', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 0382fe7d13..4ea4679054 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -489,6 +489,8 @@ const messages: TranslationMap = { '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.marketplace': 'Mercato', 'agentWorld.messaging': 'Messaggi', 'agentWorld.walletNotConfigured': 'Wallet non configurato', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 2ca634001f..348800b147 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -469,6 +469,8 @@ const messages: TranslationMap = { 'agentWorld.transferHandle.confirm': '핸들 이전', 'agentWorld.transferHandle.submitting': '이전 중…', 'agentWorld.transferHandle.recipientRequired': '수신자 핸들을 입력하세요.', + 'agentWorld.transferHandle.confirmLabel': '확인하려면 핸들을 입력하세요', + 'agentWorld.transferHandle.confirmMismatch': '입력한 핸들이 일치하지 않습니다.', 'agentWorld.marketplace': '마켓플레이스', 'agentWorld.messaging': '메시지', 'agentWorld.walletNotConfigured': '지갑이 설정되지 않음', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 0df9395498..d9b560a3c4 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -488,6 +488,8 @@ const messages: TranslationMap = { '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.marketplace': 'Rynek', 'agentWorld.messaging': 'Wiadomości', 'agentWorld.walletNotConfigured': 'Portfel nie jest skonfigurowany', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index f053216c8d..0d1229ac0b 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -481,6 +481,8 @@ const messages: TranslationMap = { '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.marketplace': 'Mercado', 'agentWorld.messaging': 'Mensagens', 'agentWorld.walletNotConfigured': 'Carteira não configurada', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index f3cc9b61e9..41da34437c 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -481,6 +481,8 @@ const messages: TranslationMap = { 'agentWorld.transferHandle.confirm': 'Передать хэндл', 'agentWorld.transferHandle.submitting': 'Передача…', 'agentWorld.transferHandle.recipientRequired': 'Введите хэндл получателя.', + 'agentWorld.transferHandle.confirmLabel': 'Введите хэндл для подтверждения', + 'agentWorld.transferHandle.confirmMismatch': 'Введённый хэндл не совпадает.', 'agentWorld.marketplace': 'Маркетплейс', 'agentWorld.messaging': 'Сообщения', 'agentWorld.walletNotConfigured': 'Кошелёк не настроен', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 56fcb55832..4ca59f6f58 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -444,6 +444,8 @@ const messages: TranslationMap = { 'agentWorld.transferHandle.confirm': '转移句柄', 'agentWorld.transferHandle.submitting': '正在转移…', 'agentWorld.transferHandle.recipientRequired': '请输入接收者的句柄。', + 'agentWorld.transferHandle.confirmLabel': '输入句柄以确认', + 'agentWorld.transferHandle.confirmMismatch': '输入的句柄不匹配。', 'agentWorld.marketplace': '市场', 'agentWorld.messaging': '消息', 'agentWorld.walletNotConfigured': '钱包未设置', From 9289c1418487c41c18922ccf95fae694810ac261 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 15 Jul 2026 07:32:47 +0000 Subject: [PATCH 04/86] chore(release): v0.61.2 [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 0d2729dbd3..177d704f9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4341,7 +4341,7 @@ dependencies = [ [[package]] name = "openhuman" -version = "0.61.1" +version = "0.61.2" dependencies = [ "aes-gcm", "aho-corasick", diff --git a/Cargo.toml b/Cargo.toml index 19d24e8edc..2b6bff7f03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openhuman" -version = "0.61.1" +version = "0.61.2" edition = "2021" description = "OpenHuman core business logic and RPC server" autobins = false diff --git a/app/package.json b/app/package.json index bbdf900ba1..b85fa69d80 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "openhuman-app", - "version": "0.61.1", + "version": "0.61.2", "type": "module", "engines": { "node": ">=24.0.0" diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index cd75352d2f..c71413f1cb 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.1" +version = "0.61.2" dependencies = [ "anyhow", "async-trait", @@ -5564,7 +5564,7 @@ dependencies = [ [[package]] name = "openhuman" -version = "0.61.1" +version = "0.61.2" dependencies = [ "aes-gcm", "aho-corasick", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 2515f07f37..177210c7e1 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "OpenHuman" -version = "0.61.1" +version = "0.61.2" 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 49ecbf7ed5..ac600a9c19 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.1", + "version": "0.61.2", "identifier": "com.openhuman.app", "build": { "beforeDevCommand": "pnpm run dev", From fafe0bf2ac08376d8c2876299df0b7e3872636aa Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:58:22 +0300 Subject: [PATCH 05/86] =?UTF-8?q?feat(flows):=20workflow-builder=20agent-f?= =?UTF-8?q?riendliness=20plumbing=20=E2=80=94=20schema-aware=20sandbox,=20?= =?UTF-8?q?unified=20draft=20handles,=20reference=20gates=20(#4881)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../flows/agents/workflow_builder/prompt.md | 103 ++- src/openhuman/flows/builder_tools.rs | 762 +++++++++++++++--- src/openhuman/flows/builder_tools_tests.rs | 707 ++++++++++++++++ src/openhuman/flows/ops.rs | 402 ++++++++- src/openhuman/flows/ops_tests.rs | 245 ++++++ src/openhuman/flows/tools.rs | 4 + src/openhuman/flows/tools_tests.rs | 3 + src/openhuman/tinyflows/caps.rs | 121 +++ vendor/tinyflows | 2 +- 9 files changed, 2206 insertions(+), 143 deletions(-) diff --git a/src/openhuman/flows/agents/workflow_builder/prompt.md b/src/openhuman/flows/agents/workflow_builder/prompt.md index 7d8430866f..3a643e3496 100644 --- a/src/openhuman/flows/agents/workflow_builder/prompt.md +++ b/src/openhuman/flows/agents/workflow_builder/prompt.md @@ -13,11 +13,15 @@ no tool that does — by design. Your authoring outputs are: - **`propose_workflow`** / **`revise_workflow`** — these *validate* a candidate graph and hand back a proposal summary. They **never** save anything. -- **`dry_run_workflow`** — runs a *draft* in a **sandbox** against mock +- **`dry_run_workflow`** — runs a graph in a **sandbox** against mock capabilities (deterministic echoes). Nothing real happens: no message is sent, - no code runs, no HTTP fires. Treat its output as a wiring check only. + no code runs, no HTTP fires. Treat its output as a wiring check only. Takes the + graph as any of `draft_id` / `flow_id` / an inline `graph` (precedence + `draft_id` > `flow_id` > `graph`). - **`save_workflow`** — the ONE persistence tool you have, and it only writes to - a flow that **already exists** (you need its `flow_id`). See below. + a flow that **already exists** (you need its `flow_id` as the target). Its + source is a `draft_id` (the usual case after iterating with `edit_workflow`) OR + an inline `graph`. See below. Persisting is otherwise the user's own action, not a tool you have — the one exception is `save_workflow` on an **existing** flow id, and only when the @@ -39,8 +43,9 @@ default. Your arc is: **When the user says "save it":** if you have a `save_workflow` action available — an **existing** `flow_id` plus their explicit ask ("save this", -"yes save it onto flow_X") — just call `save_workflow { flow_id, graph, -name? }` and confirm in one plain line what you saved (trigger, steps, and — +"yes save it onto flow_X") — just call `save_workflow { flow_id, draft_id, +name? }` (pass the `draft_id` you've been iterating on; an inline `graph` +also works) and confirm in one plain line what you saved (trigger, steps, and — if the flow is enabled with a schedule/app_event trigger — that it's now live and will fire on its own). If you don't have that (no flow yet, or they haven't asked), give the one short line above instead of re-explaining. @@ -91,13 +96,19 @@ rather than a general context recall), use `memory_hybrid_search` in its - `search_tool_catalog { query, toolkit? }` → real Composio action **slugs** from the FULL LIVE catalog for ANY named app — connected or not, curated or not (curated matches come back `featured: true` and are - ranked first). **Never hallucinate a slug** — if the catalog has no - match for the app, prefer an `http_request` node or tell the user the - integration isn't available. Each match also carries `required_args` / - `output_fields` / `primary_array_path` — but call `get_tool_contract - { slug }` before you actually WIRE a match: it hands back the exact - required args, the full input/output schema, and the array path a - `split_out` should use (see `tool_call` below). `propose_workflow` / + ranked first; a match may also carry `runtime_gated: true`, meaning that + action is blocked on real runs — prefer a `featured` one instead). + **Prefer ONE short keyword** (e.g. `gmail`, `send email`) for the widest + listing; a multi-word query that finds nothing no longer dead-ends — it + falls back to the nearest per-keyword matches with an explanatory `note`, + so read that note rather than assuming the app is missing. **Never + hallucinate a slug** — if the catalog genuinely has no match, prefer an + `http_request` node or tell the user the integration isn't available. Each + match also carries `required_args` / `output_fields` / `primary_array_path` + — but call `get_tool_contract { slug }` before you actually WIRE a match: it + hands back the exact required args, the full input/output schema, and the + array path a `split_out` should use (see `tool_call` below). + `propose_workflow` / `revise_workflow` / `save_workflow` HARD-REJECT a `tool_call` whose slug isn't real in the live catalog, or that's missing one of its real required args — so grounding here isn't optional polish, it's what @@ -117,13 +128,29 @@ You have a machine-readable belt; use it instead of relying on memory: gotchas. Consult these instead of guessing config shapes (this is the source of truth; the summary below is just orientation). - **Iterate cheaply:** once a draft exists, prefer `edit_workflow { draft_id | - flow_id | graph, ops[] }` (add_node / update_node_config[merge-patch] / - rename_node / add_edge / remove_edge / …) over re-emitting the whole graph - with `revise_workflow` — it's fewer tokens and won't drop a node or mangle an - edge. Edits to a `draft_id` are written back to the shared draft. -- **Check without proposing:** `validate_workflow { graph | flow_id }` runs the - same structural + hard-gate stack and returns every problem at once, so you - can self-verify mid-build without emitting a proposal card. + flow_id | graph, ops[] }` over re-emitting the whole graph with + `revise_workflow` — it's fewer tokens and won't drop a node or mangle an edge. + The op shapes (each is `{ "op": , … }`; `id` also accepts the alias + `node_id`, and `rename_node`'s `new_id` accepts `new_node_id`): + `add_node {node}` · `update_node_config {id, config}` (a JSON merge-patch — a + `null` value deletes that config key) · `set_node_name {id, name}` · + `rename_node {id, new_id}` (rewires edges) · `remove_node {id}` (drops its + edges) · `add_edge {edge}` · `remove_edge {from_node, to_node, from_port?, + to_port?}` · `set_node_position {id, position}`. Ops apply **strictly in array + order**, so to replace a node put its `remove_node` BEFORE the `add_node` (or + just `update_node_config` in place) — an "id already exists" error is almost + always that ordering slip. A bad op's error names the failing op index and the + exact shape that op wanted; fix and call again. + **Persistence:** `edit_workflow` NEVER saves. Editing a `flow_id` **seeds a new + draft** from that flow (the flow itself is untouched) and returns its + `draft_id`; editing a `draft_id` writes back to that same draft. The result + always carries `persisted: false` plus a `next` hint — keep iterating by + passing the returned `draft_id` to `edit_workflow` / `dry_run_workflow`, and + persist only on the user's explicit ask with `save_workflow { flow_id, + draft_id }`. A proposal is never a save. +- **Check without proposing:** `validate_workflow { draft_id | flow_id | graph }` + runs the same structural + hard-gate stack and returns every problem at once, + so you can self-verify mid-build without emitting a proposal card. - **Steer connections:** `list_connectable_toolkits` flags which toolkits are already connected — prefer those; the proposal's `required_connections` enumerates what still needs linking. @@ -300,6 +327,20 @@ A `WorkflowGraph` is `{ name?, nodes: [...], edges: [...] }`. slug isn't a real action in the live Composio catalog for its toolkit — a hallucinated or typo'd slug never makes it past validation, so always ground `config.slug` in a `search_tool_catalog` result first. + - **The `connection_ref` is enforced against the RIGHT toolkit.** + `config.connection_ref` must read `composio::` where + `` matches the slug's toolkit AND `` is one of the user's real + connections **for that toolkit** — get each ref verbatim from + `list_flow_connections`. Copying an id from a DIFFERENT toolkit (e.g. a + TikTok connection id onto a Gmail node) is HARD-REJECTED at + `propose_workflow`/`revise_workflow`/`save_workflow`, naming the correct + ref — so never reuse an id across toolkits. + - **`get_tool_contract` may return a top-level `runtime_gate` warning.** For + an uncurated action of a toolkit that ships a curated catalog, the real + runtime tool gate allows curated actions only, so that action is REJECTED + on every real run. Treat a `runtime_gate` warning as a **hard stop**: go + back to `search_tool_catalog` and pick a `featured: true` action instead of + wiring the gated one. - **Wiring a DOWNSTREAM node off THIS tool's output?** Don't guess the field name (e.g. assuming `GMAIL_FETCH_EMAILS` returns `.messages`) — `get_tool_contract`'s `output_fields` names the action's REAL top-level @@ -585,7 +626,11 @@ names, `dry_run_workflow` again, and repeat until it comes back clean. Only then call `propose_workflow` / `save_workflow`. Don't hand back a proposal you haven't verified just because the turn has run long — the user would rather wait one more tool call than review a graph that silently does -nothing. +nothing. **One exception:** a `null_resolutions` entry flagged `unverifiable: +true` (or an `unverifiable_bindings` list) is a Composio-upstream binding the +sandbox genuinely can't check — confirm it with `get_tool_contract` rather +than re-wiring, and don't loop on it (see "Interpreting dry-run results +honestly" below). ### Interpreting dry-run results honestly @@ -613,13 +658,27 @@ values appear: entry and the dry run returns `ok: false`. **These are real bugs — never dismiss them.** Fix every one before proposing. +3. **Unverifiable Composio-upstream bindings** — a `null_resolutions` entry + may carry `unverifiable: true` and an `upstream_tool_call` when the required + arg binds to the OUTPUT of a Composio `tool_call` node (an early-abort dry + run surfaces the same class as `unverifiable_bindings`). The echo sandbox + can never produce a Composio tool's real output fields, so this null does + **NOT** prove the binding wrong — it is genuinely unknowable here. Do **not** + thrash re-wiring it. Confirm the path against `get_tool_contract`'s + `output_fields` / `primary_array_path` (remember Composio results nest under + `.item.json.data.`), or `get_tool_output_sample { slug, args }` for the real + shape; it's a bug only if the path doesn't match the action's actual output. + The propose/save gate no longer blocks on this class, so a graph whose only + flag is `unverifiable` bindings you've confirmed is fine to propose. + #### Sandbox mock behavior per node type (authoritative — do NOT probe) | Node kind | Sandbox output | Enveloped? | What resolves downstream | |-----------|----------------|------------|---------------------------| | `trigger` | Passthrough — echoes the `input` value (default `{}`) | No | Whatever was passed as `input` | -| `agent` (with `output_parser.schema`) | Typed placeholder per schema property (`string`→`""`, `number`/`integer`→`0`, `boolean`→`false`, `object`→`{}`, `array`→`[]`, `enum`→its first listed value) | Yes | `=nodes..item.json.` → the placeholder (non-null) | -| `agent` (no schema) | `{ "agent": "", "request": {...}, "connection": ... }` | Yes | Only `.json.agent` / `.json.request` / `.json.connection` resolve; any other `.json.` → null | +| `agent` (with `output_parser.schema`) | Typed placeholder per schema property (`string`→`""`, `number`/`integer`→`0`, `boolean`→`false`, `object`→`{}`, `array`→`[]`, `enum`→its first listed value). Applies to **every** agent node — plain or with an `agent_ref` | Yes | `=nodes..item.json.` → the placeholder (non-null) | +| `agent` (no schema, plain — no `agent_ref`) | `{ "completion": , "connection": ... }` (the mock LLM echo) | Yes | Only `.json.completion` / `.json.connection` resolve; any other `.json.` → null | +| `agent` (no schema, with `agent_ref`) | `{ "agent": "", "request": {...}, "connection": ... }` | Yes | Only `.json.agent` / `.json.request` / `.json.connection` resolve; any other `.json.` → null | | `tool_call` | Required Composio args are preflight-checked first (missing/null → dry run fails before the mock even runs), then echoes `{ "tool": "", "args": {...}, "connection": ... }` — NOT a real API response | Yes | `.json.tool` / `.json.args` / `.json.connection` resolve; a response-shaped field (e.g. `.json.data.` for a real Composio call — see "the envelope" above) does **not**, because the mock echo carries no `data` wrapper. That is a mock-shape gap, not a wiring bug — don't "fix" a correctly-wired `.json.data.` binding just because the dry run can't resolve it | | `http_request` | `{ "status": 200, "request": {...}, "connection": ... }` | Yes | `.json.status` → `200`; response-body fields → null | | `code` | `{ "result": }` — the real `source` is NOT executed | **No** | `.item.result` resolves directly (no `.json.` — `code` does not envelope) | diff --git a/src/openhuman/flows/builder_tools.rs b/src/openhuman/flows/builder_tools.rs index 599d90b0ab..21a49b19a6 100644 --- a/src/openhuman/flows/builder_tools.rs +++ b/src/openhuman/flows/builder_tools.rs @@ -64,6 +64,30 @@ use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; /// capabilities are non-blocking echoes, so this is a generous safety net. const DRY_RUN_TIMEOUT_SECS: u64 = 30; +/// Comma list of the valid `op` tag values, for the missing-/unknown-`op` +/// parse errors surfaced by [`EditWorkflowTool`]. +const VALID_OP_TYPES: &str = "add_node, update_node_config, set_node_name, rename_node, \ + remove_node, add_edge, remove_edge, set_node_position"; + +/// The expected field shape for a given `op` tag, used in `edit_workflow`'s +/// per-op parse diagnostics so a failing op tells the agent exactly what that +/// op type wants. Returns `None` for an unrecognized tag. +fn edit_op_shape(op: &str) -> Option<&'static str> { + Some(match op { + "add_node" => "{ op, node: { id, kind, name, config? } }", + "update_node_config" => { + "{ op, id, config } (id also accepts alias `node_id`; config is a JSON merge-patch)" + } + "set_node_name" => "{ op, id, name } (id also accepts alias `node_id`)", + "rename_node" => "{ op, id, new_id } (also accept aliases `node_id` / `new_node_id`)", + "remove_node" => "{ op, id } (id also accepts alias `node_id`)", + "add_edge" => "{ op, edge: { from_node, to_node, from_port?, to_port? } }", + "remove_edge" => "{ op, from_node, to_node, from_port?, to_port? }", + "set_node_position" => "{ op, id, position: { x, y } } (id also accepts alias `node_id`)", + _ => return None, + }) +} + // ───────────────────────────────────────────────────────────────────────────── // revise_workflow — iterative refine of an existing draft (proposal only) // ───────────────────────────────────────────────────────────────────────────── @@ -191,6 +215,10 @@ impl Tool for ReviseWorkflowTool { require_approval, true, instruction, + // revise_workflow takes only an inline graph — no draft/flow handle + // to echo. The payload still carries persisted:false unconditionally. + None, + None, ) .await { @@ -240,10 +268,14 @@ impl Tool for EditWorkflowTool { {id, config} (JSON merge-patch — a null value deletes that config key), set_node_name \ {id, name}, rename_node {id, new_id} (rewires edges), remove_node {id} (drops its edges), \ add_edge {edge}, remove_edge {from_node, to_node, from_port?, to_port?}, set_node_position \ - {id, position}. Like propose/revise_workflow this ONLY VALIDATES and returns a proposal \ - for the user to review — it never creates, updates, or enables the flow. If an op fails or \ - the resulting graph is invalid, the error names the failing op / node; fix it and call \ - edit_workflow again." + {id, position}. PERSISTENCE: the applied edit is written to a DRAFT, never onto the saved \ + flow — this tool NEVER saves. Editing a flow_id SEEDS A NEW DRAFT from that flow's graph \ + and returns its `draft_id`; editing a draft_id writes back to that same draft. The result \ + carries `draft_id`, `flow_id` (if any), `persisted: false`, and a `next` hint. To keep \ + iterating pass that `draft_id` (to edit_workflow / dry_run_workflow); to persist, call \ + save_workflow { flow_id, draft_id } when the user asks. If an op fails or the resulting \ + graph is invalid, the error names the failing op / node; fix it and call edit_workflow \ + again." } fn parameters_schema(&self) -> Value { @@ -314,9 +346,16 @@ impl Tool for EditWorkflowTool { .filter(|s| !s.is_empty()); let inline_graph = args.get("graph").filter(|v| !v.is_null()); - // When editing a draft, remember its id so the applied edit is written - // back — the draft is the durable working copy across turns/reloads. + // The applied edit is always written back to a durable DRAFT (the shared + // working copy across turns/reloads). `write_back_draft` is the draft id + // it lands on; `edited_from_flow` is the saved flow this edit derives + // from / would persist onto, if any. The core WS2 fix: editing a bare + // `flow_id` used to persist NOTHING and return NO handle — the edit was + // unreachable and read as "written onto the flow". Now a `flow_id` base + // seeds a NEW draft, so the edit is durable, addressable, and clearly + // NOT the saved flow. let mut write_back_draft: Option = None; + let mut edited_from_flow: Option = None; let (base_graph, default_name) = match (draft_id, flow_id, inline_graph) { (Some(id), _, _) => match ops::flows_draft_get(&self.config, id) { @@ -325,6 +364,9 @@ impl Tool for EditWorkflowTool { match ops::migrate_and_deserialize_graph(draft.graph.clone()) { Ok(graph) => { write_back_draft = Some(draft.id.clone()); + // A draft may already be linked to a saved flow — + // carry that through so the proposal echoes it. + edited_from_flow = draft.flow_id.clone(); (graph, draft.name) } Err(e) => { @@ -341,7 +383,47 @@ impl Tool for EditWorkflowTool { } }, (None, Some(id), _) => match ops::flows_get(&self.config, id).await { - Ok(outcome) => (outcome.value.graph, outcome.value.name), + Ok(outcome) => { + let flow = outcome.value; + // Seed a NEW draft from the saved flow's graph so the edit is + // durable and reachable (the RPC/canvas path uses the same + // `flows_draft_create` op). Linking the draft to `flow.id` + // means a later save_workflow { flow_id, draft_id } knows its + // target. + let graph_json = match serde_json::to_value(&flow.graph) { + Ok(v) => v, + Err(e) => { + return Ok(ToolResult::error(format!( + "Could not serialize flow '{id}' to seed a draft: {e}" + ))); + } + }; + match ops::flows_draft_create( + &self.config, + Some(flow.id.clone()), + flow.name.clone(), + graph_json, + crate::openhuman::flows::DraftOrigin::Chat, + ) { + Ok(created) => { + let new_draft_id = created.value.id.clone(); + tracing::debug!( + target: "flows", + draft_id = %new_draft_id, + flow_id = %flow.id, + "[flows] edit_workflow: seeded a new draft from saved flow (edits live on the draft, NOT the flow)" + ); + write_back_draft = Some(new_draft_id); + edited_from_flow = Some(flow.id.clone()); + (flow.graph, flow.name) + } + Err(e) => { + return Ok(ToolResult::error(format!( + "Could not create a draft to edit flow '{id}': {e}" + ))); + } + } + } Err(e) => { return Ok(ToolResult::error(format!( "Could not load flow '{id}' to edit: {e}" @@ -370,31 +452,46 @@ impl Tool for EditWorkflowTool { } }; - // Parse the ops list. - let ops_value = match args.get("ops") { - Some(v) if v.is_array() => v.clone(), + // Parse the ops list element-by-element so a bad op reports its index, + // its `op` tag, the serde error, AND the expected field shape for THAT + // op type — instead of a bare aggregate "missing field `id`" that names + // neither the failing op nor what it wanted (audit WS4). + let ops_array = match args.get("ops") { + Some(Value::Array(items)) => items.clone(), _ => { return Ok(ToolResult::error( "Missing 'ops' parameter (a non-empty array of structured edits).".to_string(), )); } }; - let graph_ops: Vec = match serde_json::from_value(ops_value) - { - Ok(ops) => ops, - Err(e) => { - return Ok(ToolResult::error(format!( - "Could not parse `ops`: {e}. Each op is {{ \"op\": , ... }} — valid \ - types: add_node, update_node_config, set_node_name, rename_node, \ - remove_node, add_edge, remove_edge, set_node_position." - ))); - } - }; - if graph_ops.is_empty() { + if ops_array.is_empty() { return Ok(ToolResult::error( "`ops` is empty — provide at least one edit.".to_string(), )); } + let mut graph_ops: Vec = Vec::with_capacity(ops_array.len()); + for (index, item) in ops_array.into_iter().enumerate() { + let op_tag = item.get("op").and_then(Value::as_str).map(str::to_string); + match serde_json::from_value::(item) { + Ok(op) => graph_ops.push(op), + Err(e) => { + let shape = match op_tag.as_deref() { + Some(tag) => match edit_op_shape(tag) { + Some(shape) => format!("op `{tag}` expects {shape}"), + None => { + format!("unknown op type `{tag}` — valid types: {VALID_OP_TYPES}") + } + }, + None => format!("missing `op` field — valid types: {VALID_OP_TYPES}"), + }; + tracing::debug!(target: "flows", index, ?op_tag, error = %e, "[flows] edit_workflow: op failed to parse"); + return Ok(ToolResult::error(format!( + "Could not parse op {index}: {e}. Expected {shape}. Each op is \ + {{ \"op\": , ... }}. Fix the ops and call edit_workflow again." + ))); + } + } + } let name = args .get("name") @@ -430,8 +527,22 @@ impl Tool for EditWorkflowTool { Ok(graph) => graph, Err(e) => { tracing::debug!(target: "flows", %name, error = %e, "[flows] edit_workflow: an op failed to apply"); + // Ops apply strictly in array order, so an add_node for an id + // that already exists is almost always an ordering mistake + // (adding before removing the old node). Point at the fix — this + // is the exact 2nd wasted call the WS4 audit caught. + let hint = match (e.op, &e.kind) { + ("add_node", tinyflows::graph_ops::GraphOpErrorKind::NodeIdExists(id)) => { + format!( + "\n\nOps apply strictly in array order. To replace node `{id}`, put a \ + remove_node op for it BEFORE the add_node, or use update_node_config \ + to patch it in place." + ) + } + _ => String::new(), + }; return Ok(ToolResult::error(format!( - "{e}\n\nFix the ops and call edit_workflow again." + "{e}{hint}\n\nFix the ops and call edit_workflow again." ))); } }; @@ -469,6 +580,8 @@ impl Tool for EditWorkflowTool { } // Full builder hard-gate stack + proposal payload (shared with revise). + // Thread the persistence-state handles so the payload carries draft_id / + // flow_id / persisted:false and can't be misread as a save. match ops::build_builder_proposal( &self.config, "edit_workflow", @@ -477,12 +590,32 @@ impl Tool for EditWorkflowTool { require_approval, true, instruction, + write_back_draft.clone(), + edited_from_flow.clone(), ) .await { Ok(mut payload) => { - if let Some(draft_id) = write_back_draft { - payload["draft_id"] = json!(draft_id); + // A prominent, one-line pointer at where the edit actually lives + // (the draft) vs. where it does NOT (the saved flow) — the exact + // confusion the WS2 audit caught. Only meaningful when the edit + // landed on a draft (inline-graph edits have no durable handle). + if let Some(draft_id) = write_back_draft.as_deref() { + let next = match edited_from_flow.as_deref() { + Some(flow_id) => format!( + "Edits live on draft {draft_id}, NOT on flow {flow_id}. Iterate with \ + edit_workflow/dry_run_workflow {{ draft_id: \"{draft_id}\" }}, then \ + persist with save_workflow {{ flow_id: \"{flow_id}\", draft_id: \ + \"{draft_id}\" }} when the user asks." + ), + None => format!( + "Edits live on draft {draft_id} (not yet linked to a saved flow). \ + Iterate with edit_workflow/dry_run_workflow {{ draft_id: \ + \"{draft_id}\" }}, then persist with create_workflow, or save_workflow \ + {{ flow_id, draft_id: \"{draft_id}\" }} once a flow exists." + ), + }; + payload["next"] = json!(next); } Ok(ToolResult::success(serde_json::to_string_pretty(&payload)?)) } @@ -524,25 +657,31 @@ impl Tool for ValidateWorkflowTool { fn description(&self) -> &str { "Check a workflow graph WITHOUT proposing or saving it — the same validation the \ propose/revise/edit/save tools run, surfaced on its own so you can verify a draft mid-\ - build. Provide the graph to check (inline `graph`, or `flow_id` for a saved flow). \ - Returns { ok, structurally_valid, errors, error_details:[{code, message, node_id}], \ - gate_errors, warnings }: `errors` lists EVERY structural problem at once; `gate_errors` \ - lists the hard author-gate failures (unresolvable bindings, unreal tool slugs, unwired \ - required args) checked only once the graph is structurally valid; `warnings` are \ - non-fatal. `ok` is true only when there are no errors and no gate_errors. Read-only." + build. Provide the graph to check as exactly one of `draft_id` (a working draft), \ + `flow_id` (a saved flow), or inline `graph` (if several are given, draft_id wins, then \ + flow_id). Returns { ok, structurally_valid, errors, error_details:[{code, message, \ + node_id}], gate_errors, warnings }: `errors` lists EVERY structural problem at once; \ + `gate_errors` lists the hard author-gate failures (unresolvable bindings, unreal tool \ + slugs, unwired required args) checked only once the graph is structurally valid; \ + `warnings` are non-fatal. `ok` is true only when there are no errors and no gate_errors. \ + Read-only." } fn parameters_schema(&self) -> Value { json!({ "type": "object", "properties": { + "draft_id": { + "type": "string", + "description": "A working draft to validate. Provide one of draft_id / flow_id / graph (draft_id wins)." + }, "flow_id": { "type": "string", - "description": "A saved flow to validate. Provide this OR `graph`." + "description": "A saved flow to validate. Provide one of draft_id / flow_id / graph." }, "graph": { "type": "object", - "description": "An inline tinyflows WorkflowGraph to validate. Provide this OR `flow_id`.", + "description": "An inline tinyflows WorkflowGraph to validate. Provide one of draft_id / flow_id / graph.", "properties": { "nodes": { "type": "array" }, "edges": { "type": "array" } @@ -561,7 +700,14 @@ impl Tool for ValidateWorkflowTool { } async fn execute(&self, args: Value) -> anyhow::Result { - // Resolve the graph to check from either a saved flow or an inline graph. + // Resolve the graph to check from exactly one of a working draft, a + // saved flow, or an inline graph — same precedence (draft_id > flow_id > + // graph) as edit_workflow, so the sibling tools accept the same handles. + let draft_id = args + .get("draft_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()); let flow_id = args .get("flow_id") .and_then(Value::as_str) @@ -569,8 +715,16 @@ impl Tool for ValidateWorkflowTool { .filter(|s| !s.is_empty()); let inline_graph = args.get("graph").filter(|v| !v.is_null()); - let graph_json = match (flow_id, inline_graph) { - (Some(id), _) => match ops::load_flow_graph(&self.config, id) { + let graph_json = match (draft_id, flow_id, inline_graph) { + (Some(id), _, _) => match ops::flows_draft_get(&self.config, id) { + Ok(outcome) => outcome.value.graph, + Err(e) => { + return Ok(ToolResult::error(format!( + "Could not load draft '{id}' to validate: {e}" + ))); + } + }, + (None, Some(id), _) => match ops::load_flow_graph(&self.config, id) { Ok(Some(graph)) => serde_json::to_value(&graph)?, Ok(None) => { return Ok(ToolResult::error(format!("flow '{id}' not found"))); @@ -581,11 +735,11 @@ impl Tool for ValidateWorkflowTool { ))); } }, - (None, Some(graph)) => graph.clone(), - (None, None) => { + (None, None, Some(graph)) => graph.clone(), + (None, None, None) => { return Ok(ToolResult::error( - "Provide either `flow_id` (a saved flow) or `graph` (an inline graph) to \ - validate." + "Provide one of `draft_id` (a working draft), `flow_id` (a saved flow), or \ + `graph` (an inline graph) to validate." .to_string(), )); } @@ -593,6 +747,7 @@ impl Tool for ValidateWorkflowTool { tracing::debug!( target: "flows", + from_draft = draft_id.is_some(), from_flow = flow_id.is_some(), "[flows] validate_workflow: checking graph (read-only)" ); @@ -1458,6 +1613,75 @@ pub(crate) async fn search_live_catalog( toolkit_filter: Option<&str>, limit: usize, ) -> Vec { + search_catalog(config, query, toolkit_filter, limit) + .await + .results +} + +/// Cap on fallback (per-keyword) matches — a near-miss query must not flood the +/// agent's context with the whole toolkit, so the OR-scored fallback returns at +/// most this many rows regardless of the primary `limit`. +const MAX_FALLBACK_RESULTS: usize = 10; + +/// Outcome of a catalog search: the shaped rows, whether the per-keyword +/// fallback pass fired, and an optional advisory `note` the tool surfaces so an +/// agent never misreads a keyword miss as "the action doesn't exist". +pub(crate) struct CatalogSearchOutcome { + pub results: Vec, + /// True when the per-token OR fallback pass ran (primary AND match was + /// empty for a multi-word query). + pub fallback: bool, + /// Advisory note explaining a near-miss / keyword-based search, if any. + pub note: Option, +} + +/// Shape one live-catalog [`ToolContract`](crate::openhuman::tinyflows::caps::ToolContract) +/// into a search-result row. The SINGLE row-construction site shared by both +/// the primary AND-match path and the per-keyword fallback path, so every row +/// carries the same fields — including WS3's `runtime_gated: true` on an +/// uncurated action of a toolkit that ships a curated-only allowlist. +fn shape_catalog_row( + tool: &crate::openhuman::tinyflows::caps::ToolContract, + toolkit: &str, + toolkit_curated: bool, +) -> Value { + let mut row = json!({ + "slug": tool.slug, + "toolkit": toolkit, + "description": tool.description, + "required_args": tool.required_args, + "output_fields": tool.output_fields, + "primary_array_path": tool.primary_array_path, + "featured": tool.is_curated, + }); + // Compact: only present when true. + if !tool.is_curated && toolkit_curated { + if let Some(obj) = row.as_object_mut() { + obj.insert("runtime_gated".to_string(), Value::Bool(true)); + } + } + row +} + +/// Search the FULL LIVE Composio catalog and return a [`CatalogSearchOutcome`]. +/// +/// Primary pass: case-insensitive AND — an action matches only if EVERY +/// whitespace-separated term substring-matches its slug, toolkit name, or +/// description (curated matches ranked first, stable sort preserves fetch +/// order). When that yields zero rows for a MULTI-WORD query, a per-keyword OR +/// fallback runs: each action is scored by how many query tokens match its +/// slug/toolkit/description, and the top [`MAX_FALLBACK_RESULTS`] (ranked by +/// hit-count desc, then curated first) are returned with an advisory `note`. +/// This is what keeps a natural-language query like "twitter tweet replies +/// lookup" from returning a bare `count: 0` even though `TWITTER_*` actions +/// exist — the agent gets the nearest keyword matches instead of falsely +/// concluding the action is missing. +pub(crate) async fn search_catalog( + config: &Config, + query: &str, + toolkit_filter: Option<&str>, + limit: usize, +) -> CatalogSearchOutcome { use crate::openhuman::memory_sync::composio::providers::agent_ready_toolkits; use crate::openhuman::tinyflows::caps::fetch_live_toolkit_catalog; @@ -1487,10 +1711,26 @@ pub(crate) async fn search_live_catalog( })) .await; + // Drop toolkits whose fetch failed (no backend session / network error) — + // they contribute zero results rather than erroring the whole search. + let fetched: Vec<(String, Vec)> = fetched + .into_iter() + .filter_map(|(tk, catalog)| catalog.map(|c| (tk, c))) + .collect(); + + // Does the scanned scope hold ANY actions at all? Distinguishes "keyword + // miss" (has actions, none matched) from "nothing to search" (empty scope). + let any_actions = fetched.iter().any(|(_, catalog)| !catalog.is_empty()); + + // ── Primary pass: case-insensitive AND across every term ── let mut matches: Vec<(bool, Value)> = Vec::new(); - for (toolkit, catalog) in fetched { - let Some(catalog) = catalog else { continue }; - for tool in &catalog { + for (toolkit, catalog) in &fetched { + // WS3 — a toolkit that ships a curated catalog is a hard curated-only + // allowlist at RUNTIME, so any `featured: false` action of it is + // rejected on every real run. Compute once per toolkit and flag those + // rows so the blocker is visible at search time (transcript failure #2). + let toolkit_curated = ops::toolkit_has_curated_catalog(toolkit); + for tool in catalog { let slug_lc = tool.slug.to_ascii_lowercase(); let desc_lc = tool .description @@ -1505,15 +1745,7 @@ pub(crate) async fn search_live_catalog( } matches.push(( tool.is_curated, - json!({ - "slug": tool.slug, - "toolkit": toolkit, - "description": tool.description, - "required_args": tool.required_args, - "output_fields": tool.output_fields, - "primary_array_path": tool.primary_array_path, - "featured": tool.is_curated, - }), + shape_catalog_row(tool, toolkit, toolkit_curated), )); } } @@ -1522,7 +1754,104 @@ pub(crate) async fn search_live_catalog( // within each group. matches.sort_by_key(|(is_curated, _)| std::cmp::Reverse(*is_curated)); matches.truncate(limit); - matches.into_iter().map(|(_, v)| v).collect() + let primary: Vec = matches.into_iter().map(|(_, v)| v).collect(); + + if !primary.is_empty() { + return CatalogSearchOutcome { + results: primary, + fallback: false, + note: None, + }; + } + + // ── Zero primary hits ── + // Single-token queries keep today's behavior exactly; only attach a light + // advisory note so a lone keyword miss still explains the search is + // keyword-based (task WS5.4, optional). + if terms.len() <= 1 { + let note = if any_actions { + Some(format!( + "No actions matched '{query}'. This search is keyword-based (matches action \ + slug/name/description) — try a different single keyword (e.g. 'gmail' or \ + 'tweets')." + )) + } else { + None + }; + return CatalogSearchOutcome { + results: Vec::new(), + fallback: false, + note, + }; + } + + // ── Fallback pass (multi-word, zero primary hits): per-token OR scoring ── + // Score each action by how many DISTINCT query tokens match its + // slug/toolkit/description; keep the primary path's curated boost as the + // tiebreak. Rows go through the SAME `shape_catalog_row` path as primary. + let mut scored: Vec<(usize, bool, Value)> = Vec::new(); + for (toolkit, catalog) in &fetched { + let toolkit_curated = ops::toolkit_has_curated_catalog(toolkit); + for tool in catalog { + let slug_lc = tool.slug.to_ascii_lowercase(); + let desc_lc = tool + .description + .as_deref() + .unwrap_or_default() + .to_ascii_lowercase(); + let hits = terms + .iter() + .filter(|term| { + slug_lc.contains(*term) || toolkit.contains(*term) || desc_lc.contains(*term) + }) + .count(); + if hits == 0 { + continue; + } + scored.push(( + hits, + tool.is_curated, + shape_catalog_row(tool, toolkit, toolkit_curated), + )); + } + } + + // Most keyword hits first, then curated first; stable sort preserves fetch + // order within a (hits, curated) group. + scored.sort_by_key(|(hits, is_curated, _)| std::cmp::Reverse((*hits, *is_curated))); + scored.truncate(limit.min(MAX_FALLBACK_RESULTS)); + let results: Vec = scored.into_iter().map(|(_, _, v)| v).collect(); + + tracing::debug!( + target: "flows", + query, + fallback = true, + hits = results.len(), + "[flows] search_tool_catalog: primary AND-match empty for a multi-word query — ran per-keyword OR fallback" + ); + + if results.is_empty() { + // Literally zero tokens matched anything: no rows, but a note so the + // agent doesn't read `count: 0` as "action doesn't exist" (task WS5.3). + return CatalogSearchOutcome { + results, + fallback: true, + note: Some(format!( + "No actions matched any keyword in '{query}'. This search is keyword-based \ + (matches action slug/name/description) — retry with a single keyword (e.g. one \ + word like 'gmail' or 'tweets') for a full listing." + )), + }; + } + + CatalogSearchOutcome { + results, + fallback: true, + note: Some(format!( + "No exact match for '{query}'. Showing the nearest per-keyword matches — retry with a \ + single keyword (e.g. one word like 'gmail' or 'tweets') for a full listing." + )), + } } #[async_trait] @@ -1553,7 +1882,7 @@ impl Tool for SearchToolCatalogTool { "properties": { "query": { "type": "string", - "description": "Keywords to match against tool slugs/descriptions (case-insensitive; all terms must match)." + "description": "Keywords to match against tool slugs/descriptions (case-insensitive). All terms must match for an exact hit; a multi-word query with no exact match falls back to the nearest per-keyword matches. For the widest listing, prefer ONE keyword (e.g. 'gmail' or 'tweets')." }, "toolkit": { "type": "string", @@ -1585,12 +1914,24 @@ impl Tool for SearchToolCatalogTool { toolkit = toolkit.unwrap_or("(any)"), "[flows] search_tool_catalog: searching the FULL LIVE Composio catalog (read-only)" ); - let results = search_live_catalog(&self.config, &query, toolkit, MAX_CATALOG_RESULTS).await; - Ok(ToolResult::success(serde_json::to_string_pretty(&json!({ - "query": query, - "count": results.len(), - "results": results, - }))?)) + let outcome = search_catalog(&self.config, &query, toolkit, MAX_CATALOG_RESULTS).await; + // Build with `note` first so an agent reading top-down sees the + // near-miss / keyword-based advisory before the (possibly zero) rows. + // `count` is always the number of returned rows, never a stand-in for + // "no such action" — a fallback carries a non-zero count. + let mut obj = serde_json::Map::new(); + if let Some(note) = outcome.note { + obj.insert("note".to_string(), Value::String(note)); + } + obj.insert("query".to_string(), Value::String(query)); + obj.insert( + "count".to_string(), + Value::Number(outcome.results.len().into()), + ); + obj.insert("results".to_string(), Value::Array(outcome.results)); + Ok(ToolResult::success(serde_json::to_string_pretty( + &Value::Object(obj), + )?)) } } @@ -1705,6 +2046,37 @@ impl Tool for GetToolContractTool { // permanently `None`. let contract = crate::openhuman::tinyflows::caps::apply_probe_override(contract.clone()); + + // WS3 — EARLY runtime-gate warning (transcript failure #2): a + // real-but-uncurated action of a toolkit that ships a curated + // catalog is a hard curated-only allowlist at RUNTIME, so it is + // REJECTED on every real run. The late `validate_workflow` gate + // catches it, but only ~15 tool calls after the agent has built + // and wired the node. Surface the blocker HERE, at contract-fetch + // time (and first in the payload), so the agent never wires it. + if !contract.is_curated && ops::toolkit_has_curated_catalog(&toolkit) { + tracing::debug!( + target: "flows", + %slug, + %toolkit, + "[flows] get_tool_contract: uncurated action of a curated toolkit — attaching runtime_gate warning" + ); + #[derive(serde::Serialize)] + struct ContractWithRuntimeGate { + runtime_gate: &'static str, + #[serde(flatten)] + contract: crate::openhuman::tinyflows::caps::ToolContract, + } + let payload = ContractWithRuntimeGate { + runtime_gate: "This action will be REJECTED on every real run — the \ + runtime tool gate only allows curated actions for this \ + toolkit. Pick a `featured: true` result from \ + search_tool_catalog instead.", + contract, + }; + return Ok(ToolResult::success(serde_json::to_string_pretty(&payload)?)); + } + Ok(ToolResult::success(serde_json::to_string_pretty( &contract, )?)) @@ -2170,6 +2542,79 @@ impl Tool for GetNodeKindContractTool { /// (an unexercised branch can be entirely intentional), and is surfaced on /// both the `ok: true` and `ok: false` result shapes so the caller can /// double-check that node's wiring by hand. +/// Builds one `null_resolutions` diagnostic entry for a `tool_call` node's +/// null-resolved `args.*` config expression. +/// +/// The common case reports `{ node_id, location, expression }` — a wiring +/// mistake the agent should fix. But when the null-resolved expression binds to +/// the output of an upstream Composio `tool_call` node +/// ([`ops::composio_tool_call_upstream_ref`]), the entry is instead marked +/// `unverifiable: true` and carries an honest `suggestion`: the echo sandbox +/// can NEVER produce a Composio tool's real output fields, so this particular +/// null is expected here and does NOT prove the binding wrong (WS6 — the +/// transcript audit where the agent re-wired an already-correct binding three +/// times chasing this exact false negative). The message points at +/// `get_tool_contract` / `get_tool_output_sample` as the real disambiguators. +fn build_null_resolution_entry( + node_id: &str, + diag: &tinyflows::expr::NullResolution, + graph: &WorkflowGraph, +) -> Value { + if let Some(upstream) = crate::openhuman::flows::ops::composio_tool_call_upstream_ref( + &diag.expression, + graph, + node_id, + ) { + let field = diag.location.strip_prefix("args.").unwrap_or("args"); + return json!({ + "node_id": node_id, + "location": diag.location, + "expression": diag.expression, + "unverifiable": true, + "upstream_tool_call": upstream, + "suggestion": format!( + "required arg `{field}` binds to the output of Composio tool_call node \ + `{upstream}` — the SANDBOX only echoes tool calls and can never produce \ + their real output fields, so this binding is UNVERIFIABLE here (not \ + necessarily wrong). Confirm the path against get_tool_contract {{ slug }}'s \ + output_fields / primary_array_path (remember Composio results nest under \ + `.item.json.data.`), or get_tool_output_sample {{ slug, args }} for the \ + real shape. It is a real bug only if the path doesn't match the action's \ + actual output." + ), + }); + } + json!({ + "node_id": node_id, + "location": diag.location, + "expression": diag.expression, + }) +} + +/// Every null-resolved `args.*` config expression that landed on a `tool_call` +/// node, as `null_resolutions` diagnostic entries (see +/// [`build_null_resolution_entry`] for the shape, including the WS6 +/// `unverifiable` Composio-upstream variant). Shared by the settled-run path +/// (which fails the dry run on these) and the errored-run path (which surfaces +/// only the `unverifiable` ones so a stop-policy preflight abort explains +/// itself honestly instead of via the generic required-arg text). +fn tool_call_arg_null_entries( + steps: &[tinyflows::observability::ExecutionStep], + graph: &WorkflowGraph, + tool_call_node_ids: &std::collections::HashSet<&str>, +) -> Vec { + steps + .iter() + .filter(|step| tool_call_node_ids.contains(step.node_id.as_str())) + .flat_map(|step| { + step.diagnostics + .iter() + .filter(|&diag| diag.location == "args" || diag.location.starts_with("args.")) + .map(|diag| build_null_resolution_entry(&step.node_id, diag, graph)) + }) + .collect() +} + pub struct DryRunWorkflowTool { security: Arc, config: Arc, @@ -2188,13 +2633,15 @@ impl Tool for DryRunWorkflowTool { } fn description(&self) -> &str { - "Dry-run a DRAFT workflow graph in a SANDBOX to self-verify it before \ + "Dry-run a workflow graph in a SANDBOX to self-verify it before \ proposing. Compiles the graph and executes it against MOCK capabilities \ — every LLM / tool_call / http_request / code node returns a deterministic \ echo, so NOTHING real happens (no messages sent, no code run). Returns the \ simulated per-node output labeled as sandbox output. Use it to catch \ - wiring/routing mistakes; it does NOT prove real integrations work. Pass \ - the same graph shape as propose_workflow, plus an optional `input`." + wiring/routing mistakes; it does NOT prove real integrations work. Provide \ + the graph as exactly one of `draft_id` (a working draft), `flow_id` (a saved \ + flow), or inline `graph` (draft_id wins, then flow_id), plus an optional \ + `input`." } fn parameters_schema(&self) -> Value { @@ -2203,11 +2650,15 @@ impl Tool for DryRunWorkflowTool { "properties": { "draft_id": { "type": "string", - "description": "A working draft to simulate. Provide this OR `graph`." + "description": "A working draft to simulate. Provide one of draft_id / flow_id / graph (draft_id wins)." + }, + "flow_id": { + "type": "string", + "description": "A saved flow to simulate. Provide one of draft_id / flow_id / graph." }, "graph": { "type": "object", - "description": "The DRAFT tinyflows WorkflowGraph to simulate: { nodes: [...], edges: [...] }. Provide this OR `draft_id`.", + "description": "An inline tinyflows WorkflowGraph to simulate: { nodes: [...], edges: [...] }. Provide one of draft_id / flow_id / graph.", "properties": { "nodes": { "type": "array" }, "edges": { "type": "array" } @@ -2235,31 +2686,48 @@ impl Tool for DryRunWorkflowTool { } async fn execute(&self, args: Value) -> anyhow::Result { - // Graph source: a working draft (draft_id) or an inline graph. - let graph_json = if let Some(draft_id) = args + // Graph source: exactly one of a working draft, a saved flow, or an + // inline graph — same precedence (draft_id > flow_id > graph) as the + // sibling validate/edit tools, so they all accept the same handles. + let draft_id = args .get("draft_id") .and_then(Value::as_str) .map(str::trim) - .filter(|s| !s.is_empty()) - { - match ops::flows_draft_get(&self.config, draft_id) { + .filter(|s| !s.is_empty()); + let flow_id = args + .get("flow_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()); + let inline_graph = args.get("graph").filter(|v| !v.is_null()); + + let graph_json = match (draft_id, flow_id, inline_graph) { + (Some(id), _, _) => match ops::flows_draft_get(&self.config, id) { Ok(outcome) => outcome.value.graph, Err(e) => { return Ok(ToolResult::error(format!( - "Could not load draft '{draft_id}' to dry-run: {e}" + "Could not load draft '{id}' to dry-run: {e}" ))); } - } - } else { - match args.get("graph") { - Some(v) if !v.is_null() => v.clone(), - _ => { - return Ok(ToolResult::error( - "Provide `graph` (an inline graph) or `draft_id` (a working draft) to \ - dry-run." - .to_string(), - )); + }, + (None, Some(id), _) => match ops::load_flow_graph(&self.config, id) { + Ok(Some(graph)) => serde_json::to_value(&graph)?, + Ok(None) => { + return Ok(ToolResult::error(format!("flow '{id}' not found"))); + } + Err(e) => { + return Ok(ToolResult::error(format!( + "Could not load flow '{id}' to dry-run: {e}" + ))); } + }, + (None, None, Some(v)) => v.clone(), + (None, None, None) => { + return Ok(ToolResult::error( + "Provide one of `draft_id` (a working draft), `flow_id` (a saved flow), or \ + `graph` (an inline graph) to dry-run." + .to_string(), + )); } }; let input = args.get("input").cloned().unwrap_or_else(|| json!({})); @@ -2299,6 +2767,12 @@ impl Tool for DryRunWorkflowTool { let mut caps = tinyflows::caps::mock::mock_capabilities_with_agent( crate::openhuman::tinyflows::caps::SchemaAwareMockAgentRunner, ); + // Plain agent nodes (no `agent_ref`) never reach the runner above — + // the vendored `agent` node routes them to the `llm` slot instead (see + // `SchemaAwareMockLlm`'s doc). Swap the vendored `MockLlm` echo for the + // schema-aware mock so their `output_parser.schema` is honored too, + // instead of the echo shape failing the sub-port's validation. + caps.llm = std::sync::Arc::new(crate::openhuman::tinyflows::caps::SchemaAwareMockLlm); // Wiring preflight over the echo mocks (see the struct doc): required // Composio args must be present and non-null even in the sandbox. caps.tools = std::sync::Arc::new(crate::openhuman::tinyflows::caps::PreflightToolInvoker { @@ -2344,6 +2818,46 @@ impl Tool for DryRunWorkflowTool { { Ok(Ok(outcome)) => outcome, Ok(Err(e)) => { + // A `stop`-policy `tool_call` whose required arg resolved null + // aborts the WHOLE run here (via `PreflightToolInvoker`), so + // the honest per-field diagnostic never reaches the settled-run + // `null_resolutions` path below. Recover it from the observer: + // if the abort was caused by a required arg bound to an upstream + // Composio `tool_call`'s output, the echo mock simply CAN'T + // produce that field — so surface it as `unverifiable` rather + // than letting the generic "required arg missing/null" text + // (which sent the transcript agent re-wiring a correct binding + // three times) stand alone. WS6. + let unverifiable_bindings: Vec = + tool_call_arg_null_entries(&observer.steps(), &graph, &tool_call_node_ids) + .into_iter() + .filter(|entry| { + entry.get("unverifiable").and_then(Value::as_bool) == Some(true) + }) + .collect(); + if !unverifiable_bindings.is_empty() { + tracing::debug!( + target: "flows", + error = %e, + unverifiable_count = unverifiable_bindings.len(), + "[flows] dry_run_workflow: sandbox run aborted on a Composio-upstream \ + binding the echo mock cannot verify — surfacing it honestly" + ); + return Ok(ToolResult::success(serde_json::to_string_pretty(&json!({ + "sandbox": true, + "ok": false, + "error": e.to_string(), + "unverifiable_bindings": unverifiable_bindings, + "note": "SANDBOX (mock) output — a tool_call node aborted because a \ + required arg binds to the output of an upstream Composio tool_call, \ + which the sandbox can only ECHO (it never produces real tool output \ + fields). See unverifiable_bindings: each MAY already be wired \ + correctly — confirm the path with get_tool_contract {{ slug }} \ + (output_fields / primary_array_path; Composio results nest under \ + .item.json.data.) or get_tool_output_sample {{ slug, args }} instead \ + of re-wiring blindly. No real side effects occurred.", + }))?)); + } tracing::debug!(target: "flows", error = %e, "[flows] dry_run_workflow: sandbox run errored"); return Ok(ToolResult::success(serde_json::to_string_pretty(&json!({ "sandbox": true, @@ -2363,23 +2877,12 @@ impl Tool for DryRunWorkflowTool { // `tool_call` node's `args.*` config path — the class of binding // mistake that "builds" (compiles, dry-runs against echo mocks) but // does nothing at runtime because the wired field never had a value. - let null_resolutions: Vec = observer - .steps() - .iter() - .filter(|step| tool_call_node_ids.contains(step.node_id.as_str())) - .flat_map(|step| { - step.diagnostics - .iter() - .filter(|&diag| diag.location == "args" || diag.location.starts_with("args.")) - .map(|diag| { - json!({ - "node_id": step.node_id, - "location": diag.location, - "expression": diag.expression, - }) - }) - }) - .collect(); + // Each entry is honest about WHY it resolved null: a binding to an + // upstream Composio `tool_call`'s output is flagged `unverifiable` + // (the echo mock can't produce real tool output fields) rather than + // reported as a plain wiring mistake — see [`build_null_resolution_entry`]. + let null_resolutions: Vec = + tool_call_arg_null_entries(&observer.steps(), &graph, &tool_call_node_ids); // Collect every null-resolved `agent`-node `prompt` — execution- // breaking in the same way a null `tool_call` arg is: `prompt` is the @@ -2723,12 +3226,16 @@ impl Tool for SaveWorkflowTool { fn description(&self) -> &str { "Save a workflow graph onto an EXISTING saved flow (by `flow_id`), persisting it. \ - Use this after the user asked you to build/update a workflow and you have \ - dry-run-verified the graph: it validates and writes the graph (and optional new \ - `name`) to that flow. It can NOT create a new flow, and it never changes the \ - flow's enabled state or its approval gate. NOTE: if the flow is enabled and the \ - graph has a schedule/app_event trigger, saving arms it — it will start firing on \ - its own. Always tell the user what you saved. Params: { flow_id, graph, name? }." + This is the ONLY builder tool that writes onto a saved flow — edit/validate/dry_run \ + never do. Use it after the user asked you to build/update a workflow and you have \ + dry-run-verified the graph. The graph source is either `draft_id` (a working draft — \ + the usual case after editing with edit_workflow; draft_id wins if both are given) or \ + an inline `graph`; `flow_id` is always required as the persistence TARGET. It \ + validates and writes the graph (and optional new `name`) to that flow. It can NOT \ + create a new flow, and it never changes the flow's enabled state or its approval \ + gate. NOTE: if the flow is enabled and the graph has a schedule/app_event trigger, \ + saving arms it — it will start firing on its own. Always tell the user what you \ + saved. Params: { flow_id, draft_id? | graph?, name? }." } fn parameters_schema(&self) -> Value { @@ -2737,11 +3244,15 @@ impl Tool for SaveWorkflowTool { "properties": { "flow_id": { "type": "string", - "description": "Id of the EXISTING saved flow to write the graph to." + "description": "Id of the EXISTING saved flow to write the graph to (the persistence target — always required)." + }, + "draft_id": { + "type": "string", + "description": "A working draft whose graph to persist onto the flow. Provide this OR inline `graph`; if both are given, draft_id wins." }, "graph": { "type": "object", - "description": "The full tinyflows WorkflowGraph to persist: { name?, nodes: [...], edges: [...] }. Same shape as propose_workflow.", + "description": "The full tinyflows WorkflowGraph to persist: { name?, nodes: [...], edges: [...] }. Provide this OR `draft_id`. Same shape as propose_workflow.", "properties": { "nodes": { "type": "array" }, "edges": { "type": "array" } @@ -2753,7 +3264,7 @@ impl Tool for SaveWorkflowTool { "description": "Optional new human-readable name for the flow." } }, - "required": ["flow_id", "graph"], + "required": ["flow_id"], "additionalProperties": false }) } @@ -2781,10 +3292,36 @@ impl Tool for SaveWorkflowTool { )) } }; - let graph_json = match args.get("graph") { - Some(v) if !v.is_null() => v.clone(), - _ => return Ok(ToolResult::error("Missing 'graph' parameter".to_string())), - }; + // Graph source: a working draft (the usual post-edit_workflow handle) or + // an inline graph. `flow_id` above is the persistence TARGET, always + // required; the draft only supplies the graph to write. If both a + // draft_id and an inline graph are given, the draft wins (it is the + // durable working copy the agent just iterated on). + let draft_id = args + .get("draft_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()); + let graph_json = + if let Some(id) = draft_id { + match ops::flows_draft_get(&self.config, id) { + Ok(outcome) => outcome.value.graph, + Err(e) => { + return Ok(ToolResult::error(format!( + "Could not load draft '{id}' to save: {e}" + ))); + } + } + } else { + match args.get("graph") { + Some(v) if !v.is_null() => v.clone(), + _ => return Ok(ToolResult::error( + "Provide `draft_id` (a working draft) or inline `graph` to save onto the \ + flow." + .to_string(), + )), + } + }; let name = args .get("name") .and_then(Value::as_str) @@ -2878,6 +3415,9 @@ impl Tool for SaveWorkflowTool { } Ok(ToolResult::success(serde_json::to_string_pretty(&json!({ "type": "workflow_saved", + // Explicit counterpart to a proposal's persisted:false — this + // graph IS now written onto the saved flow. + "persisted": true, "flow_id": flow.id, "name": flow.name, "enabled": flow.enabled, diff --git a/src/openhuman/flows/builder_tools_tests.rs b/src/openhuman/flows/builder_tools_tests.rs index fc207c73fe..e6a4d59dd1 100644 --- a/src/openhuman/flows/builder_tools_tests.rs +++ b/src/openhuman/flows/builder_tools_tests.rs @@ -211,6 +211,26 @@ fn seeded_gmail_send_contract() -> ToolContract { } } +/// A minimal seeded contract with NO required args, for WS6 dry-run tests: seeds +/// a bespoke toolkit so the required-arg preflight always passes and the sandbox +/// run settles into the `null_resolutions` path (rather than aborting), letting +/// the test assert the honest Composio-upstream diagnostic deterministically — +/// independent of whatever gmail/slack contracts other tests seed into the +/// process-global cache. +fn seeded_ws6_contract(slug: &str, toolkit: &str) -> ToolContract { + ToolContract { + slug: slug.to_string(), + toolkit: toolkit.to_string(), + description: Some("ws6 test action".to_string()), + required_args: vec![], + input_schema: Some(json!({ "type": "object", "additionalProperties": true })), + output_fields: vec![], + output_schema: None, + primary_array_path: None, + is_curated: true, + } +} + #[tokio::test] async fn search_live_catalog_finds_a_seeded_real_gmail_slug() { seed_live_catalog_cache("gmail", vec![seeded_gmail_send_contract()]); @@ -403,6 +423,279 @@ async fn get_tool_contract_rejects_a_hallucinated_slug() { assert!(result.output().contains("not a real action")); } +// ── WS3: early runtime-gate warnings on uncurated actions ──────────────────── +// +// Transcript failure #2: `get_tool_contract { slug: "TWITTER_USER_LOOKUP_ME" }` +// returned `is_curated: false` with no other signal; the agent built and wired +// the node and only ~15 tool calls later did `validate_workflow` reject it. A +// real-but-uncurated action of a toolkit that ships a curated catalog is a hard +// curated-only allowlist at RUNTIME, so surface the blocker at contract-fetch / +// search time. Uses `spotify` / `telegram` (real curated toolkits unused by +// other tests) so these seeds can't race with the shared `gmail`/`slack` keys. + +fn spotify_curated_action() -> ToolContract { + ToolContract { + slug: "SPOTIFY_START_PLAYBACK".to_string(), + toolkit: "spotify".to_string(), + description: Some("Start playback".to_string()), + required_args: vec![], + input_schema: Some(json!({ "type": "object" })), + output_fields: vec![], + output_schema: None, + primary_array_path: None, + is_curated: true, + } +} + +#[tokio::test] +async fn get_tool_contract_warns_on_an_uncurated_action_of_a_curated_toolkit() { + let uncurated = ToolContract { + slug: "SPOTIFY_OBSCURE_ACTION".to_string(), + is_curated: false, + ..spotify_curated_action() + }; + seed_live_catalog_cache("spotify", vec![spotify_curated_action(), uncurated]); + let tmp = TempDir::new().unwrap(); + let tool = GetToolContractTool::new(test_config(&tmp)); + + // Uncurated action → runtime_gate present, FIRST in the payload, contract intact. + let result = tool + .execute(json!({ "slug": "SPOTIFY_OBSCURE_ACTION" })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + let out = result.output(); + assert!(out.contains("runtime_gate"), "{out}"); + assert!(out.contains("REJECTED on every real run"), "{out}"); + let gate_pos = out.find("runtime_gate").expect("runtime_gate key"); + let slug_pos = out.find("\"slug\"").expect("slug key"); + assert!( + gate_pos < slug_pos, + "runtime_gate must serialize first (agents read top-down): {out}" + ); + let parsed: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(parsed["slug"], "SPOTIFY_OBSCURE_ACTION"); + assert_eq!(parsed["is_curated"], false); + + // Curated action of the same toolkit → NO runtime_gate. + let result = tool + .execute(json!({ "slug": "SPOTIFY_START_PLAYBACK" })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + assert!( + !result.output().contains("runtime_gate"), + "{}", + result.output() + ); +} + +#[tokio::test] +async fn search_tool_catalog_flags_runtime_gated_uncurated_rows() { + let curated = ToolContract { + slug: "TELEGRAM_SEND_MESSAGE".to_string(), + toolkit: "telegram".to_string(), + description: Some("Send a message".to_string()), + required_args: vec![], + input_schema: None, + output_fields: vec![], + output_schema: None, + primary_array_path: None, + is_curated: true, + }; + let uncurated = ToolContract { + slug: "TELEGRAM_OBSCURE_SEND".to_string(), + is_curated: false, + ..curated.clone() + }; + seed_live_catalog_cache("telegram", vec![curated, uncurated]); + + let config = Config::default(); + let results = search_live_catalog(&config, "send", Some("telegram"), 40).await; + assert_eq!(results.len(), 2, "{results:?}"); + // Curated row: no `runtime_gated` key (only present when true). + let curated_row = results.iter().find(|r| r["featured"] == true).unwrap(); + assert!(curated_row.get("runtime_gated").is_none(), "{curated_row}"); + // Uncurated row of a curated toolkit: `runtime_gated: true`. + let uncurated_row = results.iter().find(|r| r["featured"] == false).unwrap(); + assert_eq!(uncurated_row["runtime_gated"], true); +} + +// ── WS5: per-token fallback ranking for zero-result multi-word queries ─────── +// +// Transcript failure: `search_tool_catalog` behaved like near-exact matching — +// multi-word natural-language queries ("twitter tweet replies lookup") returned +// `count: 0` even though the toolkit HAS matching actions, so the agent falsely +// concluded the action didn't exist. The primary pass is a strict case- +// insensitive AND (every token must match); when that misses for a multi-word +// query, a per-keyword OR fallback now returns the nearest matches + a note. + +fn twt_lookup() -> ToolContract { + ToolContract { + slug: "TWTFALLBACKTEST_TWEET_LOOKUP".to_string(), + toolkit: "twtfallbacktest".to_string(), + description: Some("Look up a tweet".to_string()), + required_args: vec!["id".to_string()], + input_schema: None, + output_fields: vec!["text".to_string()], + output_schema: None, + primary_array_path: None, + is_curated: true, + } +} + +fn twt_replies() -> ToolContract { + ToolContract { + slug: "TWTFALLBACKTEST_LIST_REPLIES".to_string(), + toolkit: "twtfallbacktest".to_string(), + description: Some("List replies to a tweet".to_string()), + required_args: vec!["tweet_id".to_string()], + input_schema: None, + output_fields: vec!["replies".to_string()], + output_schema: None, + primary_array_path: None, + is_curated: true, + } +} + +#[tokio::test] +async fn search_catalog_multiword_miss_falls_back_to_per_keyword() { + seed_live_catalog_cache("twtfallbacktest", vec![twt_lookup(), twt_replies()]); + let config = Config::default(); + // Strict AND misses ("twitter"/"timeline" match nothing) but individual + // tokens ("tweet", "replies", "lookup") hit — so the fallback fires. + let outcome = search_catalog( + &config, + "twitter tweet replies lookup timeline", + Some("twtfallbacktest"), + 40, + ) + .await; + assert!( + outcome.fallback, + "multi-word AND-miss must run the fallback" + ); + assert_eq!(outcome.results.len(), 2, "{:?}", outcome.results); + let note = outcome.note.expect("fallback carries an advisory note"); + assert!( + note.contains("nearest per-keyword"), + "note should explain the near-miss + single-keyword retry: {note}" + ); + // Fallback rows carry the SAME shape as primary rows. + for r in &outcome.results { + assert_eq!(r["toolkit"], "twtfallbacktest"); + assert_eq!(r["featured"], true); + assert!(r["required_args"].is_array()); + } +} + +#[tokio::test] +async fn search_tool_catalog_tool_surfaces_fallback_note_with_nonzero_count() { + seed_live_catalog_cache("twtfallbacktest", vec![twt_lookup(), twt_replies()]); + let tmp = TempDir::new().unwrap(); + let tool = SearchToolCatalogTool::new(test_config(&tmp)); + let result = tool + .execute(json!({ + "query": "twitter tweet replies lookup timeline", + "toolkit": "twtfallbacktest" + })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + // `count` reflects the returned rows (non-zero) so an agent never reads a + // fallback as "no such action". + assert_eq!(parsed["count"], 2); + assert!(parsed["results"].as_array().unwrap().len() == 2); + assert!(parsed["note"].as_str().unwrap().contains("No exact match")); +} + +#[tokio::test] +async fn search_catalog_single_word_behavior_unchanged() { + seed_live_catalog_cache("onewordtest", vec![twt_lookup()]); + let config = Config::default(); + // A hit: single-word query returns the primary match, no fallback, no note. + let hit = search_catalog(&config, "tweet", Some("onewordtest"), 40).await; + assert!(!hit.fallback); + assert!(hit.note.is_none()); + assert_eq!(hit.results.len(), 1); + // A miss: single-word query stays empty and does NOT run the fallback. + let miss = search_catalog(&config, "zzznomatchzzz", Some("onewordtest"), 40).await; + assert!( + !miss.fallback, + "single-token miss must not trigger fallback" + ); + assert!(miss.results.is_empty()); +} + +#[tokio::test] +async fn search_catalog_multiword_zero_token_match_returns_note() { + seed_live_catalog_cache("zerotoktest", vec![twt_lookup()]); + let config = Config::default(); + // Multi-word query where NO token matches anything: still a note (not a bare + // count: 0), but zero rows. + let outcome = search_catalog(&config, "qqq www eeeeee", Some("zerotoktest"), 40).await; + assert!(outcome.fallback, "multi-word miss ran the fallback pass"); + assert!(outcome.results.is_empty()); + let note = outcome + .note + .expect("zero-token multi-word miss still gets a note"); + assert!( + note.contains("keyword-based"), + "note should explain the keyword-based search: {note}" + ); +} + +#[tokio::test] +async fn search_catalog_fallback_rows_flag_runtime_gated() { + // Reuse the exact telegram seed of the runtime_gated primary test so a + // concurrent run over the shared cache stays self-consistent; telegram is a + // real curated toolkit, so its uncurated action is `runtime_gated`. + let curated = ToolContract { + slug: "TELEGRAM_SEND_MESSAGE".to_string(), + toolkit: "telegram".to_string(), + description: Some("Send a message".to_string()), + required_args: vec![], + input_schema: None, + output_fields: vec![], + output_schema: None, + primary_array_path: None, + is_curated: true, + }; + let uncurated = ToolContract { + slug: "TELEGRAM_OBSCURE_SEND".to_string(), + is_curated: false, + ..curated.clone() + }; + seed_live_catalog_cache("telegram", vec![curated, uncurated]); + + let config = Config::default(); + // "obscure" hits only the uncurated slug; "lookup"/"replies" hit nothing; + // "telegram" matches the toolkit of both — so strict AND misses and the + // fallback ranks the OBSCURE row first (2 hits) over SEND_MESSAGE (1 hit). + let outcome = search_catalog( + &config, + "telegram obscure lookup replies", + Some("telegram"), + 40, + ) + .await; + assert!(outcome.fallback); + assert_eq!(outcome.results.len(), 2, "{:?}", outcome.results); + let gated = outcome + .results + .iter() + .find(|r| r["featured"] == false) + .expect("uncurated row present"); + assert_eq!(gated["runtime_gated"], true); + let curated_row = outcome + .results + .iter() + .find(|r| r["featured"] == true) + .expect("curated row present"); + assert!(curated_row.get("runtime_gated").is_none()); +} + /// B12: a cached real-output probe overrides `get_tool_contract`'s /// schema-derived `primary_array_path`/`output_fields` — most relevant for a /// slug whose live listing (like every GitHub action, verified live) has NO @@ -587,6 +880,78 @@ async fn dry_run_exercises_agent_ref_node_via_mock_agent_runner() { ); } +#[tokio::test] +async fn dry_run_plain_agent_with_output_parser_schema_is_green() { + // Regression for the transcript false-failure: a builder-generated `agent` + // node carries NO `agent_ref`, so the vendored engine routes it to the + // `llm` slot (not the `AgentRunner`). Before `SchemaAwareMockLlm` the plain + // `MockLlm` echo (`{ completion, connection }`) failed the node's + // `output_parser.schema` sub-port with `output_parser: value failed schema + // validation after auto-fix: missing required property ...`, sinking a + // correctly-built graph. Now the mock LLM synthesizes a schema-valid object, + // and a downstream node binds the typed placeholders (non-null). + let tool = DryRunWorkflowTool::new( + policy(AutonomyLevel::Supervised), + test_config(&TempDir::new().unwrap()), + ); + let graph = json!({ + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Schedule", + "config": { "trigger_kind": "schedule" } }, + { "id": "a", "kind": "agent", "name": "Extract", + "config": { "prompt": "extract the fields", + "output_parser": { "schema": { "type": "object", + "required": ["subject", "priority", "recipients"], + "properties": { + "subject": { "type": "string" }, + "priority": { "type": "integer" }, + "recipients": { "type": "array" } + } } } } }, + // Downstream node binds the schema'd agent fields: proves the + // placeholders are addressable and resolve to typed (non-null) + // values, not the vendored echo's opaque `{ completion, ... }`. + { "id": "down", "kind": "transform", "name": "Route", + "config": { "set": { + "subject": "=nodes.a.item.json.subject", + "priority": "=nodes.a.item.json.priority", + "recipients": "=nodes.a.item.json.recipients" } } } + ], + "edges": [ + { "from_node": "t", "to_node": "a" }, + { "from_node": "a", "to_node": "down" } + ] + }); + let result = tool + .execute(json!({ "graph": graph, "input": { "topic": "launch" } })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + let out = result.output(); + assert!( + !out.to_lowercase().contains("schema validation"), + "plain agent with a valid schema must not hit the output_parser failure: {out}" + ); + let parsed: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(parsed["sandbox"], true); + assert_eq!( + parsed["ok"], true, + "plain-agent-with-schema dry-run must be green: {parsed}" + ); + // The agent envelope's `json` carries the schema-synthesized placeholders. + // (In the run OUTPUT each Item serializes as `{ json: }`, and the + // agent's value is the `{json,text,raw}` envelope — hence the double hop.) + let agent_json = &parsed["output"]["nodes"]["a"]["items"][0]["json"]["json"]; + assert_eq!(agent_json["subject"], "", "{parsed}"); + assert_eq!(agent_json["priority"], 0, "{parsed}"); + assert_eq!(agent_json["recipients"], json!([]), "{parsed}"); + // The downstream node's bindings resolved to those typed placeholders — + // none of them null. + let down_json = &parsed["output"]["nodes"]["down"]["items"][0]["json"]; + assert!(!down_json["subject"].is_null(), "{parsed}"); + assert_eq!(down_json["priority"], 0, "{parsed}"); + assert_eq!(down_json["recipients"], json!([]), "{parsed}"); +} + #[tokio::test] async fn dry_run_invalid_graph_is_error() { let tool = DryRunWorkflowTool::new( @@ -711,6 +1076,112 @@ async fn dry_run_flags_tool_call_arg_null_resolved_from_unschemad_agent() { ); } +#[tokio::test] +async fn dry_run_flags_composio_upstream_binding_as_unverifiable_not_a_wiring_bug() { + // WS6: `post`'s `body` binds to the OUTPUT of an upstream Composio + // `tool_call` (`get_me`). The echo sandbox renders `get_me` as + // `{tool, args, connection}` and can NEVER produce `.item.json.data.username`, + // so the binding resolves `null` here even when it's wired correctly. The + // dry run still fails (`ok: false` — a null could hide a typo), but the + // diagnostic must be HONEST: mark it `unverifiable` and point at + // get_tool_contract / get_tool_output_sample rather than telling the agent + // its (possibly-correct) wiring is broken — the exact false negative that + // sent the transcript agent re-wiring an already-correct binding 3 times. + // Seed bespoke toolkits (no other test touches `ws6up`/`ws6dl`) with NO + // required args, so the required-arg preflight passes and the run settles + // into the `null_resolutions` path deterministically — independent of the + // process-global catalog cache other tests seed for gmail/slack/etc. + seed_live_catalog_cache("ws6up", vec![seeded_ws6_contract("WS6UP_LOOKUP", "ws6up")]); + seed_live_catalog_cache("ws6dl", vec![seeded_ws6_contract("WS6DL_SEND", "ws6dl")]); + let tool = DryRunWorkflowTool::new( + policy(AutonomyLevel::Supervised), + test_config(&TempDir::new().unwrap()), + ); + let graph = json!({ + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Manual" }, + { "id": "get_me", "kind": "tool_call", "name": "Who am I", + "config": { "slug": "WS6UP_LOOKUP", "args": {} } }, + { "id": "post", "kind": "tool_call", "name": "Post", + "config": { "slug": "WS6DL_SEND", + "args": { "recipient_email": "a@b.com", "subject": "hi", + "body": "=nodes.get_me.item.json.data.username" } } } + ], + "edges": [ + { "from_node": "t", "to_node": "get_me" }, + { "from_node": "get_me", "to_node": "post" } + ] + }); + let result = tool.execute(json!({ "graph": graph })).await.unwrap(); + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["ok"], false, "{parsed}"); + let null_resolutions = parsed["null_resolutions"] + .as_array() + .expect("null_resolutions array"); + let entry = null_resolutions + .iter() + .find(|e| e["node_id"] == "post" && e["location"] == "args.body") + .unwrap_or_else(|| panic!("expected a post.body null resolution: {parsed}")); + assert_eq!(entry["unverifiable"], true, "{parsed}"); + assert_eq!(entry["upstream_tool_call"], "get_me", "{parsed}"); + let suggestion = entry["suggestion"].as_str().expect("suggestion string"); + assert!(suggestion.contains("UNVERIFIABLE"), "{suggestion}"); + assert!(suggestion.contains("get_tool_contract"), "{suggestion}"); + assert!( + suggestion.contains("get_tool_output_sample"), + "{suggestion}" + ); +} + +#[tokio::test] +async fn dry_run_keeps_generic_null_text_for_a_non_tool_call_upstream_binding() { + // WS6 contrast: `post`'s arg binds to a `transform` node's output (whose + // real output the echo sandbox DOES produce), and the transform never sets + // the referenced field, so the null IS a genuine wiring bug. This entry must + // stay the plain `{ node_id, location, expression }` shape — no + // `unverifiable` flag — so the honest-uncertainty treatment doesn't leak + // onto real mistakes. + seed_live_catalog_cache("ws6dl", vec![seeded_ws6_contract("WS6DL_SEND", "ws6dl")]); + let tool = DryRunWorkflowTool::new( + policy(AutonomyLevel::Supervised), + test_config(&TempDir::new().unwrap()), + ); + let graph = json!({ + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Manual" }, + { "id": "build", "kind": "transform", "name": "Build", + "config": { "set": { "unrelated": "x" } } }, + { "id": "post", "kind": "tool_call", "name": "Post", + "config": { "slug": "WS6DL_SEND", + "args": { "recipient_email": "a@b.com", "subject": "hi", + "body": "=nodes.build.item.json.missing" } } } + ], + "edges": [ + { "from_node": "t", "to_node": "build" }, + { "from_node": "build", "to_node": "post" } + ] + }); + let result = tool.execute(json!({ "graph": graph })).await.unwrap(); + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["ok"], false, "{parsed}"); + let entry = parsed["null_resolutions"] + .as_array() + .expect("null_resolutions array") + .iter() + .find(|e| e["node_id"] == "post" && e["location"] == "args.body") + .unwrap_or_else(|| panic!("expected a post.body null resolution: {parsed}")); + assert!( + entry.get("unverifiable").is_none(), + "a non-tool_call upstream must keep the generic diagnostic: {parsed}" + ); + assert!( + entry.get("suggestion").is_none(), + "generic entry carries no unverifiable suggestion: {parsed}" + ); +} + #[tokio::test] async fn dry_run_passes_when_agent_schema_matches_tool_call_binding() { // The FALSE-POSITIVE-PREVENTION case: `summarize` DOES declare a schema @@ -1501,6 +1972,98 @@ async fn edit_workflow_reports_failing_op_with_guidance() { assert!(out.contains("edit_workflow again"), "{out}"); } +#[tokio::test] +async fn edit_workflow_bad_op_reports_index_type_and_shape() { + let tmp = TempDir::new().unwrap(); + let tool = EditWorkflowTool::new(test_config(&tmp)); + // ops 0 and 1 are well-formed; op 2 is an add_node missing its `node`. + let result = tool + .execute(json!({ + "graph": valid_graph(), + "ops": [ + { "op": "set_node_name", "id": "a", "name": "One" }, + { "op": "set_node_name", "id": "a", "name": "Two" }, + { "op": "add_node", "id": "b" } + ] + })) + .await + .unwrap(); + assert!(result.is_error, "{}", result.output()); + let out = result.output(); + // Names the failing op index, its op type, and the expected shape for it. + assert!(out.contains("op 2"), "{out}"); + assert!(out.contains("add_node"), "{out}"); + assert!(out.contains("node:"), "expected add_node shape in: {out}"); + assert!(out.contains("edit_workflow again"), "{out}"); +} + +#[tokio::test] +async fn edit_workflow_missing_op_field_lists_valid_types() { + let tmp = TempDir::new().unwrap(); + let tool = EditWorkflowTool::new(test_config(&tmp)); + let result = tool + .execute(json!({ + "graph": valid_graph(), + "ops": [ { "id": "a", "name": "No op tag" } ] + })) + .await + .unwrap(); + assert!(result.is_error, "{}", result.output()); + let out = result.output(); + assert!(out.contains("op 0"), "{out}"); + assert!(out.contains("missing `op` field"), "{out}"); + assert!(out.contains("update_node_config"), "{out}"); +} + +#[tokio::test] +async fn edit_workflow_add_node_exists_carries_ordering_hint() { + let tmp = TempDir::new().unwrap(); + let tool = EditWorkflowTool::new(test_config(&tmp)); + // Re-adding an existing node id fails in-order; the hint should point at the + // remove-first / patch-in-place fix. + let result = tool + .execute(json!({ + "graph": valid_graph(), + "ops": [ + { "op": "add_node", "node": { "id": "a", "kind": "merge", "name": "Dup" } } + ] + })) + .await + .unwrap(); + assert!(result.is_error, "{}", result.output()); + let out = result.output(); + assert!(out.contains("already exists"), "{out}"); + assert!(out.contains("array order"), "{out}"); + assert!(out.contains("remove_node"), "{out}"); + assert!(out.contains("update_node_config"), "{out}"); +} + +#[tokio::test] +async fn edit_workflow_accepts_node_id_aliases_end_to_end() { + let tmp = TempDir::new().unwrap(); + let tool = EditWorkflowTool::new(test_config(&tmp)); + // A valid ops array using the `node_id` alias (the natural agent guess) + // applies cleanly through edit_workflow. + let result = tool + .execute(json!({ + "graph": valid_graph(), + "name": "Aliased edit", + "ops": [ + { "op": "update_node_config", "node_id": "a", "config": { "prompt": "aliased" } }, + { "op": "set_node_name", "node_id": "a", "name": "Aliased step" } + ] + })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["type"], "workflow_proposal"); + let nodes = parsed["graph"]["nodes"].as_array().unwrap(); + let agent = nodes.iter().find(|n| n["id"] == "a").unwrap(); + assert_eq!(agent["config"]["prompt"], "aliased"); + assert_eq!(agent["name"], "Aliased step"); +} + #[tokio::test] async fn edit_workflow_rejects_a_result_that_is_structurally_invalid() { let tmp = TempDir::new().unwrap(); @@ -1724,3 +2287,147 @@ fn phase4_write_tools_have_the_right_permissions() { PermissionLevel::None ); } + +// ── WS2: unified draft_id|flow_id|graph handles + explicit persistence state ── + +#[tokio::test] +async fn edit_workflow_by_flow_id_seeds_a_retrievable_draft_and_marks_unpersisted() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + // A saved flow to edit — editing it must NOT write onto the flow (the WS2 + // bug: a flow_id edit used to persist nothing and return no handle). + let flow = ops::flows_create(&config, "Base flow".to_string(), valid_graph(), false) + .await + .unwrap() + .value; + + let tool = EditWorkflowTool::new(config.clone()); + let result = tool + .execute(json!({ + "flow_id": flow.id, + "ops": [ { "op": "set_node_name", "id": "a", "name": "Renamed step" } ] + })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + + // The edit lives on a NEW draft, is explicitly NOT persisted, and echoes the + // flow it derives from plus a `next` hint naming the draft. + assert_eq!(parsed["persisted"], false); + assert_eq!(parsed["flow_id"], flow.id.as_str()); + let draft_id = parsed["draft_id"] + .as_str() + .expect("edit_workflow by flow_id returns a draft_id") + .to_string(); + assert!(parsed["next"].as_str().unwrap().contains(&draft_id)); + + // The draft is retrievable via ops::flows_draft_get and holds the EDITED + // graph, linked back to the source flow. + let draft = ops::flows_draft_get(&config, &draft_id).unwrap().value; + assert_eq!(draft.flow_id.as_deref(), Some(flow.id.as_str())); + let agent = draft.graph["nodes"] + .as_array() + .unwrap() + .iter() + .find(|n| n["id"] == "a") + .unwrap(); + assert_eq!(agent["name"], "Renamed step"); + + // The SAVED flow is untouched — the whole point of WS2. + let saved = ops::flows_get(&config, &flow.id).await.unwrap().value; + let saved_graph = serde_json::to_value(&saved.graph).unwrap(); + let saved_agent = saved_graph["nodes"] + .as_array() + .unwrap() + .iter() + .find(|n| n["id"] == "a") + .unwrap(); + assert_eq!( + saved_agent["name"], "Summarize", + "the flow must not be edited" + ); +} + +#[tokio::test] +async fn dry_run_workflow_by_flow_id_runs_the_saved_flow_graph() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = ops::flows_create(&config, "Runnable".to_string(), valid_graph(), false) + .await + .unwrap() + .value; + let tool = DryRunWorkflowTool::new(policy(AutonomyLevel::Supervised), config.clone()); + let result = tool.execute(json!({ "flow_id": flow.id })).await.unwrap(); + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["sandbox"], true); + assert_eq!(parsed["ok"], true); +} + +#[tokio::test] +async fn validate_workflow_by_draft_id_checks_the_draft_graph() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let draft = ops::flows_draft_create( + &config, + None, + "Draft".to_string(), + valid_graph(), + crate::openhuman::flows::DraftOrigin::Chat, + ) + .unwrap() + .value; + let tool = ValidateWorkflowTool::new(config.clone()); + let result = tool.execute(json!({ "draft_id": draft.id })).await.unwrap(); + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["ok"], true); + assert_eq!(parsed["structurally_valid"], true); +} + +#[tokio::test] +async fn save_workflow_by_draft_id_persists_the_draft_graph_onto_the_flow() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + // A flow seeded with a bare 1-node graph. + let flow_id = seed_flow(&config, "Blank flow").await; + // A draft holding the richer 2-node valid graph, linked to that flow. + let draft = ops::flows_draft_create( + &config, + Some(flow_id.clone()), + "Draft".to_string(), + valid_graph(), + crate::openhuman::flows::DraftOrigin::Chat, + ) + .unwrap() + .value; + + let tool = SaveWorkflowTool::new(config.clone()); + let result = tool + .execute(json!({ "flow_id": flow_id, "draft_id": draft.id })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["type"], "workflow_saved"); + assert_eq!(parsed["persisted"], true); + assert_eq!(parsed["node_count"], 2); + + // The draft's graph really landed on the flow. + let saved = ops::flows_get(&config, &flow_id).await.unwrap().value; + assert_eq!(saved.graph.nodes.len(), 2); +} + +#[tokio::test] +async fn revise_workflow_proposal_is_marked_unpersisted() { + let tmp = TempDir::new().unwrap(); + let tool = ReviseWorkflowTool::new(test_config(&tmp)); + let result = tool + .execute(json!({ "name": "R", "graph": valid_graph() })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["persisted"], false); +} diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index 9f5bca5b12..69b39cd5aa 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -142,6 +142,15 @@ pub(crate) async fn run_builder_gates(config: &Config, graph: &WorkflowGraph) -> if !binding_errors.is_empty() { return binding_errors; } + // Async, live connection list: a tool_call whose `connection_ref` names the + // wrong toolkit for its slug, or a connection id the user doesn't actually + // have (WS3 — the transcript bug where a TIKTOK connection id was wired onto + // Twitter/Gmail nodes and every author-time gate returned ok). Cheap: + // one connection-list fetch, no per-node catalog round trips. + let connection_ref_errors = validate_connection_refs(config, graph).await; + if !connection_ref_errors.is_empty() { + return connection_ref_errors; + } // Async, live catalog: a tool_call whose slug isn't a real Composio action // or whose real required args aren't all wired. let contract_errors = validate_tool_contracts(config, graph).await; @@ -192,11 +201,20 @@ pub(crate) async fn strict_gate(config: &Config, graph_json: &Value) -> Result<( /// the tool in the "fix … and call `` again" guidance so each caller's /// error text points the agent back at the right tool. /// +/// `draft_id` / `flow_id` are OPTIONAL persistence-state context echoed onto +/// the payload (the draft this proposal's edit lives on, and the saved flow it +/// derives from / targets). The payload ALWAYS carries `"persisted": false` so +/// a proposal can never be mistaken for a save confirmation — the exact false +/// belief the WS2 audit caught (an agent read a proposal as "written onto the +/// saved flow"). Actual persistence only happens via `save_workflow` / +/// `create_workflow` / `flows_draft_promote`. +/// /// Returns `Ok(payload)` on success, or `Err(message)` with a /// model-consumable, fix-and-retry error when a gate rejects the graph. The /// caller is responsible for structural validation (`validate_and_migrate_graph` /// / `validate_all`) *before* calling this — these gates assume a compilable /// graph. +#[allow(clippy::too_many_arguments)] pub(crate) async fn build_builder_proposal( config: &Config, retry_tool: &str, @@ -205,6 +223,8 @@ pub(crate) async fn build_builder_proposal( require_approval: bool, revision: bool, instruction: Option, + draft_id: Option, + flow_id: Option, ) -> Result { // The full builder hard-gate stack, run through the single canonical // runner so every proposal/save/strict-RPC path gates identically (F3). @@ -238,6 +258,10 @@ pub(crate) async fn build_builder_proposal( let mut payload = json!({ "type": "workflow_proposal", "revision": revision, + // A proposal is NEVER a persisted flow — it is a candidate the user + // still has to accept/save. Stamp this unconditionally so the payload + // can't be misread as a save confirmation (WS2 audit). + "persisted": false, "name": name, "graph": graph_value, "require_approval": require_approval, @@ -248,6 +272,14 @@ pub(crate) async fn build_builder_proposal( if let Some(instruction) = instruction { payload["instruction"] = json!(instruction); } + // Echo the persistence-state handles so the agent can iterate/persist + // against the right ids (the draft the edit lives on; the flow it targets). + if let Some(draft_id) = draft_id { + payload["draft_id"] = json!(draft_id); + } + if let Some(flow_id) = flow_id { + payload["flow_id"] = json!(flow_id); + } Ok(payload) } @@ -1102,10 +1134,23 @@ pub(crate) fn validate_binding_resolvability(graph: &WorkflowGraph) -> Vec bool { + use crate::openhuman::memory_sync::composio::providers::{catalog_for_toolkit, get_provider}; + get_provider(toolkit) + .and_then(|p| p.curated_tools()) + .or_else(|| catalog_for_toolkit(toolkit)) + .is_some() +} + pub(crate) async fn validate_tool_contracts(config: &Config, graph: &WorkflowGraph) -> Vec { - use crate::openhuman::memory_sync::composio::providers::{ - catalog_for_toolkit, get_provider, toolkit_from_slug, - }; + use crate::openhuman::memory_sync::composio::providers::toolkit_from_slug; use crate::openhuman::tinyflows::caps::{ fetch_live_toolkit_catalog, missing_required_args, unsupported_arg_names, }; @@ -1166,10 +1211,7 @@ pub(crate) async fn validate_tool_contracts(config: &Config, graph: &WorkflowGra // action on a curated toolkit and then fail every run with "tool // not permitted". Hold authoring to the same bar the runtime gate // enforces instead of loosening the runtime gate. - let has_static_catalog = get_provider(&toolkit) - .and_then(|p| p.curated_tools()) - .or_else(|| catalog_for_toolkit(&toolkit)) - .is_some(); + let has_static_catalog = toolkit_has_curated_catalog(&toolkit); if has_static_catalog && !contract.is_curated { tracing::warn!( target: "flows", @@ -1273,6 +1315,248 @@ pub(crate) async fn validate_tool_contracts(config: &Config, graph: &WorkflowGra errors } +// ───────────────────────────────────────────────────────────────────────────── +// Connection-ref gate (WS3): a Composio tool_call's `connection_ref` must name +// a real connected account of the RIGHT toolkit +// ───────────────────────────────────────────────────────────────────────────── +// +// Transcript audit: the user's connections were `twitter → +// composio:twitter:ca_JX6QU88UfSk4`, `gmail → composio:gmail:ca_vX_WA8FsqNmE`, +// `tiktok → composio:tiktok:ca_LPCp3WQpaDma`. The agent wired +// `composio:twitter:ca_LPCp3WQpaDma` and `composio:gmail:ca_LPCp3WQpaDma` (the +// TIKTOK id) onto the Twitter and Gmail tool_call nodes. dry_run / validate / +// propose all returned ok:true — nothing cross-checked the id against the user's +// real connections, nor the ref's toolkit segment against the slug — and it +// would fail on the first real run. This gate closes that gap: it parses the +// ref, enforces the toolkit segment matches the slug (needs no I/O), and — when +// the live connection list is reachable — that the id names a real connected +// account of that toolkit, naming the correct ref when it can. + +/// Parses a `composio::` connection_ref into its `(toolkit, id)` +/// segments. Mirrors [`crate::openhuman::tinyflows::caps::composio_connection_id`]'s +/// rsplit for the id (everything after the LAST `:`), taking everything between +/// the `composio:` prefix and that last `:` as the toolkit. Returns `None` for +/// anything that isn't this shape (missing `composio:` prefix, no `:` after it, +/// or an empty toolkit/id segment). +fn parse_composio_connection_ref(conn_ref: &str) -> Option<(&str, &str)> { + let rest = conn_ref.strip_prefix("composio:")?; + let (toolkit, id) = rest.rsplit_once(':')?; + if toolkit.trim().is_empty() || id.trim().is_empty() { + return None; + } + Some((toolkit.trim(), id.trim())) +} + +/// First connected account `connection_ref` for `toolkit` (case-insensitive) +/// from `conns`, used to name the correct ref in a rejection's "did you mean" +/// hint. `None` when the toolkit has no connection at all. +fn first_connection_ref_for_toolkit(conns: &[FlowConnection], toolkit: &str) -> Option { + conns + .iter() + .find(|c| { + c.toolkit + .as_deref() + .is_some_and(|t| t.eq_ignore_ascii_case(toolkit)) + }) + .map(|c| c.connection_ref.clone()) +} + +/// Hard gate: for every Composio `tool_call` node carrying a `connection_ref`, +/// prove the ref names a real connected account of the SAME toolkit as the +/// slug. Fetches the live connection list once (same source +/// [`flows_list_connections`] reads) and delegates the pure matching to +/// [`validate_connection_refs_against`]. +/// +/// Fail-open on I/O: if the Composio connection list is unreachable (backend +/// outage), the id-existence check is SKIPPED (a `tracing::debug!` records it) +/// so a real connection is never false-rejected during an outage — but the +/// toolkit-mismatch check, which needs no I/O, still runs. +pub(crate) async fn validate_connection_refs( + config: &Config, + graph: &WorkflowGraph, +) -> Vec { + let connections: Option> = + match crate::openhuman::composio::ops::composio_list_connections(config).await { + Ok(outcome) => Some(build_flow_connections( + outcome.value.connections, + Vec::new(), + )), + Err(e) => { + tracing::debug!( + target: "flows", + error = %e, + "[flows] connection-ref check: composio connection list unavailable — \ + skipping id-existence check (fail-open); toolkit-mismatch check still runs" + ); + None + } + }; + validate_connection_refs_against(graph, connections.as_deref()) +} + +/// Pure connection-ref validator (no I/O) so the gate's decision logic is +/// unit-testable without a live Composio backend. `connections` is `Some(list)` +/// when the live connection list was fetched (possibly empty — a genuine "no +/// connections" state), or `None` when it was unavailable (fail-open: the +/// id-existence check is skipped, only the toolkit-mismatch check runs). +fn validate_connection_refs_against( + graph: &WorkflowGraph, + connections: Option<&[FlowConnection]>, +) -> Vec { + use crate::openhuman::memory_sync::composio::providers::toolkit_from_slug; + + let mut errors = Vec::new(); + for node in &graph.nodes { + if node.kind != NodeKind::ToolCall { + continue; + } + let Some(slug) = node.config.get("slug").and_then(Value::as_str) else { + continue; + }; + // `=`-derived slugs resolve at runtime; native `oh:` tools have no + // Composio connection to name. + if slug.starts_with('=') || slug.starts_with("oh:") { + continue; + } + // A MISSING `connection_ref` stays allowed (unchanged): a Composio + // tool_call with no ref runs against the ambient signed-in account and + // the flow prompts for a connection at first run. + let Some(conn_ref) = node.config.get("connection_ref").and_then(Value::as_str) else { + continue; + }; + if conn_ref.trim().is_empty() { + continue; + } + let Some(slug_toolkit) = toolkit_from_slug(slug) else { + continue; + }; + + let Some((ref_toolkit, ref_id)) = parse_composio_connection_ref(conn_ref) else { + tracing::debug!( + target: "flows", + node = %node.id, + %slug, + toolkit = %slug_toolkit, + %conn_ref, + matched = false, + "[flows] connection-ref check: malformed ref — rejecting" + ); + errors.push(format!( + "Node '{}': `connection_ref` `{conn_ref}` is malformed — a Composio account ref \ + must look like `composio::` (e.g. \ + `composio:{slug_toolkit}:`). Call list_flow_connections and copy a \ + `connection_ref` value verbatim.", + node.id + )); + continue; + }; + + // Toolkit segment vs the slug's toolkit — needs no I/O. + if !ref_toolkit.eq_ignore_ascii_case(&slug_toolkit) { + let suggestion = connections + .and_then(|conns| first_connection_ref_for_toolkit(conns, &slug_toolkit)); + tracing::debug!( + target: "flows", + node = %node.id, + %slug, + toolkit = %slug_toolkit, + %ref_toolkit, + %ref_id, + matched = false, + "[flows] connection-ref check: toolkit segment does not match the slug's toolkit — rejecting" + ); + let hint = match suggestion { + Some(r) => format!(" — did you mean `{r}`?"), + None => format!( + " — no `{slug_toolkit}` account is connected; connect one with \ + composio_connect (or ask the user to), then use its `connection_ref`" + ), + }; + errors.push(format!( + "Node '{}': `connection_ref` `{conn_ref}` names the `{ref_toolkit}` toolkit but the \ + tool_call slug `{slug}` is a `{slug_toolkit}` action{hint}.", + node.id + )); + continue; + } + + // Existence check: the id must name a real connected account of this + // toolkit. Skipped (fail-open) when the connection list is unavailable. + let Some(conns) = connections else { + tracing::debug!( + target: "flows", + node = %node.id, + %slug, + toolkit = %slug_toolkit, + %ref_id, + "[flows] connection-ref check: toolkit matches; id-existence check skipped (connections unavailable)" + ); + continue; + }; + // The id must belong to a connection OF THIS TOOLKIT — not merely + // exist somewhere. The transcript bug was a real TIKTOK connection id + // stamped onto a `composio:twitter:` ref: the id exists globally, but + // it is not a Twitter account, so it must still be rejected. + let id_exists = conns.iter().any(|c| { + c.toolkit + .as_deref() + .is_some_and(|t| t.eq_ignore_ascii_case(&slug_toolkit)) + && parse_composio_connection_ref(&c.connection_ref) + .is_some_and(|(_, cid)| cid.eq_ignore_ascii_case(ref_id)) + }); + if id_exists { + tracing::debug!( + target: "flows", + node = %node.id, + %slug, + toolkit = %slug_toolkit, + %ref_id, + matched = true, + "[flows] connection-ref check: ref resolves to a real connected account — ok" + ); + continue; + } + // Unknown id. Name the right ref for this toolkit if one exists. + match first_connection_ref_for_toolkit(conns, &slug_toolkit) { + Some(r) => { + tracing::debug!( + target: "flows", + node = %node.id, + %slug, + toolkit = %slug_toolkit, + %ref_id, + matched = false, + "[flows] connection-ref check: unknown id; toolkit has a different connected account — rejecting" + ); + errors.push(format!( + "Node '{}': `connection_ref` `{conn_ref}` does not match any connected \ + `{slug_toolkit}` account — did you mean `{r}`? Call list_flow_connections and \ + copy a `connection_ref` value verbatim.", + node.id + )); + } + None => { + tracing::debug!( + target: "flows", + node = %node.id, + %slug, + toolkit = %slug_toolkit, + %ref_id, + matched = false, + "[flows] connection-ref check: no connected account for this toolkit — rejecting" + ); + errors.push(format!( + "Node '{}': `connection_ref` `{conn_ref}` names a `{slug_toolkit}` account, but \ + no `{slug_toolkit}` account is connected — connect one with composio_connect \ + (or ask the user to), then use its `connection_ref`.", + node.id + )); + } + } + } + errors +} + // ───────────────────────────────────────────────────────────────────────────── // Required-arg resolvability gate (issue B18) // ───────────────────────────────────────────────────────────────────────────── @@ -1348,13 +1632,19 @@ const REQUIRED_ARG_NULL_CHECK_TIMEOUT_SECS: u64 = 15; /// check only ever adds a diagnostic the sandbox actually observed. pub(crate) async fn validate_required_arg_resolvability(graph: &WorkflowGraph) -> Vec { use crate::openhuman::flows::builder_tools::CapturingObserver; - use crate::openhuman::tinyflows::caps::SchemaAwareMockAgentRunner; + use crate::openhuman::tinyflows::caps::{SchemaAwareMockAgentRunner, SchemaAwareMockLlm}; let Ok(compiled) = tinyflows::compiler::compile(graph) else { return Vec::new(); }; - let caps = tinyflows::caps::mock::mock_capabilities_with_agent(SchemaAwareMockAgentRunner); + let mut caps = tinyflows::caps::mock::mock_capabilities_with_agent(SchemaAwareMockAgentRunner); + // Same fix as `DryRunWorkflowTool`: a plain agent node (no `agent_ref`) + // routes to the `llm` slot, not the runner above, so the vendored `MockLlm` + // echo would fail its `output_parser.schema` sub-port and make this gate + // reject a correct graph (which is why `propose_workflow` was rejecting + // valid graphs). The schema-aware mock LLM honors the schema instead. + caps.llm = Arc::new(SchemaAwareMockLlm); let observer = Arc::new(CapturingObserver::default()); let observer_dyn: Arc = observer.clone(); @@ -1426,6 +1716,34 @@ pub(crate) async fn validate_required_arg_resolvability(graph: &WorkflowGraph) - ); continue; } + // A null bound to the OUTPUT of an upstream Composio `tool_call` + // node is UNVERIFIABLE in this echo sandbox — the mock renders a + // Composio `tool_call` as `{tool, args, connection}` and can NEVER + // produce its real output fields (`.item.json.data.`), so a + // downstream binding to one resolves `null` here even when the + // wiring is perfectly correct. Hard-rejecting it (WS6) would block + // a possibly-correct graph from ever being proposed — the exact + // false-negative the transcript audit caught. Downgrade to a + // debug-logged skip; `dry_run_workflow` remains the surface that + // reports it (as an `unverifiable` diagnostic the agent can act on + // via get_tool_contract / get_tool_output_sample). + if let Some(upstream) = + composio_tool_call_upstream_ref(&diag.expression, graph, &step.node_id) + { + tracing::debug!( + target: "flows", + node = %step.node_id, + %slug, + %field, + upstream = %upstream, + expression = %diag.expression, + "[flows] required-arg resolvability check: arg binds to a Composio \ + tool_call's output — UNVERIFIABLE in the echo sandbox (the mock cannot \ + produce real tool output fields), not rejecting; dry_run_workflow \ + reports it instead" + ); + continue; + } tracing::warn!( target: "flows", node = %step.node_id, @@ -1541,6 +1859,72 @@ fn is_trigger_scoped_expression( predecessors.peek().is_some() && predecessors.all(|e| e.from_node == trigger_id) } +/// If a null-resolved config expression on `node_id` is bound to the OUTPUT of +/// an upstream **Composio `tool_call`** node (a `tool_call` whose `slug` is a +/// real Composio action — not `=`-derived, not native `oh:`), returns that +/// upstream node's id; otherwise `None`. +/// +/// The dry-run / gate sandbox renders a Composio `tool_call` as a deterministic +/// echo (`{tool, args, connection}`) and can NEVER produce its real output +/// fields, so a downstream binding to `.item.json.data.` off such a node +/// resolves `null` in the sandbox **even when the wiring is correct** — the +/// binding is UNVERIFIABLE here, not necessarily broken. Callers use this to +/// tell that honest-uncertainty case apart from a genuinely broken binding +/// (one wired to an `agent` / `transform` / `code` / trigger upstream, whose +/// real output the sandbox DOES produce, so a null there IS a real bug). +/// +/// Handles both addressing forms the engine can trace: +/// - explicit `=nodes....` / `=.nodes[""]...` (parsed via +/// [`explicit_nodes_ref`]), and +/// - implicit `=item...` / `=items...`, resolved against `node_id`'s direct +/// predecessor — but only when there is exactly ONE incoming edge, so an +/// ambiguous fan-in is never mis-attributed to a single upstream node. +/// +/// Anything else (a `=run...` trigger reference, a jq expression not rooted at +/// one of the above, or a reference to a non-`tool_call` / native / dynamic +/// node) returns `None`. +pub(crate) fn composio_tool_call_upstream_ref<'a>( + expr: &str, + graph: &'a WorkflowGraph, + node_id: &str, +) -> Option<&'a str> { + let referenced_id: String = if let Some(id) = explicit_nodes_ref(expr) { + id.to_string() + } else { + let body = expr.strip_prefix('=').unwrap_or(expr).trim(); + let body = body.strip_prefix('.').unwrap_or(body); + let is_item_scoped = body == "item" + || body.starts_with("item.") + || body.starts_with("item[") + || body == "items" + || body.starts_with("items.") + || body.starts_with("items["); + if !is_item_scoped { + return None; + } + let mut preds = graph + .edges + .iter() + .filter(|e| e.to_node == node_id) + .map(|e| e.from_node.as_str()); + let first = preds.next()?; + if preds.next().is_some() { + // Ambiguous fan-in — cannot attribute the null to one upstream node. + return None; + } + first.to_string() + }; + let node = graph.nodes.iter().find(|n| n.id == referenced_id)?; + if node.kind != NodeKind::ToolCall { + return None; + } + let slug = node.config.get("slug").and_then(Value::as_str)?; + if slug.starts_with('=') || slug.starts_with("oh:") { + return None; + } + Some(node.id.as_str()) +} + /// Validates a candidate graph without persisting it — the same /// migrate/validate path `flows_create` and `ProposeWorkflowTool` use — and /// reports structural errors alongside non-fatal trigger warnings diff --git a/src/openhuman/flows/ops_tests.rs b/src/openhuman/flows/ops_tests.rs index 4349f0523f..27731b6374 100644 --- a/src/openhuman/flows/ops_tests.rs +++ b/src/openhuman/flows/ops_tests.rs @@ -2059,6 +2059,162 @@ async fn validate_tool_contracts_passes_a_fully_wired_real_slug() { assert!(errors.is_empty(), "{errors:?}"); } +// ── validate_connection_refs (WS3) ────────────────────────────────────────── +// +// The transcript bug: the user's connections were twitter → +// `composio:twitter:ca_JX6QU88UfSk4`, gmail → `composio:gmail:ca_vX_WA8FsqNmE`, +// tiktok → `composio:tiktok:ca_LPCp3WQpaDma`. The agent wired +// `composio:twitter:ca_LPCp3WQpaDma` (the TIKTOK id) onto a Twitter node and +// every author-time gate returned ok. These tests exercise the pure matcher so +// no live Composio backend is touched. + +/// Build a composio `FlowConnection` fixture (the exact shape +/// `build_flow_connections` produces). +fn ws3_flow_conn(toolkit: &str, id: &str) -> FlowConnection { + FlowConnection { + connection_ref: format!("composio:{toolkit}:{id}"), + kind: "composio".to_string(), + display: toolkit.to_string(), + toolkit: Some(toolkit.to_string()), + scheme: None, + } +} + +/// The user's real connected set from the transcript. +fn ws3_transcript_connections() -> Vec { + vec![ + ws3_flow_conn("twitter", "ca_JX6QU88UfSk4"), + ws3_flow_conn("gmail", "ca_vX_WA8FsqNmE"), + ws3_flow_conn("tiktok", "ca_LPCp3WQpaDma"), + ] +} + +/// A single tool_call node graph with `slug` + optional `connection_ref`. +fn ws3_tool_call_graph(slug: &str, connection_ref: Option<&str>) -> WorkflowGraph { + let mut config = json!({ "slug": slug, "args": {} }); + if let Some(cr) = connection_ref { + config["connection_ref"] = json!(cr); + } + graph(json!({ + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Manual" }, + { "id": "act", "kind": "tool_call", "name": "Act", "config": config } + ], + "edges": [ { "from_node": "t", "to_node": "act" } ] + })) +} + +#[test] +fn connection_refs_reject_the_transcript_wrong_id_naming_the_right_ref() { + // Twitter node carrying the TIKTOK connection id: toolkit segment matches + // (twitter == twitter) but the id belongs to no Twitter account. + let g = ws3_tool_call_graph( + "TWITTER_CREATION_OF_A_POST", + Some("composio:twitter:ca_LPCp3WQpaDma"), + ); + let conns = ws3_transcript_connections(); + let errors = validate_connection_refs_against(&g, Some(&conns)); + assert_eq!(errors.len(), 1, "{errors:?}"); + assert!(errors[0].contains("act"), "{}", errors[0]); + assert!( + errors[0].contains("composio:twitter:ca_JX6QU88UfSk4"), + "must name the correct ref verbatim: {}", + errors[0] + ); + assert!(errors[0].contains("did you mean"), "{}", errors[0]); +} + +#[test] +fn connection_refs_reject_a_toolkit_mismatch_naming_the_right_ref() { + // A literal `composio:tiktok:...` ref stamped onto a Twitter node. + let g = ws3_tool_call_graph( + "TWITTER_CREATION_OF_A_POST", + Some("composio:tiktok:ca_LPCp3WQpaDma"), + ); + let conns = ws3_transcript_connections(); + let errors = validate_connection_refs_against(&g, Some(&conns)); + assert_eq!(errors.len(), 1, "{errors:?}"); + assert!(errors[0].contains("tiktok"), "{}", errors[0]); + assert!( + errors[0].contains("composio:twitter:ca_JX6QU88UfSk4"), + "{}", + errors[0] + ); +} + +#[test] +fn connection_refs_reject_an_unknown_id_when_the_toolkit_has_no_connection() { + // Gmail slug, but no gmail account connected at all → point at composio_connect. + let g = ws3_tool_call_graph("GMAIL_SEND_EMAIL", Some("composio:gmail:ca_missing")); + let conns = vec![ws3_flow_conn("twitter", "ca_JX6QU88UfSk4")]; + let errors = validate_connection_refs_against(&g, Some(&conns)); + assert_eq!(errors.len(), 1, "{errors:?}"); + assert!(errors[0].contains("composio_connect"), "{}", errors[0]); + assert!(!errors[0].contains("did you mean"), "{}", errors[0]); +} + +#[test] +fn connection_refs_pass_the_correct_ref() { + let g = ws3_tool_call_graph( + "TWITTER_CREATION_OF_A_POST", + Some("composio:twitter:ca_JX6QU88UfSk4"), + ); + let conns = ws3_transcript_connections(); + let errors = validate_connection_refs_against(&g, Some(&conns)); + assert!(errors.is_empty(), "{errors:?}"); +} + +#[test] +fn connection_refs_reject_a_malformed_ref() { + let g = ws3_tool_call_graph("GMAIL_SEND_EMAIL", Some("gmail-ca_vX_WA8FsqNmE")); + let conns = ws3_transcript_connections(); + let errors = validate_connection_refs_against(&g, Some(&conns)); + assert_eq!(errors.len(), 1, "{errors:?}"); + assert!(errors[0].contains("malformed"), "{}", errors[0]); +} + +#[test] +fn connection_refs_skip_oh_and_refless_and_expression_nodes() { + // Native oh: tool with a ref → skipped. + let g_oh = ws3_tool_call_graph("oh:memory_search", Some("composio:twitter:whatever")); + assert!( + validate_connection_refs_against(&g_oh, Some(&ws3_transcript_connections())).is_empty() + ); + // Composio tool_call with NO connection_ref stays allowed (prompts at run). + let g_refless = ws3_tool_call_graph("TWITTER_CREATION_OF_A_POST", None); + assert!( + validate_connection_refs_against(&g_refless, Some(&ws3_transcript_connections())) + .is_empty() + ); + // `=`-derived slug → skipped. + let g_expr = ws3_tool_call_graph("=item.slug", Some("composio:twitter:ca_LPCp3WQpaDma")); + assert!( + validate_connection_refs_against(&g_expr, Some(&ws3_transcript_connections())).is_empty() + ); +} + +#[test] +fn connection_refs_fail_open_on_unavailable_connections_but_keep_mismatch() { + // Connections unavailable (None): the id-existence check is SKIPPED — a + // toolkit-matched ref with an unknown id passes rather than false-reject. + let g_ok = ws3_tool_call_graph( + "TWITTER_CREATION_OF_A_POST", + Some("composio:twitter:ca_anything"), + ); + assert!( + validate_connection_refs_against(&g_ok, None).is_empty(), + "unknown id must be skipped when connections are unavailable" + ); + // ...but the toolkit-mismatch check needs no I/O and still fires. + let g_mismatch = ws3_tool_call_graph( + "TWITTER_CREATION_OF_A_POST", + Some("composio:tiktok:ca_LPCp3WQpaDma"), + ); + let errors = validate_connection_refs_against(&g_mismatch, None); + assert_eq!(errors.len(), 1, "{errors:?}"); + assert!(errors[0].contains("tiktok"), "{}", errors[0]); +} + // ── validate_required_arg_resolvability (issue B18) ───────────────────────── // // `validate_tool_contracts`'s `missing_required_args` only proves an arg is @@ -2190,6 +2346,95 @@ async fn validate_required_arg_resolvability_rejects_an_explicit_nodes_reference assert!(errors[0].contains("nodes.build_body"), "{}", errors[0]); } +/// A required tool arg wired to a PLAIN agent node's (`no agent_ref`) +/// `output_parser.schema` field must pass this sandbox gate: the schema-aware +/// mock LLM (wired above via `caps.llm = SchemaAwareMockLlm`) synthesizes a +/// schema-valid completion, so the agent's output-parser sub-port succeeds and +/// the downstream `=nodes..item.json.` binding resolves to a typed +/// placeholder (non-null) instead of the run aborting on a schema-validation +/// failure. Without the mock LLM this gate would sink `propose_workflow`/`save` +/// on a correctly-built graph (the vendored `MockLlm` echo fails the sub-port). +#[tokio::test] +async fn validate_required_arg_resolvability_accepts_a_schema_agent_field_binding() { + let g = graph(json!({ + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Manual" }, + { "id": "summarize", "kind": "agent", "name": "Summarize", + "config": { "prompt": "summarize the thread", + "output_parser": { "schema": { "type": "object", + "required": ["channel"], + "properties": { "channel": { "type": "string" } } } } } }, + { "id": "post", "kind": "tool_call", "name": "Post", + "config": { "slug": "SLACK_SEND_MESSAGE", + "args": { "channel": "=nodes.summarize.item.json.channel" } } } + ], + "edges": [ + { "from_node": "t", "to_node": "summarize" }, + { "from_node": "summarize", "to_node": "post" } + ] + })); + let errors = validate_required_arg_resolvability(&g).await; + assert!(errors.is_empty(), "{errors:?}"); +} + +/// WS6: a required arg wired to the OUTPUT of an upstream Composio `tool_call` +/// must NOT be hard-rejected by this gate. The echo sandbox renders a Composio +/// `tool_call` as `{tool, args, connection}` and can never produce its real +/// output fields, so `=nodes..item.json.data.` resolves `null` +/// here even when the wiring is perfectly correct — rejecting it would block a +/// possibly-correct graph from ever being proposed (the transcript false +/// negative). Contrast `..._rejects_an_explicit_nodes_reference` above, where +/// the same explicit-`nodes` form addresses a `code` node (whose real output +/// the sandbox DOES produce) and stays a hard reject. +#[tokio::test] +async fn validate_required_arg_resolvability_downgrades_a_composio_tool_call_upstream_binding() { + let g = graph(json!({ + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Manual" }, + { "id": "get_me", "kind": "tool_call", "name": "Who am I", + "config": { "slug": "TWITTER_USER_LOOKUP_ME", "args": {} } }, + { "id": "post", "kind": "tool_call", "name": "Post", + "config": { "slug": "GMAIL_SEND_EMAIL", + "args": { "recipient_email": "a@b.com", "subject": "hi", + "body": "=nodes.get_me.item.json.data.username" } } } + ], + "edges": [ + { "from_node": "t", "to_node": "get_me" }, + { "from_node": "get_me", "to_node": "post" } + ] + })); + let errors = validate_required_arg_resolvability(&g).await; + assert!( + errors.is_empty(), + "a binding to a Composio tool_call's output is UNVERIFIABLE, not a hard reject: {errors:?}" + ); +} + +/// WS6 companion: the implicit `=item...` form of the same case — `post`'s only +/// predecessor is a Composio `tool_call`, so `=item.json.data.username` +/// addresses that node's (echo-only) output and is likewise unverifiable, not a +/// reject. +#[tokio::test] +async fn validate_required_arg_resolvability_downgrades_an_item_scoped_composio_upstream_binding() { + let g = graph(json!({ + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Manual" }, + { "id": "get_me", "kind": "tool_call", "name": "Who am I", + "config": { "slug": "TWITTER_USER_LOOKUP_ME", "args": {} } }, + { "id": "post", "kind": "tool_call", "name": "Post", + "config": { "slug": "GMAIL_SEND_EMAIL", + "args": { "recipient_email": "a@b.com", "subject": "hi", + "body": "=item.json.data.username" } } } + ], + "edges": [ + { "from_node": "t", "to_node": "get_me" }, + { "from_node": "get_me", "to_node": "post" } + ] + })); + let errors = validate_required_arg_resolvability(&g).await; + assert!(errors.is_empty(), "{errors:?}"); +} + /// (Codex feedback on this PR) `notion` ships a static curated catalog /// (`catalog_for_toolkit`), so at RUNTIME `flow_tool_allowed`'s Path A /// hard-rejects any slug `find_curated` doesn't recognize — even a real, diff --git a/src/openhuman/flows/tools.rs b/src/openhuman/flows/tools.rs index 8a36cf5e99..20eb1278d8 100644 --- a/src/openhuman/flows/tools.rs +++ b/src/openhuman/flows/tools.rs @@ -261,6 +261,10 @@ impl Tool for ProposeWorkflowTool { Ok(ToolResult::success(serde_json::to_string_pretty(&json!({ "type": "workflow_proposal", + // A proposal is never a persisted flow — stamp it so the payload + // can't be misread as a save confirmation (WS2 audit). Matches + // `ops::build_builder_proposal`'s unconditional persisted:false. + "persisted": false, "name": name, "graph": graph_value, "require_approval": require_approval, diff --git a/src/openhuman/flows/tools_tests.rs b/src/openhuman/flows/tools_tests.rs index 3ce228532e..59912f2fcf 100644 --- a/src/openhuman/flows/tools_tests.rs +++ b/src/openhuman/flows/tools_tests.rs @@ -58,6 +58,9 @@ async fn valid_graph_returns_workflow_proposal_success() { assert_eq!(parsed["type"], "workflow_proposal"); assert_eq!(parsed["name"], "Daily standup summary"); assert_eq!(parsed["graph"]["nodes"].as_array().unwrap().len(), 3); + // A proposal is never a persisted flow — the payload must say so (WS2) so + // an agent can't misread it as a save confirmation. + assert_eq!(parsed["persisted"], false); } #[tokio::test] diff --git a/src/openhuman/tinyflows/caps.rs b/src/openhuman/tinyflows/caps.rs index 486fb9f864..12d37105a7 100644 --- a/src/openhuman/tinyflows/caps.rs +++ b/src/openhuman/tinyflows/caps.rs @@ -1200,6 +1200,71 @@ impl AgentRunner for SchemaAwareMockAgentRunner { } } +/// A **dry-run-only** [`LlmProvider`] mock that, unlike the vendored crate's +/// `tinyflows::caps::mock::MockLlm`, respects an `agent` node's +/// `config.output_parser.schema` when synthesizing its completion. +/// +/// This closes the OTHER half of the same gap [`SchemaAwareMockAgentRunner`] +/// closes. The vendored `agent` node only routes to an [`AgentRunner`] when the +/// node carries a **non-empty `agent_ref`** AND the host wired an agent registry +/// (`vendor/tinyflows/src/nodes/integration/agent.rs`, `run_turn`: +/// `(Some(agent_ref), Some(runner)) => runner.run_agent(...)`); **every other +/// case** — and builder-generated agent nodes carry NO `agent_ref` — falls back +/// to `ctx.caps.llm.complete(cfg.clone(), conn)`. So in the sandbox those plain +/// agent nodes never reach `SchemaAwareMockAgentRunner` at all: they hit the +/// `llm` slot, which (with the vendored `MockLlm`) echoes +/// `{ "completion": , "connection": }`. The agent node's +/// output-parser sub-port then validates that echo against the declared schema +/// (`schema::parse_and_validate` — it validates the WHOLE completion value, not +/// a `.text` field), no field matches, and it falls to a one-shot LLM auto-fix +/// that the same `MockLlm` also can't satisfy — so the dry run errors with +/// `output_parser: value failed schema validation after auto-fix: missing +/// required property ...` even for a workflow a real run would execute cleanly. +/// This false-failure burned many dry-run cycles for correctly-built graphs. +/// +/// When `request` (the node config the node hands to `complete` — see the +/// `_ => ctx.caps.llm.complete(cfg.clone(), conn)` arm above) carries a non-null +/// `output_parser.schema`, this returns [`placeholder_for_schema`] DIRECTLY. +/// The sub-port receives that already-schema-valid object as its `value` +/// (`validate` returns no errors), so it returns `Ok` WITHOUT ever invoking the +/// auto-fix LLM path — exactly the shape the vendored validator's +/// `type`/`required`/`enum` checks accept, with no real model call. With no +/// schema, it mirrors the vendored `MockLlm` echo shape byte-for-byte +/// (`{ "completion": request, "connection": conn }`) so schema-less agent +/// dry-run behavior — and downstream `=nodes..item.json.completion...` +/// bindings — stay identical to today. +#[derive(Debug, Default, Clone)] +pub struct SchemaAwareMockLlm; + +#[async_trait] +impl LlmProvider for SchemaAwareMockLlm { + async fn complete(&self, request: Value, conn: Option<&str>) -> Result { + let schema = request + .get("output_parser") + .and_then(|parser| parser.get("schema")) + .filter(|schema| !schema.is_null()); + match schema { + Some(schema) => { + let placeholder = placeholder_for_schema(schema); + tracing::debug!( + target: "flows", + "[flows] dry_run: schema-aware mock LLM synthesized a placeholder \ + matching output_parser.schema (plain agent node, no agent_ref)" + ); + Ok(placeholder) + } + None => { + tracing::debug!( + target: "flows", + "[flows] dry_run: schema-aware mock LLM has no output_parser.schema — \ + mirroring the vendored MockLlm echo shape" + ); + Ok(json!({ "completion": request, "connection": conn })) + } + } + } +} + /// Builds a placeholder JSON value satisfying `schema`'s `properties`/`type` /// constraints, for [`SchemaAwareMockAgentRunner`]. Only the shallow, top-level /// `properties` map is populated — enough for the minimal validator in @@ -3578,6 +3643,62 @@ mod tests { assert_eq!(out["request"], request); } + // ── SchemaAwareMockLlm ─────────────────────────────────────────────────── + + #[tokio::test] + async fn schema_aware_mock_llm_mirrors_vendored_echo_without_a_schema() { + // No `output_parser.schema`: byte-identical to the vendored `MockLlm` + // so schema-less agent dry runs (which route to the `llm` slot, not the + // runner) keep today's `{ completion, connection }` shape. + let llm = SchemaAwareMockLlm; + let request = json!({ "prompt": "hi" }); + let out = llm + .complete(request.clone(), Some("conn_1")) + .await + .expect("complete"); + assert_eq!(out["completion"], request); + assert_eq!(out["connection"], "conn_1"); + + let without_conn = llm.complete(request, None).await.expect("complete"); + assert!(without_conn["connection"].is_null()); + } + + #[tokio::test] + async fn schema_aware_mock_llm_synthesizes_a_schema_valid_completion() { + // A plain agent node (no `agent_ref`) hands its config to the `llm` + // slot; the returned object must pass the output-parser sub-port's + // validator directly (no auto-fix hop) for every declared type. + let llm = SchemaAwareMockLlm; + let request = json!({ + "prompt": "extract", + "output_parser": { "schema": { "type": "object", + "required": ["email", "count", "active", "meta", "tags"], + "properties": { + "email": { "type": "string" }, + "count": { "type": "integer" }, + "active": { "type": "boolean" }, + "meta": { "type": "object" }, + "tags": { "type": "array" } + } } } + }); + let out = llm.complete(request, None).await.expect("complete"); + assert_eq!(out["email"], ""); + assert_eq!(out["count"], 0); + assert_eq!(out["active"], false); + assert_eq!(out["meta"], json!({})); + assert_eq!(out["tags"], json!([])); + } + + #[tokio::test] + async fn schema_aware_mock_llm_ignores_null_schema() { + // `output_parser: { schema: null }` is treated as "no schema" — the + // vendored echo shape, same as the runner's null-schema handling. + let llm = SchemaAwareMockLlm; + let request = json!({ "prompt": "hi", "output_parser": { "schema": null } }); + let out = llm.complete(request.clone(), None).await.expect("complete"); + assert_eq!(out["completion"], request); + } + #[test] fn placeholder_for_schema_falls_back_to_type_without_properties() { assert_eq!( diff --git a/vendor/tinyflows b/vendor/tinyflows index f18238904f..e5327de9f6 160000 --- a/vendor/tinyflows +++ b/vendor/tinyflows @@ -1 +1 @@ -Subproject commit f18238904f48c395dce99c0c1d759c3dd1bfb678 +Subproject commit e5327de9f602c1fbbf72d45150729f45b91fa3a8 From 1c4376102358b93270d57bed948105f089a93344 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:33:31 +0530 Subject: [PATCH 06/86] fix(flows): guarantee a terminal state on every flows_build turn (no silent trail-off) (#4887) --- src/openhuman/flows/ops.rs | 207 +++++++++++++++++++++++++++++-- src/openhuman/flows/ops_tests.rs | 186 +++++++++++++++++++++++++++ 2 files changed, 381 insertions(+), 12 deletions(-) diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index 69b39cd5aa..2868bd8ded 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -4095,26 +4095,25 @@ pub async fn flows_build( } }; - // Emit the terminal chat event so a client viewing the copilot thread stops - // "processing" and finalizes the assistant bubble (the bridge streams only - // intermediate deltas). Success delivers `chat_done`; a run error delivers - // `chat_error`. The blocking return below is unchanged. - if let Some(target) = &stream { - let terminal: Result = match &run_error { - None => Ok(assistant_text.clone()), - Some(err) => Err(err.clone()), - }; - finalize_flow_stream(target, &terminal, &prompt).await; - } - // Capture the proposal from the run's tool history (propose/revise/save all // emit the same self-describing `{ type: "workflow_proposal", … }` payload). + // Extracted BEFORE the stream is finalized below (issue: builder + // convergence): the trail-off backstop needs `proposal`/`capped` to decide + // whether to override `assistant_text`, and the streamed copilot-pane chat + // bubble must render the SAME (possibly-overridden) text as the RPC + // response — the frontend renders from the stream, not the return value, + // so patching only the latter would still leave an interactive user + // staring at the original silent/status-only text. let proposal = extract_workflow_proposal(agent.history()); // A run that both errored AND produced no proposal is a hard failure; a run // that proposed before erroring still returns the proposal for review. if proposal.is_none() { if let Some(err) = &run_error { + if let Some(target) = &stream { + let terminal: Result = Err(err.clone()); + finalize_flow_stream(target, &terminal, &prompt).await; + } return Err(format!("workflow_builder produced no proposal: {err}")); } } @@ -4132,11 +4131,51 @@ pub async fn flows_build( let hit_cap = agent.last_turn_hit_cap(); let capped = hit_cap && proposal.is_none(); + // Terminal-state guarantee (builder convergence fix): a turn can end + // "naturally" (no more tool calls, not capped, no run error) yet still + // produce neither a proposal nor a real question — the model ran out of + // steam mid-build and left a status dump ("Done so far: checked + // connections…") as its final reply. `prompt.md` tells the model to + // always end a building turn in a proposal or a question, but a prompt + // rule can be silently ignored; this is the fail-closed backend backstop + // that makes it a hard invariant regardless of model behavior — the user + // is NEVER left with silence or an unanswerable status note. + let trail_off = !capped && proposal.is_none() && run_error.is_none(); + let assistant_text = if trail_off && !text_looks_like_question(&assistant_text) { + let fallback = build_trail_off_fallback(agent.history()); + tracing::warn!( + target: "flows", + flow_id = req.flow_id.as_deref().unwrap_or(""), + original_len = assistant_text.len(), + fallback_len = fallback.len(), + "[flows] flows_build: trail-off detected (no proposal, no cap, no question) — \ + guaranteeing a fallback question instead of silence" + ); + fallback + } else { + assistant_text + }; + + // Emit the terminal chat event so a client viewing the copilot thread stops + // "processing" and finalizes the assistant bubble (the bridge streams only + // intermediate deltas). Success delivers `chat_done`; a run error delivers + // `chat_error`. The blocking return below is unchanged. Uses the + // (possibly trail-off-overridden) `assistant_text` above. + if let Some(target) = &stream { + let terminal: Result = match &run_error { + None => Ok(assistant_text.clone()), + Some(err) => Err(err.clone()), + }; + finalize_flow_stream(target, &terminal, &prompt).await; + } + tracing::info!( target: "flows", + flow_id = req.flow_id.as_deref().unwrap_or(""), has_proposal = proposal.is_some(), hit_cap, capped, + trail_off, "[flows] flows_build: workflow_builder turn complete" ); Ok(RpcOutcome::single_log( @@ -4145,11 +4184,155 @@ pub async fn flows_build( "assistant_text": assistant_text, "error": run_error, "capped": capped, + "trail_off": trail_off, }), "workflow builder turn complete", )) } +/// Heuristic: does `text` already end with a clear, answerable question? +/// Conservative by design (issue: builder convergence) — a false negative (an +/// actual question this misses) just wraps it in the trail-off fallback, +/// which still includes the blocker context, so the safe failure mode is +/// "over-wrap", never "under-detect and stay silent". +fn text_looks_like_question(text: &str) -> bool { + let trimmed = text + .trim() + .trim_end_matches(|c: char| matches!(c, '"' | '\'' | ')' | ']' | '*' | '_' | '`' | '.')) + .trim_end(); + if trimmed.is_empty() { + return false; + } + if trimmed.ends_with('?') { + return true; + } + // The question may not be the literal last character (trailing markdown + // like a closing code fence or list marker on its own line) — fall back + // to the last non-blank line. This does NOT catch a question followed by + // a further trailing sentence ("...channel?\n\nLet me know!") — that's + // an accepted false negative: the turn still ends in a real (if + // over-eagerly replaced) question, never in silence, which is the + // invariant this function exists to protect. + trimmed + .lines() + .filter(|line| !line.trim().is_empty()) + .next_back() + .is_some_and(|last_line| last_line.trim_end().ends_with('?')) +} + +/// Builder-authoring tools whose result body can explain a trail-off — the +/// authoring belt `dry_run_workflow`/`validate_workflow`/`propose_workflow`/ +/// `revise_workflow`/`edit_workflow`/`save_workflow` all report either a hard +/// gate rejection (`ToolResult::error`) or a self-reported broken-graph +/// result (`"ok": false` in a successful body), so a plain-text read-only +/// tool's output is never misattributed as the blocker. +const TRAIL_OFF_BLOCKER_TOOLS: &[&str] = &[ + "dry_run_workflow", + "validate_workflow", + "propose_workflow", + "revise_workflow", + "edit_workflow", + "save_workflow", +]; + +/// Synthesizes a guaranteed, user-facing fallback for a trail-off turn (no +/// proposal, not capped, no run error, and the model's own text isn't a +/// question). Scans the run's tool history for the last builder-tool result +/// that looks like a blocker (a hard-gate rejection, or a `dry_run_workflow`/ +/// `validate_workflow` report with `"ok": false`) and asks the user about it; +/// falls back to a generic "what should I focus on" question when no such +/// blocker is found (the model may have simply stopped with nothing to point +/// to). +fn build_trail_off_fallback( + history: &[crate::openhuman::inference::provider::ConversationMessage], +) -> String { + match last_builder_tool_blocker(history) { + Some(blocker) => format!( + "I wasn't able to finish building this workflow. Here's where I got stuck:\n\n{blocker}\n\n\ + Could you tell me how you'd like me to resolve that, or share more detail about what's needed here?" + ), + None => "I wasn't able to finish building this workflow in this turn. Could you describe \ + what you'd like in more detail, or tell me which part to focus on?" + .to_string(), + } +} + +/// Scans `history` in reverse for the last result from a +/// [`TRAIL_OFF_BLOCKER_TOOLS`] call that reads as a failure — a plain-text +/// error message (gate rejection), or a JSON body with `"ok": false` — and +/// returns a truncated, human-readable description of it. Tool names are +/// resolved by correlating each `ToolResults` entry's `tool_call_id` back to +/// the `AssistantToolCalls` message that issued it, so this never +/// misattributes an unrelated read-only tool's plain-text output as a +/// blocker. +fn last_builder_tool_blocker( + history: &[crate::openhuman::inference::provider::ConversationMessage], +) -> Option { + use crate::openhuman::inference::provider::ConversationMessage; + + let mut call_names: std::collections::HashMap = + std::collections::HashMap::new(); + for message in history { + if let ConversationMessage::AssistantToolCalls { tool_calls, .. } = message { + for call in tool_calls { + call_names.insert(call.id.clone(), call.name.clone()); + } + } + } + + for message in history.iter().rev() { + let ConversationMessage::ToolResults(results) = message else { + continue; + }; + for result in results.iter().rev() { + let Some(name) = call_names.get(&result.tool_call_id) else { + continue; + }; + if !TRAIL_OFF_BLOCKER_TOOLS.contains(&name.as_str()) { + continue; + } + // This is the MOST RECENT authoring-belt tool result in the + // turn (results are scanned newest-first). Whatever it reads as + // is authoritative: a success/progress result here means any + // earlier failure from the same tool was already resolved + // within this turn, so we must stop at this result rather than + // keep walking backward and surfacing a stale, already-fixed + // blocker (see review discussion on this PR). + return describe_tool_result_blocker(&result.content) + .map(|desc| crate::openhuman::util::truncate_with_ellipsis(&desc, 500)); + } + } + None +} + +/// Reads one builder tool result's content as a failure description, or +/// `None` when it reads as success/progress (a `workflow_proposal` payload, +/// or an `"ok": true` report). The whole body is the description, never one +/// hardcoded field, so this stays correct regardless of which fields a given +/// tool uses to explain its failure. +fn describe_tool_result_blocker(content: &str) -> Option { + let trimmed = content.trim(); + if trimmed.is_empty() { + return None; + } + if let Ok(value) = serde_json::from_str::(trimmed) { + if value.get("type").and_then(Value::as_str) == Some("workflow_proposal") { + return None; // Success: a proposal was emitted. + } + if let Some(ok) = value.get("ok").and_then(Value::as_bool) { + return if ok { None } else { Some(value.to_string()) }; + } + // Some other structured payload with no `ok`/`type` marker this + // function recognises — not confidently a blocker, skip it. + return None; + } + // Non-JSON content: a hard-gate rejection (`ToolResult::error`) puts the + // plain error message straight into the content — since every builder + // tool's SUCCESS shape is JSON (a proposal or a `{ ok, ... }` report), a + // bare string here is, by elimination, an error message. + Some(trimmed.to_string()) +} + /// Scans an agent run's conversation history for the workflow proposal a builder /// tool emitted. `propose_workflow` / `revise_workflow` / `save_workflow` all /// return a self-describing `{ "type": "workflow_proposal", … }` JSON string as diff --git a/src/openhuman/flows/ops_tests.rs b/src/openhuman/flows/ops_tests.rs index 27731b6374..22c5dc8b8a 100644 --- a/src/openhuman/flows/ops_tests.rs +++ b/src/openhuman/flows/ops_tests.rs @@ -4240,3 +4240,189 @@ async fn compute_required_connections_skips_native_and_http_nodes() { "native oh: and http_request need no connection: {required:?}" ); } + +// ───────────────────────────────────────────────────────────────────────────── +// Builder convergence fix — trail-off backstop (`flows_build`'s terminal-state +// guarantee: every turn ends in a proposal or a real question, never silence). +// ───────────────────────────────────────────────────────────────────────────── + +fn builder_tool_call( + id: &str, + name: &str, +) -> crate::openhuman::inference::provider::ConversationMessage { + use crate::openhuman::inference::provider::{ConversationMessage, ToolCall}; + ConversationMessage::AssistantToolCalls { + text: None, + tool_calls: vec![ToolCall { + id: id.to_string(), + name: name.to_string(), + arguments: "{}".to_string(), + extra_content: None, + }], + reasoning_content: None, + extra_metadata: None, + } +} + +fn builder_tool_result( + call_id: &str, + content: &str, +) -> crate::openhuman::inference::provider::ConversationMessage { + use crate::openhuman::inference::provider::{ConversationMessage, ToolResultMessage}; + ConversationMessage::ToolResults(vec![ToolResultMessage { + tool_call_id: call_id.to_string(), + content: content.to_string(), + }]) +} + +#[test] +fn text_looks_like_question_detects_trailing_question_mark() { + assert!(text_looks_like_question( + "Which Slack channel should I post to?" + )); + assert!(text_looks_like_question("Which channel?\n")); + // Trailing markdown/punctuation noise after the '?' shouldn't defeat it. + assert!(text_looks_like_question("Which channel should I use?\"")); + // A trailing blank line after the question is still detected (the last + // NON-BLANK line is what's checked). + assert!(text_looks_like_question( + "Which channel should I post to?\n\n" + )); +} + +/// A question followed by a further trailing sentence on its own line +/// ("...channel?\n\nLet me know!") is an accepted false negative — the +/// heuristic is deliberately conservative (see the function doc). Pin that +/// this case is NOT detected so a future "improvement" doesn't silently +/// change the accepted trade-off without a matching design review. +#[test] +fn text_looks_like_question_accepts_false_negative_on_trailing_pleasantry() { + assert!(!text_looks_like_question( + "Which channel should I post to?\n\nLet me know!" + )); +} + +#[test] +fn text_looks_like_question_rejects_status_dumps_and_silence() { + assert!(!text_looks_like_question( + "## Done so far\n- Checked connections\n- Verified contracts" + )); + assert!(!text_looks_like_question("")); + assert!(!text_looks_like_question(" ")); + assert!(!text_looks_like_question("I'll continue working on this.")); +} + +/// The terminal-state guarantee's core invariant: whatever `build_trail_off_fallback` +/// returns, it must ALWAYS read as a question — the user is never left with +/// silence, regardless of what (if anything) the tool history contains. +#[test] +fn build_trail_off_fallback_always_yields_a_question() { + let fallback = build_trail_off_fallback(&[]); + assert!( + text_looks_like_question(&fallback), + "fallback with no tool history must still be a question: {fallback}" + ); + assert!(!fallback.trim().is_empty()); +} + +#[test] +fn build_trail_off_fallback_surfaces_last_dry_run_blocker() { + let history = vec![ + builder_tool_call("call_1", "dry_run_workflow"), + builder_tool_result( + "call_1", + r#"{"ok": false, "null_resolutions": [{"node_id": "send", "path": "args.channel"}]}"#, + ), + ]; + let fallback = build_trail_off_fallback(&history); + assert!( + text_looks_like_question(&fallback), + "blocker fallback must still end in a question: {fallback}" + ); + assert!( + fallback.contains("null_resolutions"), + "fallback should surface the actual dry-run blocker, got: {fallback}" + ); +} + +#[test] +fn build_trail_off_fallback_surfaces_gate_rejection_error_text() { + let history = vec![ + builder_tool_call("call_1", "propose_workflow"), + builder_tool_result( + "call_1", + "propose_workflow rejected: tool slug 'slack:not_a_real_action' does not exist", + ), + ]; + let fallback = build_trail_off_fallback(&history); + assert!(text_looks_like_question(&fallback)); + assert!(fallback.contains("does not exist")); +} + +#[test] +fn build_trail_off_fallback_ignores_unrelated_read_tool_output() { + // A plain-text result from a tool OUTSIDE the builder authoring belt (e.g. + // a read-only history lookup) must never be misattributed as the blocker + // — this stays tool-agnostic within the authoring belt, not "any tool". + let history = vec![ + builder_tool_call("call_1", "get_flow_history"), + builder_tool_result("call_1", "no prior revisions found"), + ]; + let fallback = build_trail_off_fallback(&history); + assert!(text_looks_like_question(&fallback)); + assert!( + !fallback.contains("no prior revisions found"), + "must not surface an unrelated read-tool's output as the blocker: {fallback}" + ); +} + +#[test] +fn build_trail_off_fallback_ignores_a_successful_proposal_payload() { + let history = vec![ + builder_tool_call("call_1", "propose_workflow"), + builder_tool_result( + "call_1", + r#"{"type": "workflow_proposal", "name": "demo", "graph": {}}"#, + ), + ]; + let fallback = build_trail_off_fallback(&history); + assert!(text_looks_like_question(&fallback)); + assert!(!fallback.contains("workflow_proposal")); +} + +#[test] +fn build_trail_off_fallback_picks_the_most_recent_blocker() { + // Two dry-run failures in the history: the fallback should describe the + // LAST one (the one the agent was still stuck on), not the first. + let history = vec![ + builder_tool_call("call_1", "dry_run_workflow"), + builder_tool_result("call_1", r#"{"ok": false, "errors": ["first issue"]}"#), + builder_tool_call("call_2", "dry_run_workflow"), + builder_tool_result("call_2", r#"{"ok": false, "errors": ["second issue"]}"#), + ]; + let fallback = build_trail_off_fallback(&history); + assert!(fallback.contains("second issue")); + assert!(!fallback.contains("first issue")); +} + +/// Regression for review feedback (chatgpt-codex-connector, PR #4887): a +/// dry-run failure that the agent goes on to FIX later in the same turn +/// (a later `{"ok": true}` from the same authoring belt) must not be +/// resurfaced as "here's where I got stuck" — that failure is already +/// resolved. The scan must stop at the most recent authoring-belt result, +/// not keep walking backward past a success to an older, stale blocker. +#[test] +fn build_trail_off_fallback_does_not_resurface_a_resolved_blocker() { + let history = vec![ + builder_tool_call("call_1", "dry_run_workflow"), + builder_tool_result("call_1", r#"{"ok": false, "errors": ["first issue"]}"#), + builder_tool_call("call_2", "dry_run_workflow"), + builder_tool_result("call_2", r#"{"ok": true, "warnings": []}"#), + ]; + let fallback = build_trail_off_fallback(&history); + assert!( + !fallback.contains("first issue"), + "must not surface an already-resolved blocker: {fallback}" + ); + assert!(text_looks_like_question(&fallback)); +} From cc3c402d255fa90cc609549cb3e2cb932381293e Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:48:45 +0530 Subject: [PATCH 07/86] =?UTF-8?q?fix(flows):=20save-safety=20=E2=80=94=20n?= =?UTF-8?q?o=20silent=20live-arming=20on=20update,=20flag=20empty=20runs,?= =?UTF-8?q?=20close=20resume=5Fflow=5Frun=20HITL=20bypass=20(#4889)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/openhuman/flows/ops.rs | 165 +++++++++++++++-- src/openhuman/flows/ops_tests.rs | 277 ++++++++++++++++++++++++++--- src/openhuman/flows/store.rs | 18 +- src/openhuman/flows/store_tests.rs | 2 + 4 files changed, 418 insertions(+), 44 deletions(-) diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index 2868bd8ded..3f5f7bae3f 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -366,6 +366,24 @@ pub(crate) fn graph_has_outbound_side_effect(graph: &WorkflowGraph) -> bool { }) } +/// Whether `graph` has anything for [`flows_run`] to actually *do* — i.e. at +/// least one non-`trigger` node, wired up by at least one edge. A graph made +/// of nothing but a bare `trigger` node (or a `trigger` plus unreachable/ +/// disconnected nodes with no edges at all) can compile and "run" cleanly +/// while producing no work whatsoever — the exact live finding this guards: +/// a trigger-only flow reported `status="completed" pending_approvals=0` +/// having done nothing, which reads as a successful automation to anyone not +/// staring at the node count. Used by `flows_run` to attach a +/// human-readable note to an otherwise-silent "success". +pub(crate) fn graph_has_actionable_nodes(graph: &WorkflowGraph) -> bool { + let non_trigger_nodes = graph + .nodes + .iter() + .filter(|n| n.kind != NodeKind::Trigger) + .count(); + non_trigger_nodes > 0 && !graph.edges.is_empty() +} + /// Produces host-side, **non-fatal** validation warnings for a graph — today /// exactly one: "this trigger kind does not fire automatically yet". Returns /// an empty vec when the trigger fires (`manual`/`schedule`/`app_event`), when @@ -2462,6 +2480,26 @@ fn map_flow_update_error(e: store::FlowUpdateError) -> String { /// old cadence, or a newly-added schedule would never get bound at all. /// Skipped entirely for a name/`require_approval`-only update (no /// `graph_json` supplied), since the trigger definitely didn't change. +/// +/// **B29 Rule 1 analogue for saves** (save/enable safety — same issue +/// `flows_create` guards at creation time, see its doc): `flows_create` +/// refuses to persist an automatic-trigger graph (`schedule` / `app_event` / +/// `webhook`, see [`trigger_is_automatic`]) as `enabled`, but that guard only +/// runs once, at creation. Without an equivalent here, a flow created +/// `enabled: true` with a manual/no-op trigger could later have an +/// automatic-trigger graph saved onto it — via the `save_workflow` agent +/// tool, the canvas Save button, a proposal apply, or any other +/// `flows_update` caller — and go LIVE immediately with no user review +/// (confirmed live: a flow started firing on an unreviewed 8am schedule). +/// So: when the *new* graph's trigger is automatic, the flow is *currently* +/// enabled, and the *previous* graph's trigger was NOT automatic (a +/// manual/none → automatic transition), this forces the persisted `enabled` +/// back to `false` in the same store write — the user must explicitly +/// re-arm via `flows_set_enabled` after reviewing the new trigger. +/// Deliberately narrower than Rule 1's at-create version: a flow that was +/// already an enabled *automatic*-trigger flow being legitimately re-edited +/// (e.g. tweaking a cron expression) is left alone — the user already opted +/// in once, and re-disarming on every edit would just be friction. pub async fn flows_update( config: &Config, id: &str, @@ -2485,17 +2523,47 @@ pub async fn flows_update( } }; + // B29 Rule 1 analogue: only disarm a manual/none → automatic transition + // on an already-enabled flow. An automatic → automatic re-edit, or a + // flow that isn't enabled to begin with, is untouched. + let was_auto = trigger_is_automatic(&existing.graph); + let now_auto = trigger_is_automatic(&graph); + let should_disarm = now_auto && existing.enabled && !was_auto; + let enabled_override = should_disarm.then_some(false); + tracing::debug!( + target: "flows", + flow_id = %id, + was_auto, + now_auto, + currently_enabled = existing.enabled, + should_disarm, + "[flows] flows_update: auto-trigger disarm decision inputs" + ); + tracing::debug!(target: "flows", flow_id = %id, has_expected = expected_version.is_some(), "[flows] flows_update: persisting changes"); + // `enabled_override` is threaded into the same guarded UPDATE as the + // graph/name/require_approval write (see `store::update_flow_graph`) + // rather than a follow-up `flows_set_enabled` call, so the disarm can + // never race a concurrent read/write of `enabled`. let updated = store::update_flow_graph( config, id, new_name, graph, new_require_approval, + enabled_override, expected_version.as_deref(), ) .map_err(map_flow_update_error)?; + if should_disarm { + tracing::info!( + target: "flows", + flow_id = %id, + "[flows] flows_update: auto-disabled — graph changed manual→automatic trigger on an enabled flow" + ); + } + if graph_changed && updated.enabled { let trigger_unchanged = bus::extract_trigger_kind(&existing) == bus::extract_trigger_kind(&updated) @@ -2508,10 +2576,16 @@ pub async fn flows_update( } publish_flow_changed(id, "updated", "system"); - Ok(RpcOutcome::single_log( - updated, - format!("flow updated: {id}"), - )) + let mut logs = vec![format!("flow updated: {id}")]; + if should_disarm { + logs.push( + "Flow was auto-disabled because its trigger changed from manual to automatic \ + (schedule / app_event / webhook). Enable it explicitly (flows_set_enabled) once \ + you've reviewed the new trigger." + .to_string(), + ); + } + Ok(RpcOutcome::new(updated, logs)) } /// Lists a flow's revision history (prior graph snapshots), newest first, @@ -2856,6 +2930,24 @@ pub async fn flows_run( .map_err(|e| e.to_string())? .ok_or_else(|| format!("flow '{flow_id}' not found"))?; + // Live finding: a graph with no actionable nodes (only a `trigger`, or a + // `trigger` plus nodes with no edges wiring them up) compiles and "runs" + // cleanly but does nothing — and previously reported + // `status="completed" pending_approvals=0` indistinguishably from a real + // run, reading as "triggered but nothing happened" was actually a + // success. Surface it loudly instead of letting it pass silently: warn + // now (independent of how the run below turns out), and attach a + // human-readable note to the returned outcome so the UI can show + // "nothing to run" rather than a bare "completed". + let no_actionable_nodes = !graph_has_actionable_nodes(&flow.graph); + if no_actionable_nodes { + tracing::warn!( + target: "flows", + flow_id = %flow_id, + "[flows] flows_run: flow has no actionable nodes — nothing to execute" + ); + } + // `store::get_flow` already ran the stored `graph_json` through // `tinyflows::migrate::migrate` before deserializing, so `flow.graph` is // always on the current schema here. @@ -3011,17 +3103,26 @@ pub async fn flows_run( flow_id = %flow_id, status, pending_approvals = outcome.pending_approvals.len(), + no_actionable_nodes, "[flows] flows_run: finished" ); - Ok(RpcOutcome::single_log( - json!({ - "output": outcome.output, - "pending_approvals": outcome.pending_approvals, - "thread_id": thread_id, - }), - format!("flow run {status}"), - )) + const NO_ACTIONABLE_NODES_NOTE: &str = "This flow's graph has no actionable nodes beyond \ + its trigger (no downstream action nodes, or no edges connecting them) — the run \ + completed without doing anything. Add and wire up at least one action node."; + + let mut result = json!({ + "output": outcome.output, + "pending_approvals": outcome.pending_approvals, + "thread_id": thread_id, + }); + let mut logs = vec![format!("flow run {status}")]; + if no_actionable_nodes { + result["note"] = json!(NO_ACTIONABLE_NODES_NOTE); + logs.push(NO_ACTIONABLE_NODES_NOTE.to_string()); + } + + Ok(RpcOutcome::new(result, logs)) } /// Resumes a `flows_run` that paused at a human-in-the-loop approval gate, @@ -3949,7 +4050,9 @@ pub async fn flows_discover( const FLOW_BUILD_TIMEOUT_SECS: u64 = 600; /// Tools stripped from the `workflow_builder` belt on the direct `flows_build` -/// RPC path (issue #4593). +/// RPC path (issue #4593; widened for `resume_flow_run`/`cancel_flow_run` +/// alongside issue #4881, which added both to the belt without extending +/// this list). /// /// `flows_build` runs the builder under [`AgentTurnOrigin::Cli`] so the approval /// gate does not fail-closed in a headless/streamed run — but that same origin @@ -3969,22 +4072,46 @@ const FLOW_BUILD_TIMEOUT_SECS: u64 = 600; /// name (now the unrelated harness spawn tool) is listed too as belt-and-braces /// against a re-rename or the name ever leaking back onto this belt; /// `hide_tools` no-ops on a name that isn't present. -const FLOWS_BUILD_HIDDEN_TOOLS: &[&str] = &["run_workflow", "run_flow"]; +/// +/// `resume_flow_run` ([`builder_tools::ResumeFlowRunTool`]) is the exact same +/// concern as `run_flow`, one hop later: it is `external_effect() == true` +/// (its own description says "This ADVANCES A REAL RUN — approved outbound +/// nodes will fire") and would be auto-allowed by the same `Cli`-origin gate +/// bypass, letting an authoring turn (or a confused/prompt-injected model) +/// approve a live run's parked Slack/Gmail/HTTP node with zero human +/// confirmation — the exact HITL hole #4593 closed, reopened by #4881 +/// widening the belt. +/// +/// `cancel_flow_run` fires no new outbound effect +/// (`external_effect() == false`), so it isn't a gate-bypass concern the same +/// way — but an authoring turn still has no business tearing down a run the +/// *user* started, so it is hidden alongside the two above out of caution. +/// +/// `create_workflow` / `duplicate_flow` are deliberately **left visible**: +/// both are hard-forced **born disabled** (see [`builder_tools::CreateWorkflowTool`] +/// / [`builder_tools::DuplicateFlowTool`]), so even an unattended call can't +/// leave anything live — lower risk than the run/resume/cancel trio above. +const FLOWS_BUILD_HIDDEN_TOOLS: &[&str] = &[ + "run_workflow", + "run_flow", + "resume_flow_run", + "cancel_flow_run", +]; -/// Strip the live-run tool(s) in [`FLOWS_BUILD_HIDDEN_TOOLS`] from `agent`'s -/// callable set for the direct `flows_build` RPC path. +/// Strip the live-run / resume / cancel tool(s) in [`FLOWS_BUILD_HIDDEN_TOOLS`] +/// from `agent`'s callable set for the direct `flows_build` RPC path. /// /// Delegates to [`crate::openhuman::agent::Agent::hide_tools`], which removes /// the names from the builder's (already narrow) visible belt and rebuilds the /// session's `ToolPolicySession` so they resolve to `Deny` at the tool-call /// boundary — a hard execution guarantee even if the model requests the tool. -/// The authoring tools (`propose`/`revise`/`save`/`dry_run`/reads) are all -/// `external_effect() == false` and untouched, so the turn never fail-closes. +/// The authoring tools (`propose`/`revise`/`save`/`dry_run`/reads/`create_workflow`/ +/// `duplicate_flow`) stay visible and untouched, so the turn never fail-closes. fn restrict_builder_toolset(agent: &mut crate::openhuman::agent::Agent) { tracing::debug!( target: "flows", hidden = ?FLOWS_BUILD_HIDDEN_TOOLS, - "[flows] flows_build: hiding live-run tools from builder belt" + "[flows] flows_build: hiding live-run/resume/cancel tools from builder belt" ); agent.hide_tools(FLOWS_BUILD_HIDDEN_TOOLS); } diff --git a/src/openhuman/flows/ops_tests.rs b/src/openhuman/flows/ops_tests.rs index 22c5dc8b8a..a8a789d02a 100644 --- a/src/openhuman/flows/ops_tests.rs +++ b/src/openhuman/flows/ops_tests.rs @@ -228,6 +228,79 @@ async fn flows_run_completes_trigger_only_graph() { assert!(reloaded.value.last_run_at.is_some()); } +/// Live finding: a trigger-only graph (no downstream action nodes at all) +/// used to report `status="completed" pending_approvals=0` from `flows_run` +/// completely indistinguishably from a run that actually did something — +/// "triggered but nothing happened" read as a plain success. This asserts +/// the run still completes (running an empty flow isn't an error), but now +/// carries a human-readable `note` in the result so the UI can show +/// "nothing to run" instead of a bare "completed". +#[tokio::test] +async fn flows_run_on_trigger_only_graph_surfaces_no_actionable_nodes_note() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let created = flows_create(&config, "empty".to_string(), trigger_only_graph(), false) + .await + .unwrap(); + + let outcome = flows_run(&config, &created.value.id, json!({}), FlowRunTrigger::Rpc) + .await + .unwrap(); + + let note = outcome.value["note"] + .as_str() + .expect("trigger-only run must carry a human-readable 'note' field"); + assert!( + note.contains("no actionable nodes") || note.to_lowercase().contains("nothing"), + "note should explain that nothing ran, got: {note}" + ); + assert!( + outcome.logs.iter().any(|l| l.contains("no actionable")), + "the note should also surface via the RpcOutcome logs, got: {:?}", + outcome.logs + ); + + // Still a completed run, not an error — an empty flow isn't a failure, + // just a no-op that must not masquerade as having done real work. + let reloaded = flows_get(&config, &created.value.id).await.unwrap(); + assert_eq!(reloaded.value.last_status.as_deref(), Some("completed")); +} + +/// A graph with a real downstream node, wired up by an edge, must NOT carry +/// the "nothing to run" note — only a graph with no actionable nodes at all. +/// Uses `output_parser` nodes (like the approval-gated fixture above) rather +/// than an `agent`/`tool_call` node so the run completes deterministically +/// without needing a configured LLM provider or network access. +#[tokio::test] +async fn flows_run_on_graph_with_actionable_nodes_has_no_empty_flow_note() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let graph = json!({ + "name": "has-work", + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Trigger" }, + { "id": "downstream", "kind": "output_parser", "name": "Downstream" } + ], + "edges": [ + { "from_node": "t", "to_node": "downstream" } + ] + }); + let created = flows_create(&config, "has-work".to_string(), graph, false) + .await + .unwrap(); + + let outcome = flows_run(&config, &created.value.id, json!({}), FlowRunTrigger::Rpc) + .await + .unwrap(); + + assert!( + outcome.value.get("note").is_none(), + "a graph with real downstream nodes must not get the empty-flow note, got: {:?}", + outcome.value.get("note") + ); +} + #[tokio::test] async fn flows_run_reports_pending_approval_and_blocks_downstream() { let tmp = TempDir::new().unwrap(); @@ -559,6 +632,141 @@ async fn flows_update_does_not_rebind_when_graph_is_not_supplied() { assert_eq!(job.expression, old_job.expression); } +// ── flows_update B29 Rule 1 analogue (save/enable safety on update) ─────── +// +// `flows_create` already refuses to persist an automatic-trigger graph as +// `enabled` (Rule 1, above). Live finding: `flows_update` had no equivalent +// — a flow created `enabled: true` with a manual trigger could later have an +// automatic-trigger graph (schedule / app_event / webhook) saved onto it via +// `flows_update` and go LIVE immediately with no user review. These tests +// cover the manual→automatic transition (must disarm), automatic→automatic +// re-edit (must NOT disarm — the user already opted in), and manual→manual +// (never touched). + +#[tokio::test] +async fn flows_update_disables_on_manual_to_automatic_trigger_transition_when_enabled() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + // A manual-trigger flow persists enabled straight from create (Rule 1 + // only gates automatic triggers). + let created = flows_create( + &config, + "manual-then-scheduled".to_string(), + manual_trigger_graph(), + false, + ) + .await + .unwrap(); + assert!(created.value.enabled, "manual-trigger flows create enabled"); + + // Saving an automatic-trigger graph onto that enabled flow must disarm + // it — not go live unattended. + let updated = flows_update( + &config, + &created.value.id, + None, + Some(schedule_trigger_graph("0 8 * * *")), + None, + None, + ) + .await + .unwrap(); + + assert!( + !updated.value.enabled, + "an enabled flow whose trigger just changed from manual to automatic must be \ + auto-disabled, not armed live" + ); + assert!( + updated.logs.iter().any(|l| l.contains("auto-disabled")), + "the disarm must be surfaced in the outcome logs, got: {:?}", + updated.logs + ); + + // Persisted, not just returned in-memory. + let reloaded = flows_get(&config, &created.value.id).await.unwrap(); + assert!(!reloaded.value.enabled); + + // And no cron job was left bound — the flow never actually went live. + assert!( + crate::openhuman::cron::find_flow_schedule_job(&config, &created.value.id) + .unwrap() + .is_none(), + "an auto-disabled flow must not have its schedule cron job bound" + ); +} + +#[tokio::test] +async fn flows_update_preserves_enabled_when_already_automatic() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + // Rule 1 creates an automatic-trigger flow disabled; the user arms it + // explicitly — this IS the "already reviewed and opted in" state. + let created = flows_create( + &config, + "scheduled".to_string(), + schedule_trigger_graph("0 9 * * *"), + false, + ) + .await + .unwrap(); + assert!(!created.value.enabled); + flows_set_enabled(&config, &created.value.id, true) + .await + .unwrap(); + + // A legitimate re-edit (still an automatic trigger, just a new cron + // expression) must NOT be treated as a fresh unattended arm. + let updated = flows_update( + &config, + &created.value.id, + None, + Some(schedule_trigger_graph("30 8 * * *")), + None, + None, + ) + .await + .unwrap(); + + assert!( + updated.value.enabled, + "re-editing an already-enabled automatic-trigger flow must not disarm it — the \ + user already opted in once" + ); + assert!(!updated.logs.iter().any(|l| l.contains("auto-disabled"))); +} + +#[tokio::test] +async fn flows_update_preserves_enabled_for_manual_target() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let created = flows_create(&config, "manual".to_string(), manual_trigger_graph(), false) + .await + .unwrap(); + assert!(created.value.enabled); + + // manual → manual: no automatic trigger ever enters the picture, so + // `enabled` must be left completely untouched. + let mut new_graph = manual_trigger_graph(); + new_graph["name"] = json!("manual-renamed"); + let updated = flows_update( + &config, + &created.value.id, + None, + Some(new_graph), + None, + None, + ) + .await + .unwrap(); + + assert!(updated.value.enabled); + assert!(!updated.logs.iter().any(|l| l.contains("auto-disabled"))); +} + // ── flows_resume (issue B2) ─────────────────────────────────────────────── fn approval_gated_graph() -> Value { @@ -3454,21 +3662,25 @@ fn finalize_terminal_status_no_error_when_clean() { assert_eq!(error, None); } -/// Regression for issue #4593: the `flows_build` builder turn runs under -/// `AgentTurnOrigin::Cli`, which makes the `ApprovalGate` auto-allow every -/// `external_effect` tool. The flows live-runner executes a *live* saved flow, -/// so it must be unreachable on this path — `restrict_builder_toolset` drops it -/// from the builder's callable belt while leaving the authoring tools in place -/// so the turn still functions (never fail-closes). +/// Regression for issue #4593 (widened for #4881's `resume_flow_run`/ +/// `cancel_flow_run` addition to the belt): the `flows_build` builder turn +/// runs under `AgentTurnOrigin::Cli`, which makes the `ApprovalGate` +/// auto-allow every `external_effect` tool. The flows live-runner (`run_flow`) +/// and the run-resume tool (`resume_flow_run`) both execute/advance a *live* +/// saved flow's real outbound effects, so both must be unreachable on this +/// path — `restrict_builder_toolset` drops them (plus `cancel_flow_run`, out +/// of caution) from the builder's callable belt while leaving the authoring +/// tools in place so the turn still functions (never fail-closes). #[tokio::test] async fn flows_build_hides_the_live_run_tool_from_the_builder_belt() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - // Document WHY the live-runner must be hidden: running a saved flow fires - // real Slack/Gmail/HTTP/code effects, so it is an external-effect tool. This - // pins that invariant independently of belt name-resolution so the - // hide-list can't silently stop covering a live-run tool. + // Document WHY each run-advancing tool must be hidden: running or + // resuming a saved flow fires real Slack/Gmail/HTTP/code effects, so both + // are external-effect tools. This pins that invariant independently of + // belt name-resolution so the hide-list can't silently stop covering a + // live-run/resume tool. use crate::openhuman::tools::Tool as _; let live_runner = crate::openhuman::flows::tools::RunFlowTool::new(std::sync::Arc::new(config.clone())); @@ -3476,6 +3688,14 @@ async fn flows_build_hides_the_live_run_tool_from_the_builder_belt() { live_runner.external_effect(), "the flows live-runner must be external-effect for the #4593 concern to apply" ); + let resumer = crate::openhuman::flows::builder_tools::ResumeFlowRunTool::new( + std::sync::Arc::new(config.clone()), + ); + assert!( + resumer.external_effect(), + "resume_flow_run advances a real run's outbound effects, so it must be \ + external-effect for the same #4593/#4881 concern to apply" + ); crate::openhuman::agent::harness::AgentDefinitionRegistry::init_global(&config.workspace_dir) .expect("agent registry init"); @@ -3484,27 +3704,36 @@ async fn flows_build_hides_the_live_run_tool_from_the_builder_belt() { .expect("build workflow_builder agent"); agent.set_agent_definition_name("workflow_builder".to_string()); - // Precondition: the builder advertises the live-run tool (`run_flow`) on its - // belt before restriction — the exact tool #4593 is about. - assert!( - agent.visible_tool_names_for_test().contains("run_flow"), - "precondition: workflow_builder belt should advertise the live-run tool `run_flow`; \ - visible = {:?}", - agent.visible_tool_names_for_test() - ); + // Precondition: the builder advertises all four run-advancing tools on its + // belt before restriction — the exact set #4593/#4881 are about. + let visible_before = agent.visible_tool_names_for_test(); + for present in ["run_flow", "resume_flow_run", "cancel_flow_run"] { + assert!( + visible_before.contains(present), + "precondition: workflow_builder belt should advertise `{present}`; visible = \ + {visible_before:?}" + ); + } restrict_builder_toolset(&mut agent); - // After restriction neither the current name nor the post-rename name is - // callable on the flows_build path — the hide-list covers both (#4593). + // After restriction none of the run-advancing tools are callable on the + // flows_build path — the hide-list covers all of them (#4593 + #4881). let visible = agent.visible_tool_names_for_test(); - for hidden in ["run_workflow", "run_flow"] { + for hidden in [ + "run_workflow", + "run_flow", + "resume_flow_run", + "cancel_flow_run", + ] { assert!( !visible.contains(hidden), - "live-run tool `{hidden}` must be hidden on the flows_build path; visible = {visible:?}" + "run-advancing tool `{hidden}` must be hidden on the flows_build path; visible = \ + {visible:?}" ); } - // Authoring / read tools stay reachable so the builder turn still works + // Authoring / read tools — including the born-disabled `create_workflow` + // and `duplicate_flow` — stay reachable so the builder turn still works // headlessly under the CLI origin (no fail-close). for keep in [ "propose_workflow", @@ -3512,6 +3741,8 @@ async fn flows_build_hides_the_live_run_tool_from_the_builder_belt() { "save_workflow", "dry_run_workflow", "list_flows", + "create_workflow", + "duplicate_flow", ] { assert!( visible.contains(keep), diff --git a/src/openhuman/flows/store.rs b/src/openhuman/flows/store.rs index 9d9b95f990..3eb7e0c1bf 100644 --- a/src/openhuman/flows/store.rs +++ b/src/openhuman/flows/store.rs @@ -374,12 +374,22 @@ impl std::fmt::Display for FlowUpdateError { /// flow's `updated_at` no longer matches — so an agent save and a concurrent /// canvas save can't silently clobber each other. `None` keeps the prior /// last-write-wins behaviour for callers that don't track a version. +/// +/// `enabled_override`, when `Some`, forces the persisted `enabled` flag to +/// that value in the *same* guarded `UPDATE` as the graph/name/ +/// `require_approval` write — used by `ops::flows_update`'s B29 Rule 1 +/// analogue (auto-disarming a flow whose trigger just changed from manual to +/// automatic) so the disarm can never race a concurrent read/write of +/// `enabled` (a separate `set_enabled` call after this one would leave a +/// TOCTOU window). `None` leaves `enabled` untouched, matching the previous +/// behaviour for every other caller. pub fn update_flow_graph( config: &Config, id: &str, name: String, graph: tinyflows::model::WorkflowGraph, require_approval: bool, + enabled_override: Option, expected_updated_at: Option<&str>, ) -> std::result::Result { let current = get_flow(config, id) @@ -400,21 +410,25 @@ pub fn update_flow_graph( let prior_graph_json = serde_json::to_string(¤t.graph).unwrap_or_else(|_| "null".to_string()); let now = Utc::now().to_rfc3339(); + let new_enabled = enabled_override.unwrap_or(current.enabled); with_connection(config, |conn| { // Guarded UPDATE keyed on the observed updated_at (race-safe even // without an explicit expected version) — a concurrent writer that // moved updated_at makes this match 0 rows. Targeted columns only, so a - // concurrent set_enabled/record_run isn't clobbered. + // concurrent set_enabled/record_run isn't clobbered (unless this call + // itself carries an `enabled_override`, in which case `enabled` is + // one of the targeted columns by design). let changed = conn .execute( "UPDATE flow_definitions SET name = ?1, graph_json = ?2, updated_at = ?3, \ - require_approval = ?4 WHERE id = ?5 AND updated_at = ?6", + require_approval = ?4, enabled = ?5 WHERE id = ?6 AND updated_at = ?7", params![ name, graph_json, now, if require_approval { 1 } else { 0 }, + if new_enabled { 1 } else { 0 }, id, current.updated_at, ], diff --git a/src/openhuman/flows/store_tests.rs b/src/openhuman/flows/store_tests.rs index 8192cb0e73..778712a0e9 100644 --- a/src/openhuman/flows/store_tests.rs +++ b/src/openhuman/flows/store_tests.rs @@ -98,6 +98,7 @@ fn update_flow_graph_bumps_updated_at_and_preserves_created_at() { new_graph, false, None, + None, ) .unwrap(); @@ -204,6 +205,7 @@ fn update_flow_graph_can_change_require_approval() { trigger_graph(), true, None, + None, ) .unwrap(); assert!(updated.require_approval); From 376a8ca83c5abbced8b532aae3030da33fa001d4 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:53:45 +0530 Subject: [PATCH 08/86] =?UTF-8?q?fix(flows):=20copilot=20proposals=20reach?= =?UTF-8?q?=20the=20canvas=20=E2=80=94=20recognition,=20race-guard,=20titl?= =?UTF-8?q?e=20(#4886)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/pages/FlowCanvasPage.tsx | 99 +++++- .../pages/__tests__/FlowCanvasPage.test.tsx | 284 ++++++++++++++++++ app/src/providers/ChatRuntimeProvider.tsx | 35 +-- .../__tests__/ChatRuntimeProvider.test.tsx | 82 +++++ app/src/store/chatRuntimeSlice.test.ts | 70 +++++ app/src/store/chatRuntimeSlice.ts | 11 +- 6 files changed, 550 insertions(+), 31 deletions(-) diff --git a/app/src/pages/FlowCanvasPage.tsx b/app/src/pages/FlowCanvasPage.tsx index 4123b9d95a..cd81a4db3a 100644 --- a/app/src/pages/FlowCanvasPage.tsx +++ b/app/src/pages/FlowCanvasPage.tsx @@ -158,6 +158,19 @@ function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } +/** + * True when `title` is "unclaimed" — either blank or exactly the localized + * generic placeholder (`t('flows.page.newWorkflow')`, "New workflow") — i.e. + * nothing user-meaningful has been set yet. Used to decide whether accepting + * a copilot proposal is allowed to adopt `proposal.name` as the flow title: + * it must never clobber a user-chosen or description-derived name. Pure and + * exported for direct unit testing without rendering `FlowEditor`. + */ +export function isPlaceholderTitle(title: string, placeholder: string): boolean { + const trimmed = title.trim(); + return trimmed === '' || trimmed === placeholder.trim(); +} + function BackIcon() { return ( (editorFlow.name); const commitRename = useCallback(async () => { const trimmed = titleDraft.trim(); @@ -311,6 +332,7 @@ function FlowEditor({ log('rename: flow id=%s name=%s', flowId, trimmed); await updateFlow(flowId, { name: trimmed }); setName(trimmed); + persistedNameRef.current = trimmed; } catch (err) { log('rename failed: id=%s err=%o', flowId, err); setTitleDraft(name); @@ -422,12 +444,44 @@ function FlowEditor({ [draftGraph] ); - const handleAcceptProposal = useCallback((proposal: WorkflowProposal) => { - log('copilot proposal accepted'); - setDraftGraph(proposal.graph as WorkflowGraph); - setPreview(null); - setCanvasVersion(v => v + 1); - }, []); + const handleAcceptProposal = useCallback( + (proposal: WorkflowProposal) => { + log('copilot proposal accepted'); + setDraftGraph(proposal.graph as WorkflowGraph); + setPreview(null); + setCanvasVersion(v => v + 1); + + // Adopt the proposal's name into the flow title, but ONLY while the + // title is still the generic placeholder — never clobber a user-chosen + // or description-derived meaningful name. This does not persist by + // itself (matching the "accept doesn't persist" invariant below) — it + // rides into the next Save via `name`/`handleSave`. + // + // Check the VISIBLE `titleDraft`, not the committed `name` — `name` + // only updates on blur/Enter via `commitRename`, so if the user is + // mid-typing a custom title (or a rename is still in flight) when a + // proposal is accepted, `name` can still read as the stale placeholder + // while `titleDraft` already holds the user's real input. Deciding off + // `name` would silently clobber that in-progress input. Also skip + // entirely while `renaming` is true — an in-flight `commitRename` + // persist must not race with a local proposal-driven rename. + const proposedName = proposal.name?.trim(); + if ( + proposedName && + !renaming && + isPlaceholderTitle(titleDraft, t('flows.page.newWorkflow')) + ) { + // Log shape, not the user-authored name (no PII in logs). + log( + 'copilot proposal accepted: adopting proposed name into placeholder title, isDraft=%s', + isDraft + ); + setName(proposedName); + setTitleDraft(proposedName); + } + }, + [titleDraft, renaming, t, isDraft] + ); const handleRejectProposal = useCallback(() => { log('copilot proposal rejected'); @@ -453,9 +507,15 @@ function FlowEditor({ () => ({ schema_version: graph.schema_version, id: flowId ?? undefined, name }), [graph.schema_version, flowId, name] ); + // Also dirty when a copilot-adopted proposal name has changed the flow's + // `name` without yet persisting it (`persistedNameRef` only advances on a + // real Save/rename) — a name-only proposal (same graph, new name) must + // still enable Save, or the adopted title can never be persisted. const initialDirty = useMemo( - () => JSON.stringify(editorGraph) !== JSON.stringify(persistedGraphRef.current), - [editorGraph] + () => + JSON.stringify(editorGraph) !== JSON.stringify(persistedGraphRef.current) || + name !== persistedNameRef.current, + [editorGraph, name] ); // Repair seed for the copilot: bind the run context to the CURRENT draft. @@ -503,11 +563,30 @@ function FlowEditor({ navigate(`/flows/${created.id}`, { replace: true }); return; } - log('save: flow id=%s nodes=%d edges=%d', flowId, next.nodes.length, next.edges.length); - const updated = await updateFlow(flowId, { graph: next }); + // Only include `name` in the update payload when it actually diverges + // from the last-known-persisted baseline (a manual rename already + // persisted it via `commitRename`; a copilot-adopted placeholder name + // has not) — keeps the update metadata-safe and avoids needless renames. + const nameChanged = name !== persistedNameRef.current; + log( + 'save: flow id=%s nodes=%d edges=%d nameChanged=%s', + flowId, + next.nodes.length, + next.edges.length, + nameChanged + ); + const updated = await updateFlow(flowId, { graph: next, ...(nameChanged ? { name } : {}) }); const persisted = updated.graph as WorkflowGraph; persistedGraphRef.current = persisted; + persistedNameRef.current = updated.name; setDraftGraph(persisted); + if (updated.name !== name) { + // Re-sync BOTH title states from the response — leaving `titleDraft` + // stale would show the pre-save value in the input and could + // resubmit it verbatim on a later blur. + setName(updated.name); + setTitleDraft(updated.name); + } setCanvasVersion(v => v + 1); log( 'save: flow id=%s persisted — canvas re-synced from response nodes=%d edges=%d', diff --git a/app/src/pages/__tests__/FlowCanvasPage.test.tsx b/app/src/pages/__tests__/FlowCanvasPage.test.tsx index 0303e31bbd..62c416ce8e 100644 --- a/app/src/pages/__tests__/FlowCanvasPage.test.tsx +++ b/app/src/pages/__tests__/FlowCanvasPage.test.tsx @@ -10,10 +10,12 @@ import { createMemoryRouter, MemoryRouter, Route, RouterProvider, Routes } from import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Flow } from '../../services/api/flowsApi'; +import type { WorkflowProposal } from '../../store/chatRuntimeSlice'; import FlowCanvasPage, { asCopilotBuildSeed, asCopilotPrefillSeed, FlowCanvasDraftPage, + isPlaceholderTitle, } from '../FlowCanvasPage'; const getFlow = vi.hoisted(() => vi.fn()); @@ -388,6 +390,288 @@ describe('FlowCanvasPage', () => { }); }); +describe('isPlaceholderTitle', () => { + it('treats an empty or whitespace-only title as a placeholder', () => { + expect(isPlaceholderTitle('', 'New workflow')).toBe(true); + expect(isPlaceholderTitle(' ', 'New workflow')).toBe(true); + }); + + it('treats the localized generic placeholder as a placeholder', () => { + expect(isPlaceholderTitle('New workflow', 'New workflow')).toBe(true); + expect(isPlaceholderTitle(' New workflow ', 'New workflow')).toBe(true); + }); + + it('does not treat a user-chosen or description-derived name as a placeholder', () => { + expect(isPlaceholderTitle('My flow', 'New workflow')).toBe(false); + expect(isPlaceholderTitle('Standup reminder', 'New workflow')).toBe(false); + }); +}); + +// ----------------------------------------------------------------------------- +// Copilot proposal name adoption — accepting a `propose_workflow` proposal +// carries a top-level `name` the canvas previously dropped, leaving the flow +// titled the generic placeholder even when the agent proposed a real name. +// ----------------------------------------------------------------------------- +function makeProposal(overrides: Partial = {}): WorkflowProposal { + return { + name: 'Standup reminder', + graph: { + schema_version: 1, + name: 'Standup reminder', + nodes: [ + { + id: 't', + kind: 'trigger', + name: 'Start', + config: {}, + ports: [], + position: { x: 0, y: 0 }, + }, + { + id: 'a', + kind: 'agent', + name: 'Send reminder', + config: {}, + ports: [], + position: { x: 80, y: 80 }, + }, + ], + edges: [], + }, + requireApproval: false, + summary: { trigger: 'manual', steps: [] }, + ...overrides, + }; +} + +describe('FlowCanvasPage copilot proposal name adoption', () => { + beforeEach(() => { + copilotPanelProps.current = null; + getFlow.mockReset(); + updateFlow.mockReset(); + createFlow.mockReset(); + validateFlow.mockReset(); + listFlowConnections.mockReset(); + validateFlow.mockResolvedValue({ valid: true, errors: [], warnings: [] }); + listFlowConnections.mockResolvedValue([]); + }); + + function renderEditor(id = 'test-id') { + return render( + + + } /> + Flows list

} /> + + + ); + } + + it('adopts the proposal name when the title is the generic placeholder', async () => { + getFlow.mockResolvedValue(makeFlow({ name: 'New workflow' })); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('New workflow'); + + act(() => { + (copilotPanelProps.current?.onAccept as (p: WorkflowProposal) => void)(makeProposal()); + }); + + await waitFor(() => + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('Standup reminder') + ); + }); + + it('adopts the proposal name when the title is blank', async () => { + getFlow.mockResolvedValue(makeFlow({ name: '' })); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + act(() => { + (copilotPanelProps.current?.onAccept as (p: WorkflowProposal) => void)(makeProposal()); + }); + + await waitFor(() => + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('Standup reminder') + ); + }); + + it('does not clobber a user-set title when accepting a proposal', async () => { + getFlow.mockResolvedValue(makeFlow({ name: 'My flow' })); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + act(() => { + (copilotPanelProps.current?.onAccept as (p: WorkflowProposal) => void)(makeProposal()); + }); + + // Give any (incorrect) state update a chance to flush, then assert the + // title is unchanged. + await Promise.resolve(); + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('My flow'); + }); + + // Regression (CodeRabbit on #4886): the committed `name` only updates on + // blur/Enter (`commitRename`), so while the user is still typing a custom + // title the committed `name` can read as the stale placeholder even though + // the visible input already holds real user input. Adoption must check the + // VISIBLE `titleDraft`, or it clobbers in-progress typing. + it('does not clobber an in-progress (uncommitted) title edit when accepting a proposal', async () => { + getFlow.mockResolvedValue(makeFlow({ name: 'New workflow' })); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + // User is mid-typing a custom title — not yet committed via blur/Enter. + fireEvent.change(screen.getByTestId('flow-canvas-title'), { + target: { value: 'My in-progress title' }, + }); + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('My in-progress title'); + + act(() => { + (copilotPanelProps.current?.onAccept as (p: WorkflowProposal) => void)(makeProposal()); + }); + + await Promise.resolve(); + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('My in-progress title'); + }); + + it('includes the adopted name in the flows_update payload on Save (persisted flow)', async () => { + getFlow.mockResolvedValue(makeFlow({ name: 'New workflow' })); + updateFlow.mockResolvedValue(makeFlow({ name: 'Standup reminder' })); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + act(() => { + (copilotPanelProps.current?.onAccept as (p: WorkflowProposal) => void)(makeProposal()); + }); + await waitFor(() => + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('Standup reminder') + ); + + fireEvent.click(screen.getByTestId('flow-editor-save')); + fireEvent.click(screen.getByTestId('flow-action-confirm-accept')); + + await waitFor(() => expect(updateFlow).toHaveBeenCalledTimes(1)); + const [calledId, update] = updateFlow.mock.calls[0]; + expect(calledId).toBe('test-id'); + expect(update.name).toBe('Standup reminder'); + expect(update.graph).toBeDefined(); + }); + + // Regression (CodeRabbit on #4886): accepting a proposal that changes only + // the top-level `name` (graph unchanged) previously left the editor's dirty + // state false — since the graph-only diff saw no change — so Save stayed + // disabled and the adopted title could never be persisted. + it('marks the editor dirty when an accepted proposal changes only the name', async () => { + const flow = makeFlow({ name: 'New workflow' }); + getFlow.mockResolvedValue(flow); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + // Clean on load. + expect(screen.queryByTestId('flow-editor-dirty')).not.toBeInTheDocument(); + + act(() => { + (copilotPanelProps.current?.onAccept as (p: WorkflowProposal) => void)( + makeProposal({ name: 'Standup reminder', graph: flow.graph }) + ); + }); + + await waitFor(() => + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('Standup reminder') + ); + // Graph is byte-identical to the persisted baseline — only the name + // changed — but the editor must still report dirty so Save is enabled. + await waitFor(() => expect(screen.getByTestId('flow-editor-dirty')).toBeInTheDocument()); + + fireEvent.click(screen.getByTestId('flow-editor-save')); + fireEvent.click(screen.getByTestId('flow-action-confirm-accept')); + + await waitFor(() => expect(updateFlow).toHaveBeenCalledTimes(1)); + const [, update] = updateFlow.mock.calls[0]; + expect(update.name).toBe('Standup reminder'); + }); + + // Regression (CodeRabbit on #4886): when the backend returns a name that + // differs from what was submitted (server-side normalization), the title + // input must re-sync to the persisted value too — not just the committed + // `name` — or the stale draft can be resubmitted verbatim on a later blur. + it('re-syncs titleDraft from the persisted response name on Save', async () => { + getFlow.mockResolvedValue(makeFlow({ name: 'New workflow' })); + updateFlow.mockResolvedValue(makeFlow({ name: 'Standup Reminder (normalized)' })); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + act(() => { + (copilotPanelProps.current?.onAccept as (p: WorkflowProposal) => void)(makeProposal()); + }); + await waitFor(() => + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('Standup reminder') + ); + + fireEvent.click(screen.getByTestId('flow-editor-save')); + fireEvent.click(screen.getByTestId('flow-action-confirm-accept')); + + await waitFor(() => expect(updateFlow).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('Standup Reminder (normalized)') + ); + }); + + it('passes the adopted name to createFlow on Save (draft flow)', async () => { + createFlow.mockResolvedValue(makeFlow({ id: 'created-id', name: 'Standup reminder' })); + render( + + + } /> + } /> + + + ); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('New workflow'); + + act(() => { + (copilotPanelProps.current?.onAccept as (p: WorkflowProposal) => void)(makeProposal()); + }); + await waitFor(() => + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('Standup reminder') + ); + + fireEvent.click(screen.getByTestId('flow-editor-save')); + fireEvent.click(screen.getByTestId('flow-action-confirm-accept')); + + await waitFor(() => expect(createFlow).toHaveBeenCalledTimes(1)); + const [name] = createFlow.mock.calls[0]; + expect(name).toBe('Standup reminder'); + expect(updateFlow).not.toHaveBeenCalled(); + }); +}); + describe('asCopilotBuildSeed', () => { it('accepts a copilotBuild state with a non-empty description', () => { expect(asCopilotBuildSeed({ copilotBuild: { description: 'digest my Slack' } })).toEqual({ diff --git a/app/src/providers/ChatRuntimeProvider.tsx b/app/src/providers/ChatRuntimeProvider.tsx index e93b985077..ba137661d4 100644 --- a/app/src/providers/ChatRuntimeProvider.tsx +++ b/app/src/providers/ChatRuntimeProvider.tsx @@ -272,30 +272,27 @@ function chatTurnUsagePayload(event: ChatDoneEvent): { * card. */ /** - * Tool names whose successful `output` carries a `workflow_proposal` payload. - * `propose_workflow` (first draft) and `revise_workflow` (iterative refine) - * both return the identical wire shape (see `src/openhuman/flows/builder_tools.rs`), - * so the runtime surfaces a `WorkflowProposalCard` from either. These run inside - * the `workflow_builder` specialist — reached either as the main agent's own - * tool or, in the Flows copilot / prompt-bar flow, as a delegated subagent - * (`build_workflow`) — so BOTH `onToolResult` and `onSubagentToolResult` funnel - * through {@link maybeParseWorkflowProposalTool}. - */ -const WORKFLOW_PROPOSAL_TOOLS = new Set(['propose_workflow', 'revise_workflow']); - -/** - * If a completed tool result is a successful workflow-builder proposal - * (`propose_workflow`/`revise_workflow`), parse it. Returns `null` for anything - * else so callers can cheaply gate on it. Keyed by the tool NAME + success, not - * by agent, so a proposal surfaces whether the tool ran in the main agent or in - * the delegated `workflow_builder` worker. + * Recognition is content-based, not name-based: ANY workflow-builder tool + * (`propose_workflow`, `revise_workflow`, `edit_workflow`, and any future + * addition) whose successful `output` is `{ type: "workflow_proposal", ... }` + * (see `src/openhuman/flows/builder_tools.rs` / `ops::build_builder_proposal`) + * is surfaced as a `WorkflowProposalCard`. This mirrors the Rust-side blocking + * path's `extract_workflow_proposal`, which also scans tool results by + * payload `type` rather than tool name — a name allowlist here can silently + * drop proposals from newly added tools (as happened when `edit_workflow` was + * added without updating this list). `parseWorkflowProposal`'s own + * `obj.type !== 'workflow_proposal'` check is the only gate needed. These + * tools run inside the `workflow_builder` specialist — reached either as the + * main agent's own tool or, in the Flows copilot / prompt-bar flow, as a + * delegated subagent (`build_workflow`) — so BOTH `onToolResult` and + * `onSubagentToolResult` funnel through {@link maybeParseWorkflowProposalTool}. */ function maybeParseWorkflowProposalTool( - toolName: string, + _toolName: string, success: boolean, output: string | undefined ): WorkflowProposal | null { - if (!success || !WORKFLOW_PROPOSAL_TOOLS.has(toolName) || !output) return null; + if (!success || !output) return null; return parseWorkflowProposal(output); } diff --git a/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx b/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx index 353a349a20..ed25001852 100644 --- a/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx +++ b/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx @@ -1490,6 +1490,88 @@ describe('ChatRuntimeProvider — dedupe, proactive resolution, mid-turn invaria summary: { trigger: 'signup.created' }, }); }); + + // Regression (#4876 fallout): `edit_workflow` is the prompt-preferred way + // to iterate on an existing draft, and returns the identical + // `{ type: "workflow_proposal", ... }` payload as `propose_workflow` / + // `revise_workflow`. Proposal recognition is content-based (on the + // payload's `type`), not gated on a fixed tool-name allowlist, so this + // must surface a proposal exactly like the other two tools do — a + // name-based allowlist previously dropped it silently. + it('surfaces a workflow proposal from the main-agent edit_workflow tool', () => { + const listeners = renderProvider(); + const threadId = 't-edit-workflow'; + + act(() => { + listeners.onToolCall?.({ + thread_id: threadId, + request_id: 'r1', + round: 0, + tool_name: 'edit_workflow', + skill_id: 'flows', + args: {}, + tool_call_id: 'call-edit-1', + }); + listeners.onToolResult?.({ + thread_id: threadId, + request_id: 'r1', + round: 0, + tool_name: 'edit_workflow', + skill_id: 'flows', + success: true, + output: JSON.stringify({ + type: 'workflow_proposal', + name: 'Digest patch', + graph: { nodes: [], edges: [] }, + require_approval: true, + draft_id: 'draft-42', + summary: { trigger: 'manual', steps: [] }, + }), + tool_call_id: 'call-edit-1', + }); + }); + + const proposal = store.getState().chatRuntime.pendingWorkflowProposalsByThread[threadId]; + expect(proposal).toMatchObject({ name: 'Digest patch', requireApproval: true }); + }); + + // Any future tool that returns `{ type: "workflow_proposal" }` must be + // recognised too — the gate is the payload shape, not a tool-name list. + it('surfaces a workflow proposal from an unrecognised future tool name', () => { + const listeners = renderProvider(); + const threadId = 't-future-tool'; + + act(() => { + listeners.onToolCall?.({ + thread_id: threadId, + request_id: 'r1', + round: 0, + tool_name: 'some_future_builder_tool', + skill_id: 'flows', + args: {}, + tool_call_id: 'call-future-1', + }); + listeners.onToolResult?.({ + thread_id: threadId, + request_id: 'r1', + round: 0, + tool_name: 'some_future_builder_tool', + skill_id: 'flows', + success: true, + output: JSON.stringify({ + type: 'workflow_proposal', + name: 'Future proposal', + graph: { nodes: [], edges: [] }, + require_approval: true, + summary: { trigger: 'manual', steps: [] }, + }), + tool_call_id: 'call-future-1', + }); + }); + + const proposal = store.getState().chatRuntime.pendingWorkflowProposalsByThread[threadId]; + expect(proposal).toMatchObject({ name: 'Future proposal' }); + }); }); // Regression: on Windows users report being "locked out" of the composer diff --git a/app/src/store/chatRuntimeSlice.test.ts b/app/src/store/chatRuntimeSlice.test.ts index b41ff78290..05c97de39a 100644 --- a/app/src/store/chatRuntimeSlice.test.ts +++ b/app/src/store/chatRuntimeSlice.test.ts @@ -18,6 +18,7 @@ import chatRuntimeReducer, { setPendingApprovalForThread, setQueueStatusForThread, setToolTimelineForThread, + setWorkflowProposalForThread, } from './chatRuntimeSlice'; function makeRun(id: string, status: AgentRunStatus): AgentRun { @@ -689,6 +690,75 @@ describe('hydrateRuntimeFromSnapshot — live-driver guard', () => { }); }); +// Regression: `fetchAndHydrateTurnState` (via `hydrateRuntimeFromSnapshot`) +// fires on thread rehydration (e.g. the always-open Flows copilot re-opening +// a persisted thread, #4874). A workflow proposal is a client-only flag with +// no server-side record, so a rehydrate must not resurrect a *stale* one from +// a crashed prior session — but it must also not wipe a proposal the +// streaming/blocking path set THIS session, moments before a `completed` +// snapshot for the same settled turn lands. Only `interrupted` (genuine +// crashed-mid-flight cleanup) should clear it. +describe('hydrateRuntimeFromSnapshot — workflow proposal race guard', () => { + function makeProposal(name: string) { + return { + name, + graph: { nodes: [], edges: [] }, + requireApproval: true, + summary: { trigger: 'manual', steps: [] }, + }; + } + + function makeSnapshot( + threadId: string, + lifecycle: PersistedTurnState['lifecycle'] + ): PersistedTurnState { + return { + threadId, + requestId: 'req-1', + lifecycle, + iteration: 3, + maxIterations: 10, + streamingText: '', + thinking: '', + toolTimeline: [], + startedAt: '2026-06-23T00:00:00Z', + updatedAt: '2026-06-23T00:00:00Z', + }; + } + + it('clears a pending proposal on an interrupted (crashed prior-session) snapshot', () => { + const store = makeStore(); + store.dispatch( + setWorkflowProposalForThread({ threadId: 't-crashed', proposal: makeProposal('Stale') }) + ); + + store.dispatch( + hydrateRuntimeFromSnapshot({ snapshot: makeSnapshot('t-crashed', 'interrupted') }) + ); + + expect( + store.getState().chatRuntime.pendingWorkflowProposalsByThread['t-crashed'] + ).toBeUndefined(); + }); + + it('preserves a pending proposal on a completed snapshot from this session', () => { + const store = makeStore(); + // The streaming/blocking path just set this moments before the + // rehydration thunk's `completed` snapshot lands for the same turn. + store.dispatch( + setWorkflowProposalForThread({ threadId: 't-settled', proposal: makeProposal('Fresh') }) + ); + + store.dispatch( + hydrateRuntimeFromSnapshot({ snapshot: makeSnapshot('t-settled', 'completed') }) + ); + + expect(store.getState().chatRuntime.pendingWorkflowProposalsByThread['t-settled']).toEqual( + makeProposal('Fresh') + ); + }); +}); + describe('hydrateRuntimeFromSnapshot — persisted tool result output', () => { it('maps the persisted output onto parent and sub-agent rows as result', () => { const store = makeStore(); diff --git a/app/src/store/chatRuntimeSlice.ts b/app/src/store/chatRuntimeSlice.ts index 549daf4517..5ca4e06890 100644 --- a/app/src/store/chatRuntimeSlice.ts +++ b/app/src/store/chatRuntimeSlice.ts @@ -1969,8 +1969,15 @@ const chatRuntimeSlice = createSlice({ delete state.pendingPlanReviewByThread[threadId]; // Same for a workflow proposal (B4) — it's a client-only "should the // card render" flag with no server-side record, so a rehydrate must - // not resurrect one left over from a previous session. - delete state.pendingWorkflowProposalsByThread[threadId]; + // not resurrect one left over from a previous session. But only clear + // it on a genuinely stale snapshot (`interrupted` = crashed mid-flight + // in a prior process): a `completed` snapshot can be this session's own + // just-settled turn, racing against the streaming/blocking path that + // set the proposal moments ago — clearing unconditionally here would + // wipe a proposal that's still pending the user's Accept/Reject. + if (snapshot.lifecycle === 'interrupted') { + delete state.pendingWorkflowProposalsByThread[threadId]; + } if (snapshot.taskBoard) { state.taskBoardByThread[threadId] = snapshot.taskBoard; } From b1fdc400b762164ba13606f99e0c4b27c199f56d Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:54:51 +0530 Subject: [PATCH 09/86] fix(flows): live-updating runs lists + row-action cleanup (enable toggle, delete in overflow) (#4890) --- app/src/components/flows/FlowListRow.test.tsx | 17 +- app/src/components/flows/FlowListRow.tsx | 97 ++++-------- .../components/flows/FlowRunsDrawer.test.tsx | 78 +++++++++- app/src/components/flows/FlowRunsDrawer.tsx | 44 +++++- app/src/components/flows/FlowRunsSidebar.tsx | 8 +- .../__tests__/useFlowRunsLiveRefresh.test.ts | 145 ++++++++++++++++++ app/src/hooks/useFlowRunsLiveRefresh.ts | 100 ++++++++++++ app/src/pages/FlowsPage.test.tsx | 6 +- app/src/pages/WorkflowRunsPage.test.tsx | 67 +++++++- app/src/pages/WorkflowRunsPage.tsx | 24 ++- 10 files changed, 497 insertions(+), 89 deletions(-) create mode 100644 app/src/hooks/__tests__/useFlowRunsLiveRefresh.test.ts create mode 100644 app/src/hooks/useFlowRunsLiveRefresh.ts diff --git a/app/src/components/flows/FlowListRow.test.tsx b/app/src/components/flows/FlowListRow.test.tsx index 05e7c24105..6405143cff 100644 --- a/app/src/components/flows/FlowListRow.test.tsx +++ b/app/src/components/flows/FlowListRow.test.tsx @@ -50,13 +50,13 @@ describe('FlowListRow', () => { it('renders the flow name and reflects enabled state on the toggle', () => { renderRow(); expect(screen.getByText('Daily digest')).toBeInTheDocument(); - // The toggle is an icon button; state is conveyed via aria-pressed, not text. - expect(screen.getByTestId('flow-toggle-flow-1')).toHaveAttribute('aria-pressed', 'true'); + // The toggle is a SettingsSwitch (role=switch); state is conveyed via aria-checked. + expect(screen.getByTestId('flow-toggle-flow-1')).toHaveAttribute('aria-checked', 'true'); }); it('reflects paused state on the toggle when disabled', () => { renderRow({ flow: makeFlow({ enabled: false }) }); - expect(screen.getByTestId('flow-toggle-flow-1')).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByTestId('flow-toggle-flow-1')).toHaveAttribute('aria-checked', 'false'); }); it('shows "Never run" when the flow has no last_run_at', () => { @@ -147,9 +147,16 @@ describe('FlowListRow', () => { expect(onDuplicate).toHaveBeenCalledWith(makeFlow()); }); - it('deletes via the direct Delete icon (not the menu)', () => { + it('routes Delete through the overflow menu', () => { const { onDelete } = renderRow(); - fireEvent.click(screen.getByTestId('flow-delete-flow-1')); + // Delete is a destructive secondary action now — behind the "⋯" menu, not + // a standalone icon button in the flat row. + expect(screen.queryByTestId('flow-delete-flow-1')).not.toBeInTheDocument(); + fireEvent.click(screen.getByTestId('flow-menu-flow-1')); + + const deleteItem = screen.getByTestId('flow-delete-flow-1'); + expect(deleteItem).toHaveTextContent('Delete'); + fireEvent.click(deleteItem); expect(onDelete).toHaveBeenCalledWith(makeFlow()); }); }); diff --git a/app/src/components/flows/FlowListRow.tsx b/app/src/components/flows/FlowListRow.tsx index ea063788a3..b1c6e5dcc5 100644 --- a/app/src/components/flows/FlowListRow.tsx +++ b/app/src/components/flows/FlowListRow.tsx @@ -3,14 +3,17 @@ * * Mirrors the row layout of `CoreJobList` * (`app/src/components/settings/panels/cron/CoreJobList.tsx`): name + status - * badge header, a line of run metadata, then a row of `Button` actions. Swaps - * the cron "pause/resume" text button for a `SettingsSwitch` toggle (the - * canonical boolean control — see `components/settings/controls`) since - * enable/disable here is a persistent setting, not a one-off action. + * badge header, a line of run metadata, then a row of `Button` actions. Uses + * the canonical `SettingsSwitch` boolean control (`components/settings/controls`) + * for enable/disable, since that's a persistent setting, not a one-off action — + * not an icon `Button`, so its state reads at a glance instead of needing a + * hover/title to disambiguate on vs. off. * * "View runs" (issue B5a.1) opens `FlowRunsDrawer` (mounted by `FlowsPage`) * for this flow's run history — re-added now that B3b's run inspector has - * landed and the drawer has somewhere to send the user. + * landed and the drawer has somewhere to send the user. Delete lives in the + * same overflow menu (destructive actions shouldn't sit in the flat button + * row next to Run/toggle, where a mis-click is one tap away). * * The flow name (issue B5b.1) is itself the "View" affordance for the new * read-only Workflow Canvas: it's rendered as a button that calls `onView`, @@ -22,6 +25,7 @@ */ import { useT } from '../../lib/i18n/I18nContext'; import type { Flow } from '../../services/api/flowsApi'; +import SettingsSwitch from '../settings/controls/SettingsSwitch'; import Button from '../ui/Button'; import FlowRowMenu from './FlowRowMenu'; @@ -33,41 +37,6 @@ function PlayIcon() { ); } -function PowerIcon() { - // On/off — enabled vs. paused (distinct from Run's play triangle). - return ( - - ); -} - -function TrashIcon() { - return ( - - ); -} - /** Which of this row's actions currently has a request in flight, if any. */ export type FlowListRowBusy = 'toggle' | 'run' | null; @@ -153,27 +122,18 @@ const FlowListRow = ({
{/* All controls sit together on the right: the toggle (enabled/paused — - the switch alone communicates state), then Run, Delete, and an overflow - menu with the secondary actions (view runs / export / duplicate). */} + the switch alone communicates state), then Run, and an overflow menu + with the secondary/destructive actions (view runs / export / + duplicate / delete). */}
- + aria-label={t('flows.list.toggleEnabled')} + data-testid={`flow-toggle-${flow.id}`} + /> - onDuplicate(flow), testId: `flow-duplicate-${flow.id}`, }, + { + key: 'delete', + label: t('flows.list.delete'), + onSelect: () => onDelete(flow), + danger: true, + testId: `flow-delete-${flow.id}`, + }, ]} />
diff --git a/app/src/components/flows/FlowRunsDrawer.test.tsx b/app/src/components/flows/FlowRunsDrawer.test.tsx index ad61f6ee90..43cf8096a4 100644 --- a/app/src/components/flows/FlowRunsDrawer.test.tsx +++ b/app/src/components/flows/FlowRunsDrawer.test.tsx @@ -12,7 +12,7 @@ * `FlowRunInspectorDrawer.test.tsx`) so this suite only exercises the * run-history list + the nesting contract between the two drawers. */ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { Provider } from 'react-redux'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -207,4 +207,80 @@ describe('FlowRunsDrawer', () => { fireEvent.keyDown(document, { key: 'Escape' }); expect(onClose).not.toHaveBeenCalled(); }); + + it('discards a stale background refetch after the drawer flips to a different flow (race guard)', async () => { + // Regression test for a codex review finding on this PR: the live-refresh + // `refetch` in FlowRunsDrawer didn't guard against a response landing + // after the drawer had already switched to a different flowId, so a slow + // flow-A refetch could clobber flow-B's already-rendered runs. + vi.useFakeTimers(); + try { + let flowACalls = 0; + let resolveStaleA: ((runs: FlowRun[]) => void) | undefined; + listFlowRuns.mockImplementation((flowId: string) => { + if (flowId === 'flow-a') { + flowACalls += 1; + if (flowACalls === 1) { + // Initial load: one active run so the live-refresh poll subscribes. + return Promise.resolve([ + makeRun({ id: 'run-a', flow_id: 'flow-a', status: 'running' }), + ]); + } + // The poll-triggered refetch — stays pending until resolved below, + // simulating a slow response that outlives the flow switch. + return new Promise(resolve => { + resolveStaleA = resolve; + }); + } + if (flowId === 'flow-b') { + return Promise.resolve([ + makeRun({ id: 'run-b', flow_id: 'flow-b', status: 'completed' }), + ]); + } + return Promise.resolve([]); + }); + + const { rerender } = render( + + + + ); + + // Flush the initial load's already-resolved promise. + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByTestId('flow-run-row-run-a')).toBeInTheDocument(); + + // Trigger the live-refresh poll fallback — issues the second, hanging + // listFlowRuns('flow-a') call. + await act(async () => { + vi.advanceTimersByTime(5_000); + }); + expect(flowACalls).toBe(2); + + // Flip the drawer to a different flow while that refetch is still in flight. + rerender( + + + + ); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByTestId('flow-run-row-run-b')).toBeInTheDocument(); + + // Now let the stale flow-a response land. + await act(async () => { + resolveStaleA?.([makeRun({ id: 'run-a-late', flow_id: 'flow-a', status: 'completed' })]); + await Promise.resolve(); + }); + + // Flow-b's runs must be unaffected by the late flow-a response. + expect(screen.getByTestId('flow-run-row-run-b')).toBeInTheDocument(); + expect(screen.queryByTestId('flow-run-row-run-a-late')).not.toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/app/src/components/flows/FlowRunsDrawer.tsx b/app/src/components/flows/FlowRunsDrawer.tsx index d2d55bd8ac..8f44848162 100644 --- a/app/src/components/flows/FlowRunsDrawer.tsx +++ b/app/src/components/flows/FlowRunsDrawer.tsx @@ -8,10 +8,11 @@ * to-close + Escape-to-close via `useEscapeKey`) so it renders as a fixed * overlay regardless of where the parent mounts it. * - * Data is a one-shot fetch via `listFlowRuns` — no polling here. The run - * inspector already polls a single run's live status via `useFlowRunPoller`; - * polling the whole list here would duplicate that logic for no benefit - * (the list only needs to be fresh when the drawer opens). + * Data loads via `listFlowRuns` on open, then stays live via + * {@link useFlowRunsLiveRefresh} while any run in the list is still active — + * so a run stuck on "Running" here updates without the user having to close + * and reopen the drawer. The run inspector separately polls a single run's + * live status via `useFlowRunPoller`; that's unrelated to this list refresh. * * Clicking a run sets `selectedRunId` and renders the existing * `FlowRunInspectorDrawer` stacked on top: both are `fixed inset-0 z-50` @@ -24,9 +25,10 @@ * single Escape press closes only the topmost overlay (the inspector) first. */ import debug from 'debug'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useEscapeKey } from '../../hooks/useEscapeKey'; +import { useFlowRunsLiveRefresh } from '../../hooks/useFlowRunsLiveRefresh'; import { useT } from '../../lib/i18n/I18nContext'; import { type FlowRun, listFlowRuns } from '../../services/api/flowsApi'; import { @@ -72,8 +74,15 @@ export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Pr const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [selectedRunId, setSelectedRunId] = useState(null); + // Tracks the flowId this drawer instance is *currently* showing, so an + // in-flight `refetch()` started for a previous flow (see below) can detect + // it's stale once the drawer flips to a new flowId and bail instead of + // clobbering the new flow's already-loaded runs. + const currentFlowIdRef = useRef(flowId); useEffect(() => { + currentFlowIdRef.current = flowId; + // Reset for the new target so a previous flow's runs/error can't linger // under a different flowId while the new fetch is in flight. setSelectedRunId(null); @@ -109,6 +118,31 @@ export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Pr }; }, [flowId]); + // Background refresh for the live-update hook below — deliberately doesn't + // touch `loading`/`error` so a poll tick or progress event never flashes + // the loading state or clobbers a real load error with a transient one. + // Guards against a stale response: if the drawer flips from flow A to flow + // B while an A refetch is still in flight, the late A response must not + // overwrite B's already-loaded runs (mirrors the `cancelled` guard on the + // main load effect above). + const refetch = useCallback(() => { + if (!flowId) return; + const requestFlowId = flowId; + listFlowRuns(requestFlowId) + .then(result => { + if (currentFlowIdRef.current !== requestFlowId) return; + setRuns(result); + log('refetched runs: flowId=%s count=%d', requestFlowId, result.length); + }) + .catch(err => { + if (currentFlowIdRef.current !== requestFlowId) return; + const msg = err instanceof Error ? err.message : String(err); + log('refetch failed: flowId=%s err=%s', requestFlowId, msg); + }); + }, [flowId]); + + useFlowRunsLiveRefresh(runs, refetch); + useEscapeKey( () => { log('escape: closing flowId=%s', flowId); diff --git a/app/src/components/flows/FlowRunsSidebar.tsx b/app/src/components/flows/FlowRunsSidebar.tsx index 42edb95c96..5decc46c1c 100644 --- a/app/src/components/flows/FlowRunsSidebar.tsx +++ b/app/src/components/flows/FlowRunsSidebar.tsx @@ -3,8 +3,9 @@ * dynamic left sidebar while a flow is open on the canvas (`/flows/:id`). A * compact, scannable run history (status dot + status + relative time); clicking * a run opens the full {@link FlowRunInspectorDrawer} (which polls its live - * status). One-shot fetch via `listFlowRuns` with a manual refresh — the engine - * emits no list-level socket events, so this mirrors `FlowRunsDrawer`'s model. + * status). Fetches via `listFlowRuns`, with a manual refresh button plus + * {@link useFlowRunsLiveRefresh} keeping the list itself live while any run + * shown here is still active (no manual refresh/navigate-away required). * * Rendered by `FlowCanvasPage` inside a `SidebarContent` portal, so it only * appears for a persisted flow (a draft has no runs yet). @@ -13,6 +14,7 @@ import createDebug from 'debug'; import { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { useFlowRunsLiveRefresh } from '../../hooks/useFlowRunsLiveRefresh'; import { useT } from '../../lib/i18n/I18nContext'; import { type FlowRun, listFlowRuns } from '../../services/api/flowsApi'; import { CenteredLoadingState, ErrorBanner } from '../ui/LoadingState'; @@ -98,6 +100,8 @@ export default function FlowRunsSidebar({ flowId }: FlowRunsSidebarProps) { void load(); }, [load]); + useFlowRunsLiveRefresh(runs, load); + return (
diff --git a/app/src/hooks/__tests__/useFlowRunsLiveRefresh.test.ts b/app/src/hooks/__tests__/useFlowRunsLiveRefresh.test.ts new file mode 100644 index 0000000000..2ba90066a3 --- /dev/null +++ b/app/src/hooks/__tests__/useFlowRunsLiveRefresh.test.ts @@ -0,0 +1,145 @@ +/** + * useFlowRunsLiveRefresh — unit tests. + * + * Verifies: no subscription/poll when every run is terminal, subscribe + + * poll when a run is active, a trailing-debounced refetch on a matching + * `flow:run_progress`/`flow_run_progress` event, teardown once the runs + * settle to all-terminal, and cleanup on unmount. + */ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { FlowRun } from '../../services/api/flowsApi'; +import { useFlowRunsLiveRefresh } from '../useFlowRunsLiveRefresh'; + +const handlers = vi.hoisted(() => new Map void>>()); +const on = vi.hoisted(() => + vi.fn((event: string, cb: (data: unknown) => void) => { + const set = handlers.get(event) ?? new Set(); + set.add(cb); + handlers.set(event, set); + }) +); +const off = vi.hoisted(() => + vi.fn((event: string, cb: (data: unknown) => void) => { + handlers.get(event)?.delete(cb); + }) +); +vi.mock('../../services/socketService', () => ({ socketService: { on, off } })); + +function emit(event: 'flow:run_progress' | 'flow_run_progress', payload: unknown) { + act(() => { + for (const cb of handlers.get(event) ?? []) cb(payload); + }); +} + +function makeRun(overrides: Partial = {}): FlowRun { + return { + id: 'run-1', + flow_id: 'flow-1', + thread_id: 'run-1', + status: 'running', + started_at: '2026-01-01T00:00:00Z', + steps: [], + pending_approvals: [], + ...overrides, + }; +} + +describe('useFlowRunsLiveRefresh', () => { + beforeEach(() => { + handlers.clear(); + on.mockClear(); + off.mockClear(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not subscribe or poll when every run is terminal', () => { + const refetch = vi.fn(); + renderHook(() => + useFlowRunsLiveRefresh( + [makeRun({ status: 'completed' }), makeRun({ id: 'run-2', status: 'failed' })], + refetch + ) + ); + + expect(on).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(20_000); + expect(refetch).not.toHaveBeenCalled(); + }); + + it('subscribes to both event aliases and polls on a fallback interval when a run is active', () => { + const refetch = vi.fn(); + renderHook(() => useFlowRunsLiveRefresh([makeRun({ status: 'running' })], refetch)); + + expect(on).toHaveBeenCalledWith('flow:run_progress', expect.any(Function)); + expect(on).toHaveBeenCalledWith('flow_run_progress', expect.any(Function)); + + vi.advanceTimersByTime(5_000); + expect(refetch).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(5_000); + expect(refetch).toHaveBeenCalledTimes(2); + }); + + it('debounces a burst of flow:run_progress events into a single trailing refetch', () => { + const refetch = vi.fn(); + renderHook(() => useFlowRunsLiveRefresh([makeRun({ status: 'running' })], refetch)); + + // Keep every emit + the final debounce settle comfortably inside the + // first 5s poll tick so the poll fallback doesn't also fire here — that + // interplay is covered separately by the "poll fallback" test above. + emit('flow:run_progress', { run_id: 'run-1', node_id: 'a', status: 'success' }); + vi.advanceTimersByTime(500); + emit('flow:run_progress', { run_id: 'run-1', node_id: 'b', status: 'success' }); + vi.advanceTimersByTime(500); + emit('flow_run_progress', { run_id: 'run-1', node_id: 'c', status: 'success' }); + + // Still within the 3s trailing window from the last event — no refetch yet. + expect(refetch).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(3_000); + expect(refetch).toHaveBeenCalledTimes(1); + }); + + it('tears down the subscription and poll once the runs settle to all-terminal', () => { + const refetch = vi.fn(); + const { rerender } = renderHook(({ runs }) => useFlowRunsLiveRefresh(runs, refetch), { + initialProps: { runs: [makeRun({ status: 'running' })] }, + }); + + expect(on).toHaveBeenCalledTimes(2); + + rerender({ runs: [makeRun({ status: 'completed' })] }); + + expect(off).toHaveBeenCalledWith('flow:run_progress', expect.any(Function)); + expect(off).toHaveBeenCalledWith('flow_run_progress', expect.any(Function)); + + refetch.mockClear(); + vi.advanceTimersByTime(20_000); + expect(refetch).not.toHaveBeenCalled(); + }); + + it('cleans up the subscription, poll, and any pending debounce on unmount', () => { + const refetch = vi.fn(); + const { unmount } = renderHook(() => + useFlowRunsLiveRefresh([makeRun({ status: 'running' })], refetch) + ); + + emit('flow:run_progress', { run_id: 'run-1', node_id: 'a', status: 'success' }); + + unmount(); + + expect(off).toHaveBeenCalledWith('flow:run_progress', expect.any(Function)); + expect(off).toHaveBeenCalledWith('flow_run_progress', expect.any(Function)); + + refetch.mockClear(); + vi.advanceTimersByTime(20_000); + expect(refetch).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/hooks/useFlowRunsLiveRefresh.ts b/app/src/hooks/useFlowRunsLiveRefresh.ts new file mode 100644 index 0000000000..ee96d16687 --- /dev/null +++ b/app/src/hooks/useFlowRunsLiveRefresh.ts @@ -0,0 +1,100 @@ +/** + * useFlowRunsLiveRefresh — keeps a runs LIST fresh while any run in it is + * still active, so "Running" doesn't go stale until the user manually + * refreshes or navigates away. + * + * The backend's `FlowRunObserver` publishes `DomainEvent::FlowRunProgress` on + * each finished step, and the core socket bridge re-emits it to the frontend + * as both `flow:run_progress` and `flow_run_progress` (colon + underscore + * aliases — see `useFlowRunProgress`, whose subscribe/teardown style this + * mirrors). There is, however, no terminal/completion socket event today — + * a run transitioning `running` -> `completed`/`failed`/etc. emits nothing — + * so a step-progress subscription alone can't catch that final refresh. This + * hook therefore layers a 5s poll fallback on top of the socket subscription: + * the socket keeps the refetch snappy while steps are landing, and the poll + * guarantees the terminal transition (which has no event) still gets picked + * up within a few seconds. + * + * This is deliberately dumb about *which* run changed — callers pass the + * list they already fetched and a `refetch` that reloads it; this hook just + * decides *when* to call `refetch` again. `FlowRunsSidebar`, `FlowRunsDrawer`, + * and `WorkflowRunsPage` all wire it onto their existing one-shot fetchers. + */ +import debug from 'debug'; +import { useEffect, useRef } from 'react'; + +import type { FlowRun, FlowRunStatus } from '../services/api/flowsApi'; +import { socketService } from '../services/socketService'; + +const log = debug('flows:runs-live-refresh'); + +/** Socket event aliases the core bridge emits (colon + underscore forms). */ +const EVENT_COLON = 'flow:run_progress'; +const EVENT_UNDERSCORE = 'flow_run_progress'; + +/** Statuses a run never leaves once reached — no further refetch needed for it. */ +const TERMINAL_STATUSES = new Set([ + 'completed', + 'completed_with_warnings', + 'failed', + 'cancelled', +]); + +/** Trailing debounce window for a burst of `flow:run_progress` events. */ +const DEBOUNCE_MS = 3_000; + +/** Poll fallback cadence — catches the terminal transition, which has no socket event. */ +const POLL_INTERVAL_MS = 5_000; + +/** + * Subscribes to live run-progress events (debounced) and polls on a fallback + * interval while `runs` contains at least one non-terminal run, calling + * `refetch` to reload the list. Subscribes to nothing — and tears down any + * existing subscription/poll — once every run has settled or on unmount. + */ +export function useFlowRunsLiveRefresh(runs: FlowRun[], refetch: () => void): void { + // Keep the latest `refetch` available to the effect without retriggering + // subscribe/unsubscribe every time the caller passes a new closure. + const refetchRef = useRef(refetch); + refetchRef.current = refetch; + + const hasActive = runs.some(run => !TERMINAL_STATUSES.has(run.status)); + + useEffect(() => { + if (!hasActive) return; + + let debounceTimer: ReturnType | null = null; + const scheduleRefetch = () => { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + log('debounced refetch firing'); + refetchRef.current(); + }, DEBOUNCE_MS); + }; + + const handleProgress = () => { + log('progress event received — scheduling debounced refetch'); + scheduleRefetch(); + }; + + log('subscribing: at least one active run'); + socketService.on(EVENT_COLON, handleProgress); + socketService.on(EVENT_UNDERSCORE, handleProgress); + + const pollId = setInterval(() => { + log('poll fallback refetch'); + refetchRef.current(); + }, POLL_INTERVAL_MS); + + return () => { + log('tearing down: unsubscribing + clearing poll/debounce timers'); + socketService.off(EVENT_COLON, handleProgress); + socketService.off(EVENT_UNDERSCORE, handleProgress); + clearInterval(pollId); + if (debounceTimer) clearTimeout(debounceTimer); + }; + }, [hasActive]); +} + +export default useFlowRunsLiveRefresh; diff --git a/app/src/pages/FlowsPage.test.tsx b/app/src/pages/FlowsPage.test.tsx index 4b0fb94f73..0e10805680 100644 --- a/app/src/pages/FlowsPage.test.tsx +++ b/app/src/pages/FlowsPage.test.tsx @@ -131,9 +131,9 @@ describe('FlowsPage', () => { fireEvent.click(screen.getByTestId('flow-toggle-flow-1')); expect(setFlowEnabled).toHaveBeenCalledWith('flow-1', false); - // The toggle is an icon button now; state is conveyed via aria-pressed. + // The toggle is a SettingsSwitch (role=switch) now; state is conveyed via aria-checked. await waitFor(() => - expect(screen.getByTestId('flow-toggle-flow-1')).toHaveAttribute('aria-pressed', 'false') + expect(screen.getByTestId('flow-toggle-flow-1')).toHaveAttribute('aria-checked', 'false') ); }); @@ -275,6 +275,8 @@ describe('FlowsPage', () => { deleteFlow.mockResolvedValue('flow-1'); renderWithProviders(, { initialEntries: ['/?view=main'] }); + // Delete now lives behind the row's "⋯" overflow menu, alongside + // Export/Duplicate, rather than a standalone icon button. fireEvent.click(await screen.findByTestId('flow-menu-flow-1')); fireEvent.click(await screen.findByTestId('flow-delete-flow-1')); diff --git a/app/src/pages/WorkflowRunsPage.test.tsx b/app/src/pages/WorkflowRunsPage.test.tsx index 6867115507..d03d4e66bf 100644 --- a/app/src/pages/WorkflowRunsPage.test.tsx +++ b/app/src/pages/WorkflowRunsPage.test.tsx @@ -1,5 +1,5 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import WorkflowRunsPage from './WorkflowRunsPage'; @@ -75,4 +75,67 @@ describe('WorkflowRunsPage', () => { await waitFor(() => expect(screen.getByText('rpc down')).toBeInTheDocument()); }); + + describe('live refresh (useFlowRunsLiveRefresh integration)', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('re-fetches just the runs (not listFlows) on the live-refresh poll while a run is active', async () => { + vi.useFakeTimers(); + listAllFlowRuns + .mockResolvedValueOnce([ + { id: 'r1', flow_id: 'f1', status: 'running', started_at: '2026-01-01T00:00:00Z' }, + ]) + .mockResolvedValueOnce([ + { id: 'r1', flow_id: 'f1', status: 'completed', started_at: '2026-01-01T00:00:00Z' }, + ]); + listFlows.mockResolvedValue([{ id: 'f1', name: 'Daily digest' }]); + + render(); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByTestId('workflow-runs-list')).toBeInTheDocument(); + expect(listAllFlowRuns).toHaveBeenCalledTimes(1); + expect(listFlows).toHaveBeenCalledTimes(1); + + // The 5s poll fallback fires `refetchRuns` while the one run is still 'running'. + await act(async () => { + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + }); + + // The poll fallback re-fetched just the runs — `listFlows` is not called again. + expect(listAllFlowRuns).toHaveBeenCalledTimes(2); + expect(listFlows).toHaveBeenCalledTimes(1); + }); + + it('does not surface an error banner when a background refetch fails', async () => { + vi.useFakeTimers(); + listAllFlowRuns + .mockResolvedValueOnce([ + { id: 'r1', flow_id: 'f1', status: 'running', started_at: '2026-01-01T00:00:00Z' }, + ]) + .mockRejectedValueOnce(new Error('transient rpc blip')); + listFlows.mockResolvedValue([{ id: 'f1', name: 'Daily digest' }]); + + render(); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByTestId('workflow-runs-list')).toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + }); + + expect(listAllFlowRuns).toHaveBeenCalledTimes(2); + // The transient background failure is logged only — the list stays as-is, + // no error banner from the (unrelated) `load` error state. + expect(screen.queryByText('transient rpc blip')).not.toBeInTheDocument(); + expect(screen.getByTestId('workflow-runs-list')).toBeInTheDocument(); + }); + }); }); diff --git a/app/src/pages/WorkflowRunsPage.tsx b/app/src/pages/WorkflowRunsPage.tsx index 84b934e5c8..874dd0de37 100644 --- a/app/src/pages/WorkflowRunsPage.tsx +++ b/app/src/pages/WorkflowRunsPage.tsx @@ -1,13 +1,18 @@ /** * WorkflowRunsPage — the aggregate "All runs" view: every workflow's runs across * the whole `flows` domain, newest first, backed by the `flows_list_all_runs` - * core RPC. Each row links back to its workflow's canvas. + * core RPC. Each row links back to its workflow's canvas. Stays live via + * {@link useFlowRunsLiveRefresh} while any listed run is still active, via a + * lightweight `refetchRuns` (re-fetches just the runs, not `listFlows()` too) + * so a run doesn't sit on "Running" until the user reloads the page. */ +import debug from 'debug'; import { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import PanelPage from '../components/layout/PanelPage'; import { CenteredLoadingState, ErrorBanner } from '../components/ui/LoadingState'; +import { useFlowRunsLiveRefresh } from '../hooks/useFlowRunsLiveRefresh'; import { useT } from '../lib/i18n/I18nContext'; import { type Flow, @@ -17,6 +22,8 @@ import { listFlows, } from '../services/api/flowsApi'; +const log = debug('app:flows:runs-page'); + const STATUS_CLASS: Record = { running: 'bg-primary-500/15 text-primary-600 dark:text-primary-300', completed: 'bg-sage-500/15 text-sage-700 dark:text-sage-300', @@ -56,6 +63,21 @@ export default function WorkflowRunsPage() { void load(); }, [load]); + // Lighter-weight than `load` — only re-fetches the runs (not `listFlows()` + // too), since flow names rarely change mid-run and re-fetching them on + // every live-refresh tick would be wasted work. + const refetchRuns = useCallback(() => { + listAllFlowRuns() + .then(setRuns) + .catch(err => { + // Best-effort background refresh — a transient failure here shouldn't + // clobber the page's existing error/loading state from `load`. + log('refetchRuns failed: %o', err); + }); + }, []); + + useFlowRunsLiveRefresh(runs, refetchRuns); + const statusLabel = (status: FlowRunStatus) => t(`flows.allRuns.status.${status}`, status.replace(/_/g, ' ')); From dbfcf5a2ef5a87aa3a2a29bae15cd4d804261c17 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:03:29 +0530 Subject: [PATCH 10/86] =?UTF-8?q?fix(flows):=20exempt=20workflow=20proposa?= =?UTF-8?q?l=20tools=20from=20tokenjuice=20compaction=20(canvas=20renders?= =?UTF-8?q?=20=E2=89=A54-node=20graphs)=20(#4888)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/openhuman/flows/ops_tests.rs | 95 +++++ src/openhuman/tinyagents/middleware.rs | 519 ++++++++++++++++++++++--- 2 files changed, 562 insertions(+), 52 deletions(-) diff --git a/src/openhuman/flows/ops_tests.rs b/src/openhuman/flows/ops_tests.rs index a8a789d02a..b4c1663c1b 100644 --- a/src/openhuman/flows/ops_tests.rs +++ b/src/openhuman/flows/ops_tests.rs @@ -4472,6 +4472,101 @@ async fn compute_required_connections_skips_native_and_http_nodes() { ); } +// ── extract_workflow_proposal: survives large, tabulation-eligible graphs ───── +// +// Regression coverage for the "blank canvas on ≥4-node graphs" bug: tinyjuice's +// JSON compressor tabulates any uniform object-array of >= 3 rows over ~512 +// bytes, which strips the `"type": "workflow_proposal"` marker this extractor +// keys on. The fix lives in `tinyagents::middleware::ToolOutputMiddleware` +// (COMPACTION_EXEMPT_TOOLS), which keeps proposal-tool results out of +// tokenjuice entirely — so by the time a payload reaches `agent.history()` +// here, it must still be the untabulated, structurally-intact JSON. + +#[test] +fn extract_workflow_proposal_survives_large_graph() { + use crate::openhuman::inference::provider::{ConversationMessage, ToolResultMessage}; + + // 6 nodes, several columns each — comfortably over tinyjuice's MIN_ROWS (3) + // and ~512-byte tabulation thresholds, so an unprotected payload would get + // compacted into a `[json table: …]` marker and lose the `"type"` field. + let nodes: Vec = (0..6) + .map(|i| { + json!({ + "id": format!("node-{i}"), + "kind": if i == 0 { "trigger" } else { "tool_call" }, + "name": format!("Step {i}"), + "config": { + "slug": format!("oh:placeholder_action_{i}"), + "args": { "input": format!("value-{i}"), "note": "generic placeholder payload for size padding" } + } + }) + }) + .collect(); + let edges: Vec = (0..5) + .map(|i| json!({ "from_node": format!("node-{i}"), "to_node": format!("node-{}", i + 1) })) + .collect(); + let proposal_payload = json!({ + "type": "workflow_proposal", + "flow_id": "flow-large-graph", + "graph": { "nodes": nodes, "edges": edges }, + }); + let payload_str = serde_json::to_string(&proposal_payload).unwrap(); + assert!( + payload_str.len() > 512, + "test payload must exceed tinyjuice's tabulation byte threshold: {} bytes", + payload_str.len() + ); + + let history = vec![ConversationMessage::ToolResults(vec![ToolResultMessage { + tool_call_id: "call-1".to_string(), + content: payload_str, + }])]; + + let proposal = extract_workflow_proposal(&history).expect("proposal should be extractable"); + assert_eq!( + proposal.get("type").and_then(serde_json::Value::as_str), + Some("workflow_proposal") + ); + assert_eq!( + proposal["graph"]["nodes"].as_array().unwrap().len(), + 6, + "all 6 nodes must survive intact: {proposal}" + ); +} + +#[test] +fn extract_workflow_proposal_returns_the_latest_of_multiple_results() { + use crate::openhuman::inference::provider::{ConversationMessage, ToolResultMessage}; + + let first = json!({ "type": "workflow_proposal", "flow_id": "first" }); + let second = json!({ "type": "workflow_proposal", "flow_id": "second" }); + let history = vec![ + ConversationMessage::ToolResults(vec![ToolResultMessage { + tool_call_id: "call-1".to_string(), + content: first.to_string(), + }]), + ConversationMessage::ToolResults(vec![ToolResultMessage { + tool_call_id: "call-2".to_string(), + content: second.to_string(), + }]), + ]; + + let proposal = extract_workflow_proposal(&history).expect("proposal should be extractable"); + assert_eq!(proposal["flow_id"], "second"); +} + +#[test] +fn extract_workflow_proposal_ignores_non_proposal_tool_results() { + use crate::openhuman::inference::provider::{ConversationMessage, ToolResultMessage}; + + let history = vec![ConversationMessage::ToolResults(vec![ToolResultMessage { + tool_call_id: "call-1".to_string(), + content: json!({ "type": "search_results", "items": [] }).to_string(), + }])]; + + assert!(extract_workflow_proposal(&history).is_none()); +} + // ───────────────────────────────────────────────────────────────────────────── // Builder convergence fix — trail-off backstop (`flows_build`'s terminal-state // guarantee: every turn ends in a proposal or a real question, never silence). diff --git a/src/openhuman/tinyagents/middleware.rs b/src/openhuman/tinyagents/middleware.rs index 559c029734..dee3415cc8 100644 --- a/src/openhuman/tinyagents/middleware.rs +++ b/src/openhuman/tinyagents/middleware.rs @@ -718,6 +718,65 @@ impl Middleware<()> for PromptCacheSegmentMiddleware { } } +/// Tools whose results are self-describing JSON payloads that downstream +/// extractors and the frontend canvas parse structurally (the `type` marker +/// must survive). Compacting/summarizing them destroys the contract and +/// serves no purpose — the model doesn't benefit from a tabulated graph and +/// the payload is the turn's final output, not intermediate context. +/// +/// These tools are exempt from *every* content-rewriting stage below — +/// tokenjuice compaction (steps 1+2) **and** the per-tool char cap / shared +/// byte-budget backstop (steps 3+4, see [`is_truncation_exempt`]). Both +/// `flows::ops::extract_workflow_proposal` and the frontend's +/// `parseWorkflowProposal` parse this content as a single whole-string JSON +/// document; a byte-cap truncation at a UTF-8 boundary produces invalid JSON +/// just as surely as tokenjuice tabulation strips the `"type"` marker — both +/// end in a silent `proposal: None` and a blank canvas. A ≥10-node graph +/// routinely clears the ~16 KiB shared budget, so the truncation exemption +/// matters just as much as the compaction one. +const COMPACTION_EXEMPT_TOOLS: &[&str] = &[ + "propose_workflow", + "revise_workflow", + "edit_workflow", + "save_workflow", + "create_workflow", +]; + +/// Tools whose results the model reads to derive an exact schema (e.g. +/// `primary_array_path` / `output_fields`) from a *real* sampled tool +/// response, per the B12 output-probe contract (`flows::builder_tools`). +/// TokenJuice's array-elision tabulation defeats their purpose outright — a +/// tabulated sample hides the very array shape the model is calling the tool +/// to observe, so it derives a wrong or nonexistent `split_out.path` from the +/// summary instead of the real response. They're compaction-exempt +/// ([`is_compaction_exempt`]) for that reason. +/// +/// Unlike [`COMPACTION_EXEMPT_TOOLS`], their payload is intermediate context +/// the model reasons over — not the turn's final machine-parsed output — and +/// samples can be genuinely large (a full API response body). So they stay +/// subject to the per-tool char cap / shared byte-budget backstop +/// ([`is_truncation_exempt`] returns `false` for them): a truncated-but-not- +/// tabulated sample is still a usable (if partial) real response, and the +/// backstop keeps these calls from blowing the context budget. +const SAMPLING_TOOLS: &[&str] = &["get_tool_output_sample", "get_tool_contract"]; + +/// Steps 1 (payload summarizer) + 2 (tokenjuice compaction) exemption: +/// proposal tools (final-output contract, see [`COMPACTION_EXEMPT_TOOLS`]) +/// plus sampling tools (tabulation would corrupt the schema they exist to +/// reveal, see [`SAMPLING_TOOLS`]). +fn is_compaction_exempt(name: &str) -> bool { + COMPACTION_EXEMPT_TOOLS.contains(&name) || SAMPLING_TOOLS.contains(&name) +} + +/// Steps 3 (per-tool char cap) + 4 (shared byte-budget backstop) exemption: +/// proposal tools only. Their JSON is parsed as a single whole-string +/// document downstream, so any truncation — not just tokenjuice tabulation — +/// breaks the parse. Sampling tools are deliberately *not* in this set: see +/// [`SAMPLING_TOOLS`] for why the byte cap stays in force for them. +fn is_truncation_exempt(name: &str) -> bool { + COMPACTION_EXEMPT_TOOLS.contains(&name) +} + /// `after_tool`: apply the semantic payload summarizer (when configured) and /// then the hard per-tool-result byte cap to each tool result's model-facing /// content, before it enters the transcript. The graph analogue of the byte cap @@ -758,75 +817,120 @@ impl Middleware<()> for ToolOutputMiddleware { _state: &(), result: &mut TaToolResult, ) -> TaResult<()> { + // Proposal-/persistence-emitting workflow tools return a self-describing + // `{ "type": "workflow_proposal", … }` JSON payload that `flows::ops`' + // `extract_workflow_proposal` (and the frontend's content-based + // recognition) parse structurally. Sampling tools (`get_tool_contract` / + // `get_tool_output_sample`) return a real API response the model reads + // to derive an exact array path/schema. All four stages below are + // content-*rewriting*: tokenjuice (steps 1+2) tabulates any uniform + // object-array of ≥3 rows over ~512 bytes into a `[json table: …]` + // marker (stripping the `"type"` field on graphs with enough nodes, or + // eliding the array a sample exists to reveal); the char cap and shared + // byte-budget backstop (steps 3+4) truncate at a UTF-8 boundary, which + // breaks the whole-string JSON parse both proposal consumers do. See + // [`is_compaction_exempt`]/[`is_truncation_exempt`] for which stages + // each tool family skips and why. + let compaction_exempt = is_compaction_exempt(&result.name); + let truncation_exempt = is_truncation_exempt(&result.name); + if compaction_exempt { + tracing::debug!( + tool = %result.name, + bytes = result.content.len(), + "[tinyagents::mw] compaction-exempt: skipping payload summarizer + tokenjuice" + ); + } + if truncation_exempt { + tracing::debug!( + tool = %result.name, + bytes = result.content.len(), + "[tinyagents::mw] truncation-exempt: skipping per-tool char cap + shared byte-budget backstop" + ); + } + // 1. Semantic summarization (progressive disclosure) — swap the raw // payload for a compressed summary when the summarizer opts in. // Failures never break the tool call (the trait swallows them). - if let Some(ps) = &self.payload_summarizer { - if let Ok(Some(payload)) = ps - .maybe_summarize_in_parent(ctx, &result.name, None, &result.content) - .await - { - tracing::info!( - tool = %result.name, - from_bytes = payload.original_bytes, - to_bytes = payload.summary_bytes, - "[tinyagents::mw] payload_summarizer compressed tool output" - ); + if !compaction_exempt { + if let Some(ps) = &self.payload_summarizer { + if let Ok(Some(payload)) = ps + .maybe_summarize_in_parent(ctx, &result.name, None, &result.content) + .await + { + tracing::info!( + tool = %result.name, + from_bytes = payload.original_bytes, + to_bytes = payload.summary_bytes, + "[tinyagents::mw] payload_summarizer compressed tool output" + ); + ctx.emit(AgentEvent::Compressed { + from_tokens: estimate_output_tokens(payload.original_bytes), + to_tokens: estimate_output_tokens(payload.summary_bytes), + }); + result.content = payload.summary; + } + } + + // 2. TokenJuice content-aware compaction. This mirrors the legacy + // `agent_tool_exec` stage that ran after semantic summarization and + // before the hard output caps. + let before_tokenjuice_bytes = result.content.len(); + let compacted = crate::openhuman::tokenjuice::compact_output_with_policy( + std::mem::take(&mut result.content), + &result.name, + self.tokenjuice_compaction_enabled, + self.tokenjuice_compression, + ) + .await; + result.content = compacted; + let after_tokenjuice_bytes = result.content.len(); + if after_tokenjuice_bytes < before_tokenjuice_bytes { ctx.emit(AgentEvent::Compressed { - from_tokens: estimate_output_tokens(payload.original_bytes), - to_tokens: estimate_output_tokens(payload.summary_bytes), + from_tokens: estimate_output_tokens(before_tokenjuice_bytes), + to_tokens: estimate_output_tokens(after_tokenjuice_bytes), }); - result.content = payload.summary; } } - // 2. TokenJuice content-aware compaction. This mirrors the legacy - // `agent_tool_exec` stage that ran after semantic summarization and - // before the hard output caps. - let before_tokenjuice_bytes = result.content.len(); - let compacted = crate::openhuman::tokenjuice::compact_output_with_policy( - std::mem::take(&mut result.content), - &result.name, - self.tokenjuice_compaction_enabled, - self.tokenjuice_compression, - ) - .await; - result.content = compacted; - let after_tokenjuice_bytes = result.content.len(); - if after_tokenjuice_bytes < before_tokenjuice_bytes { - ctx.emit(AgentEvent::Compressed { - from_tokens: estimate_output_tokens(before_tokenjuice_bytes), - to_tokens: estimate_output_tokens(after_tokenjuice_bytes), - }); - } - // 3. Per-tool **char** cap — a tool that declares `max_result_size_chars` // caps its own output in characters, with the tool-cap marker the model // was taught to read (legacy engine parity). Distinct from the generic - // byte budget below: the tool cap is the tool's own contract. + // byte budget below: the tool cap is the tool's own contract. Skipped + // for truncation-exempt tools (see [`is_truncation_exempt`]) — the tool + // cap is still *computed* below (step 4's "no cap of its own" check + // reads it), just not applied to `result.content`. let tool_cap = self.tool_char_cap(&result.name); - if let Some(cap) = tool_cap { - let char_count = result.content.chars().count(); - if char_count > cap { - let truncated: String = result.content.chars().take(cap).collect(); - let dropped = char_count - cap; - tracing::debug!( - tool = %result.name, - cap, - char_count, - dropped, - "[tinyagents::mw] per-tool char cap applied" - ); - result.content = format!( - "{truncated}\n\n[truncated by tool cap: {dropped} more chars not shown]" - ); + if !truncation_exempt { + if let Some(cap) = tool_cap { + let char_count = result.content.chars().count(); + if char_count > cap { + let truncated: String = result.content.chars().take(cap).collect(); + let dropped = char_count - cap; + tracing::debug!( + tool = %result.name, + cap, + char_count, + dropped, + "[tinyagents::mw] per-tool char cap applied" + ); + result.content = format!( + "{truncated}\n\n[truncated by tool cap: {dropped} more chars not shown]" + ); + } } } // 4. Shared byte-cap backstop — truncate at a UTF-8 boundary with a marker. // Only for tools with no cap of their own (a capped tool already bounded - // itself above; stacking the two markers would double-truncate). - if tool_cap.is_none() && self.budget_bytes > 0 { + // itself above; stacking the two markers would double-truncate), and + // never for truncation-exempt tools. This is a per-result cap only — + // `apply_per_result_persistence` takes a single `content: String` and a + // fixed `self.budget_bytes`, with no shared/global accumulator across + // tool calls (the aggregate-spill variant, `spill_aggregate_tool_results`, + // is a separate legacy code path not wired into this middleware) — so + // exempting these tools' own contribution here cannot perturb any other + // tool's budget accounting. + if !truncation_exempt && tool_cap.is_none() && self.budget_bytes > 0 { let (capped, outcome) = apply_per_result_persistence( std::mem::take(&mut result.content), self.artifact_store.as_ref(), @@ -3599,6 +3703,317 @@ mod tests { ); } + // ── ToolOutputMiddleware: COMPACTION_EXEMPT_TOOLS (workflow proposals) ─── + + /// A `workflow_proposal` payload with enough uniform-object rows to clear + /// tinyjuice's `MIN_ROWS` (3) and default ~512-byte tabulation floor — + /// i.e. exactly the shape that used to get its `"type"` marker stripped by + /// the `[json table: …]` rewrite before the middleware exemption existed. + fn large_workflow_proposal_json() -> String { + let nodes: Vec = (0..6) + .map(|i| { + json!({ + "id": format!("node-{i}"), + "kind": if i == 0 { "trigger" } else { "tool_call" }, + "name": format!("Step {i}"), + "config": { + "slug": format!("oh:placeholder_action_{i}"), + "args": { "input": format!("value-{i}"), "note": "generic placeholder payload for size padding" } + } + }) + }) + .collect(); + serde_json::to_string(&json!({ + "type": "workflow_proposal", + "flow_id": "flow-large-graph", + "graph": { "nodes": nodes, "edges": [] }, + })) + .unwrap() + } + + fn compaction_enabled_mw() -> ToolOutputMiddleware { + ToolOutputMiddleware { + budget_bytes: 1_000_000, + payload_summarizer: None, + artifact_store: None, + tokenjuice_compaction_enabled: true, + tokenjuice_compression: AgentTokenjuiceCompression::Full, + tool_policies: HashMap::new(), + } + } + + #[test] + fn compaction_exempt_tools_contains_every_proposal_tool() { + for tool in [ + "propose_workflow", + "revise_workflow", + "edit_workflow", + "save_workflow", + "create_workflow", + ] { + assert!( + COMPACTION_EXEMPT_TOOLS.contains(&tool), + "{tool} must be exempt from tokenjuice/summarizer compaction" + ); + } + } + + #[tokio::test] + async fn tool_output_tabulates_a_large_graph_for_a_non_exempt_tool() { + // Sanity baseline proving this test's payload actually exercises real + // tinyjuice tabulation (and isn't just below-threshold): a tool name + // NOT in COMPACTION_EXEMPT_TOOLS loses the `"type"` marker. + let mw = compaction_enabled_mw(); + let payload = large_workflow_proposal_json(); + let mut result = tool_result("some_other_tool", &payload); + mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); + assert_ne!( + result.content, payload, + "a non-exempt tool's large uniform-array payload should be rewritten by tokenjuice" + ); + let reparsed: Result = serde_json::from_str(&result.content); + let marker_survived = reparsed + .ok() + .and_then(|v| v.get("type").and_then(|t| t.as_str().map(str::to_string))) + == Some("workflow_proposal".to_string()); + assert!( + !marker_survived, + "baseline expectation: tabulation strips the type marker for non-exempt tools" + ); + } + + #[tokio::test] + async fn tool_output_leaves_propose_workflow_byte_for_byte_intact() { + let mw = compaction_enabled_mw(); + let payload = large_workflow_proposal_json(); + let mut result = tool_result("propose_workflow", &payload); + mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); + assert_eq!( + result.content, payload, + "propose_workflow results must pass through compaction untouched" + ); + let reparsed: serde_json::Value = serde_json::from_str(&result.content).unwrap(); + assert_eq!(reparsed["type"], "workflow_proposal"); + assert_eq!(reparsed["graph"]["nodes"].as_array().unwrap().len(), 6); + } + + #[tokio::test] + async fn tool_output_leaves_every_exempt_tool_name_intact() { + let mw = compaction_enabled_mw(); + let payload = large_workflow_proposal_json(); + for tool in COMPACTION_EXEMPT_TOOLS { + let mut result = tool_result(tool, &payload); + mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); + assert_eq!( + result.content, payload, + "{tool}'s result must pass through compaction untouched" + ); + } + } + + // ── ToolOutputMiddleware: truncation exemption (#4888 follow-up, gap 1) ── + + /// A `workflow_proposal` payload with `node_count` nodes, each padded with + /// a 500-byte `note`, so the caller can force the serialized size past the + /// ~16 KiB shared byte-budget backstop (`DEFAULT_TOOL_RESULT_BUDGET_BYTES`) + /// — the size class a real ≥10-node graph proposal routinely reaches, and + /// exactly what used to get UTF-8-boundary-truncated into unparseable JSON + /// before the truncation exemption existed. + fn oversized_workflow_proposal_json(node_count: usize) -> String { + let nodes: Vec = (0..node_count) + .map(|i| { + json!({ + "id": format!("node-{i}"), + "kind": if i == 0 { "trigger" } else { "tool_call" }, + "name": format!("Step {i}"), + "config": { + "slug": format!("oh:placeholder_action_{i}"), + "args": { "input": format!("value-{i}"), "note": "a".repeat(500) } + } + }) + }) + .collect(); + serde_json::to_string(&json!({ + "type": "workflow_proposal", + "flow_id": "flow-oversized-graph", + "graph": { "nodes": nodes, "edges": [] }, + })) + .unwrap() + } + + /// Middleware config isolating the byte-cap stages (3+4): tokenjuice off, + /// no tool-declared char cap, the real `DEFAULT_TOOL_RESULT_BUDGET_BYTES` + /// (~16 KiB) as the shared backstop, and no artifact store (so an + /// over-budget non-exempt tool falls straight to inline truncation instead + /// of being persisted — deterministic to assert on). + fn truncation_probe_mw() -> ToolOutputMiddleware { + ToolOutputMiddleware { + budget_bytes: DEFAULT_TOOL_RESULT_BUDGET_BYTES, + payload_summarizer: None, + artifact_store: None, + tokenjuice_compaction_enabled: false, + tokenjuice_compression: AgentTokenjuiceCompression::Off, + tool_policies: HashMap::new(), + } + } + + #[tokio::test] + async fn tool_output_leaves_an_oversized_propose_workflow_byte_for_byte_intact() { + // Gap 1: a ≥10-node proposal routinely exceeds the ~16 KiB shared + // byte-budget backstop. Before the truncation exemption, step 4 + // truncated it at a UTF-8 boundary — invalid JSON, so both + // `flows::ops::extract_workflow_proposal` and the frontend's + // `parseWorkflowProposal` silently fell back to `proposal: None` and a + // blank canvas. This must survive byte-for-byte regardless of size. + let mw = truncation_probe_mw(); + let payload = oversized_workflow_proposal_json(30); + assert!( + payload.len() > DEFAULT_TOOL_RESULT_BUDGET_BYTES, + "test payload must exceed the shared byte budget to exercise step 4: {} bytes", + payload.len() + ); + let mut result = tool_result("propose_workflow", &payload); + mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); + assert_eq!( + result.content, payload, + "an oversized propose_workflow result must not be truncated by the shared byte-budget backstop" + ); + let reparsed: serde_json::Value = serde_json::from_str(&result.content) + .expect("must still be valid JSON after passing through after_tool"); + assert_eq!(reparsed["type"], "workflow_proposal"); + assert_eq!(reparsed["graph"]["nodes"].as_array().unwrap().len(), 30); + } + + #[tokio::test] + async fn tool_output_truncates_the_same_oversized_payload_for_a_non_exempt_tool() { + // Baseline pairing with the test above: proves the identical oversized + // payload IS truncated (and consequently unparseable) for a tool that + // is NOT truncation-exempt, so the exemption test isn't vacuously true + // because the payload never actually crossed the budget. + let mw = truncation_probe_mw(); + let payload = oversized_workflow_proposal_json(30); + let mut result = tool_result("some_other_tool", &payload); + mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); + assert_ne!( + result.content, payload, + "a non-exempt tool's oversized payload should be truncated by the shared byte-budget backstop" + ); + assert!( + result.content.contains("truncated by tool_result_budget"), + "expected the byte-budget truncation marker: {}", + result.content + ); + assert!( + serde_json::from_str::(&result.content).is_err(), + "truncated JSON should no longer parse as a whole document" + ); + } + + // ── ToolOutputMiddleware: sampling tools (#4888 follow-up, gap 2) ──────── + + /// A large uniform-array JSON payload shaped like a real sampled tool + /// response (no `workflow_proposal` envelope) — what `get_tool_output_sample` + /// / `get_tool_contract` actually return so the model can derive an exact + /// `primary_array_path`/`output_fields` from the real shape. `row_count` + /// rows of ≥3 clear tinyjuice's tabulation threshold. + fn large_sample_response_json(row_count: usize) -> String { + let rows: Vec = (0..row_count) + .map(|i| { + json!({ + "id": i, + "title": format!("Issue {i}"), + "state": "open", + "body": "padding padding padding padding padding padding", + }) + }) + .collect(); + serde_json::to_string(&json!({ "items": rows })).unwrap() + } + + #[tokio::test] + async fn get_tool_output_sample_is_compaction_exempt() { + // Gap 2: tokenjuice tabulation elides the very array the model calls + // this tool to observe, so it would derive a wrong or nonexistent + // `split_out.path` from the tabulated summary instead of the real + // response shape. The sample must reach the model untabulated. + let mw = compaction_enabled_mw(); + let payload = large_sample_response_json(10); + let mut result = tool_result("get_tool_output_sample", &payload); + mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); + assert_eq!( + result.content, payload, + "get_tool_output_sample's response must not be tokenjuice-tabulated" + ); + } + + #[tokio::test] + async fn get_tool_contract_is_compaction_exempt() { + let mw = compaction_enabled_mw(); + let payload = large_sample_response_json(10); + let mut result = tool_result("get_tool_contract", &payload); + mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); + assert_eq!( + result.content, payload, + "get_tool_contract's response must not be tokenjuice-tabulated" + ); + } + + #[tokio::test] + async fn sampling_tool_output_still_hits_the_byte_budget_backstop() { + // Unlike the proposal tools, sampling tools are deliberately NOT + // truncation-exempt: a truncated-but-untabulated sample is still a + // usable (if partial) real response, and these calls can be genuinely + // large, so the shared byte-budget backstop keeps protecting the + // context budget for them. + let mw = truncation_probe_mw(); + let payload = large_sample_response_json(400); + assert!( + payload.len() > DEFAULT_TOOL_RESULT_BUDGET_BYTES, + "test payload must exceed the shared byte budget: {} bytes", + payload.len() + ); + let mut result = tool_result("get_tool_output_sample", &payload); + mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); + assert_ne!( + result.content, payload, + "get_tool_output_sample must still be subject to the shared byte-budget backstop" + ); + assert!( + result.content.contains("truncated by tool_result_budget"), + "expected the byte-budget truncation marker: {}", + result.content + ); + } + + #[test] + fn compaction_and_truncation_exempt_sets_are_distinct() { + // Proposal tools: exempt from both compaction and truncation. + for tool in COMPACTION_EXEMPT_TOOLS { + assert!( + is_compaction_exempt(tool), + "{tool} must be compaction-exempt" + ); + assert!( + is_truncation_exempt(tool), + "{tool} must be truncation-exempt" + ); + } + // Sampling tools: exempt from compaction only. + for tool in SAMPLING_TOOLS { + assert!( + is_compaction_exempt(tool), + "{tool} must be compaction-exempt" + ); + assert!( + !is_truncation_exempt(tool), + "{tool} must remain subject to the char cap / shared byte-budget backstop" + ); + } + // An arbitrary non-listed tool: exempt from neither. + assert!(!is_compaction_exempt("some_other_tool")); + assert!(!is_truncation_exempt("some_other_tool")); + } + // ── CostBudgetMiddleware ──────────────────────────────────────────────── #[tokio::test] From 193a39913b63248af2abaff3dc3723480b2b7649 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:29:34 +0530 Subject: [PATCH 11/86] fix(flows): close stale-read race in flows_update disarm + actionable-node reachability (#4891) --- src/openhuman/flows/ops.rs | 102 ++++++++++++++++++++-------- src/openhuman/flows/ops_tests.rs | 90 +++++++++++++++++++++++++ src/openhuman/flows/store_tests.rs | 104 +++++++++++++++++++++++++++++ 3 files changed, 268 insertions(+), 28 deletions(-) diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index 3f5f7bae3f..3704acdad9 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -367,21 +367,46 @@ pub(crate) fn graph_has_outbound_side_effect(graph: &WorkflowGraph) -> bool { } /// Whether `graph` has anything for [`flows_run`] to actually *do* — i.e. at -/// least one non-`trigger` node, wired up by at least one edge. A graph made -/// of nothing but a bare `trigger` node (or a `trigger` plus unreachable/ -/// disconnected nodes with no edges at all) can compile and "run" cleanly -/// while producing no work whatsoever — the exact live finding this guards: -/// a trigger-only flow reported `status="completed" pending_approvals=0` -/// having done nothing, which reads as a successful automation to anyone not -/// staring at the node count. Used by `flows_run` to attach a -/// human-readable note to an otherwise-silent "success". +/// least one non-`trigger` node **reachable from the trigger** by following +/// directed edges. A graph made of nothing but a bare `trigger` node (or a +/// `trigger` plus unreachable/disconnected nodes — even ones wired to each +/// other by their own edges, just not to the trigger) can compile and "run" +/// cleanly while producing no work whatsoever — the exact live finding this +/// guards: a trigger-only flow reported `status="completed" +/// pending_approvals=0` having done nothing, which reads as a successful +/// automation to anyone not staring at the node count. Used by `flows_run` +/// to attach a human-readable note to an otherwise-silent "success". +/// +/// Deliberately a reachability walk rather than "any edge at all exists": +/// `nodes.len() > 1 && !edges.is_empty()` would count a disconnected +/// component's internal edges as actionable even though nothing downstream +/// of the trigger ever runs. pub(crate) fn graph_has_actionable_nodes(graph: &WorkflowGraph) -> bool { - let non_trigger_nodes = graph - .nodes - .iter() - .filter(|n| n.kind != NodeKind::Trigger) - .count(); - non_trigger_nodes > 0 && !graph.edges.is_empty() + let Some(trigger) = graph.trigger() else { + // No single resolvable trigger to walk from — fall back to the + // coarse "any non-trigger node wired up by an edge" check so a + // malformed/ambiguous-trigger graph doesn't spuriously suppress the + // empty-flow note. + return graph.nodes.iter().any(|n| n.kind != NodeKind::Trigger) && !graph.edges.is_empty(); + }; + + let mut visited: std::collections::HashSet<&str> = std::collections::HashSet::new(); + let mut stack = vec![trigger.id.as_str()]; + while let Some(current) = stack.pop() { + if !visited.insert(current) { + continue; + } + for next in graph.successors(current) { + if !visited.contains(next) { + stack.push(next); + } + } + } + + visited + .into_iter() + .filter_map(|id| graph.node(id)) + .any(|n| n.kind != NodeKind::Trigger) } /// Produces host-side, **non-fatal** validation warnings for a graph — today @@ -2491,15 +2516,29 @@ fn map_flow_update_error(e: store::FlowUpdateError) -> String { /// tool, the canvas Save button, a proposal apply, or any other /// `flows_update` caller — and go LIVE immediately with no user review /// (confirmed live: a flow started firing on an unreviewed 8am schedule). -/// So: when the *new* graph's trigger is automatic, the flow is *currently* -/// enabled, and the *previous* graph's trigger was NOT automatic (a -/// manual/none → automatic transition), this forces the persisted `enabled` -/// back to `false` in the same store write — the user must explicitly -/// re-arm via `flows_set_enabled` after reviewing the new trigger. -/// Deliberately narrower than Rule 1's at-create version: a flow that was -/// already an enabled *automatic*-trigger flow being legitimately re-edited -/// (e.g. tweaking a cron expression) is left alone — the user already opted -/// in once, and re-disarming on every edit would just be friction. +/// So: when the *new* graph's trigger is automatic and the *previous* +/// graph's trigger was NOT automatic (a manual/none → automatic +/// transition), this forces the persisted `enabled` back to `false` in the +/// same store write — the user must explicitly re-arm via +/// `flows_set_enabled` after reviewing the new trigger. An automatic → +/// automatic re-edit (e.g. tweaking a cron expression) is left alone — the +/// user already opted in once, and re-disarming on every edit would just be +/// friction. +/// +/// The override is applied **unconditionally** on a manual/none → automatic +/// transition — it does *not* gate on whether the flow *looked* enabled in +/// the `existing` read above. That read is a snapshot taken before +/// `store::update_flow_graph`'s own guarded UPDATE re-reads the row; a +/// concurrent `flows_set_enabled(id, true)` landing in the gap would leave +/// this snapshot stale while the row is actually enabled by the time the +/// guarded UPDATE runs — and since `set_enabled` bumps `updated_at` too, +/// such a race wouldn't even trip the optimistic-concurrency conflict, it +/// would just silently persist the automatic graph as enabled (the exact +/// bug this rule exists to close). Gating on the stale `existing.enabled` +/// re-opens that race; forcing the override on every transition, enabled-or- +/// not, is exactly as safe as Rule 1's at-create version — a transition on +/// an already-disabled flow is just a no-op write of `enabled=false` over +/// `enabled=false`. pub async fn flows_update( config: &Config, id: &str, @@ -2523,19 +2562,26 @@ pub async fn flows_update( } }; - // B29 Rule 1 analogue: only disarm a manual/none → automatic transition - // on an already-enabled flow. An automatic → automatic re-edit, or a - // flow that isn't enabled to begin with, is untouched. + // B29 Rule 1 analogue: disarm every manual/none → automatic trigger + // transition, unconditionally — see the doc comment above for why this + // must NOT gate on the (possibly stale) `existing.enabled` read. let was_auto = trigger_is_automatic(&existing.graph); let now_auto = trigger_is_automatic(&graph); - let should_disarm = now_auto && existing.enabled && !was_auto; - let enabled_override = should_disarm.then_some(false); + let is_manual_to_auto_transition = now_auto && !was_auto; + let enabled_override = is_manual_to_auto_transition.then_some(false); + // Best-effort flag for the info log / result message below: whether the + // flow *appeared* live going into this update. Not used for the + // override decision itself (that's unconditional, see above) — only to + // avoid telling the user "flow was auto-disabled" when it was already + // disabled going in. + let should_disarm = is_manual_to_auto_transition && existing.enabled; tracing::debug!( target: "flows", flow_id = %id, was_auto, now_auto, currently_enabled = existing.enabled, + is_manual_to_auto_transition, should_disarm, "[flows] flows_update: auto-trigger disarm decision inputs" ); diff --git a/src/openhuman/flows/ops_tests.rs b/src/openhuman/flows/ops_tests.rs index b4c1663c1b..93409a4793 100644 --- a/src/openhuman/flows/ops_tests.rs +++ b/src/openhuman/flows/ops_tests.rs @@ -301,6 +301,46 @@ async fn flows_run_on_graph_with_actionable_nodes_has_no_empty_flow_note() { ); } +/// `graph_has_actionable_nodes` must walk from the trigger, not merely check +/// "any non-trigger node plus any edge". A component with edges of its own, +/// but no path back to the trigger, is unreachable and must still surface +/// the "nothing to run" note — a naive count-based check would have missed +/// this and wrongly suppressed the note. +#[tokio::test] +async fn flows_run_on_graph_with_disconnected_component_still_surfaces_empty_flow_note() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let graph = json!({ + "name": "disconnected", + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Trigger" }, + { "id": "a", "kind": "output_parser", "name": "Orphan A" }, + { "id": "b", "kind": "output_parser", "name": "Orphan B" } + ], + "edges": [ + // "a" -> "b" is wired up, but neither is reachable from "t" — the + // trigger has no outgoing edges at all. + { "from_node": "a", "to_node": "b" } + ] + }); + let created = flows_create(&config, "disconnected".to_string(), graph, false) + .await + .unwrap(); + + let outcome = flows_run(&config, &created.value.id, json!({}), FlowRunTrigger::Rpc) + .await + .unwrap(); + + let note = outcome.value["note"] + .as_str() + .expect("a component disconnected from the trigger must still surface the empty-flow note"); + assert!( + note.contains("no actionable nodes") || note.to_lowercase().contains("nothing"), + "note should explain that nothing ran, got: {note}" + ); +} + #[tokio::test] async fn flows_run_reports_pending_approval_and_blocks_downstream() { let tmp = TempDir::new().unwrap(); @@ -697,6 +737,56 @@ async fn flows_update_disables_on_manual_to_automatic_trigger_transition_when_en ); } +/// Regression: the manual→automatic disarm must apply unconditionally, not +/// only when `flows_update`'s own `existing` read observes `enabled: true`. +/// A live race (Codex, this PR) could leave that read stale — a concurrent +/// `flows_set_enabled(id, true)` landing between the read and the guarded +/// write would previously compute `should_disarm = false` from the stale +/// snapshot and let the automatic graph persist enabled. This test pins the +/// non-racy half of that contract directly at the `flows_update` level: even +/// starting from an *observed* `enabled: false`, a manual→automatic +/// transition still writes the override (a no-op here since the flow was +/// already disabled) rather than skipping it — see +/// `store::update_flow_graph_override_wins_over_concurrently_enabled_row` +/// (store_tests.rs) for the deterministic proof that this override also wins +/// a genuine concurrent-enable race. +#[tokio::test] +async fn flows_update_disarms_manual_to_automatic_transition_even_when_already_disabled() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let created = flows_create( + &config, + "manual-then-scheduled".to_string(), + manual_trigger_graph(), + false, + ) + .await + .unwrap(); + flows_set_enabled(&config, &created.value.id, false) + .await + .unwrap(); + + let updated = flows_update( + &config, + &created.value.id, + None, + Some(schedule_trigger_graph("0 8 * * *")), + None, + None, + ) + .await + .unwrap(); + + assert!( + !updated.value.enabled, + "a manual→automatic transition must never leave the flow enabled, regardless of \ + whether it looked enabled going in" + ); + let reloaded = flows_get(&config, &created.value.id).await.unwrap(); + assert!(!reloaded.value.enabled); +} + #[tokio::test] async fn flows_update_preserves_enabled_when_already_automatic() { let tmp = TempDir::new().unwrap(); diff --git a/src/openhuman/flows/store_tests.rs b/src/openhuman/flows/store_tests.rs index 778712a0e9..cd679fae42 100644 --- a/src/openhuman/flows/store_tests.rs +++ b/src/openhuman/flows/store_tests.rs @@ -107,6 +107,110 @@ fn update_flow_graph_bumps_updated_at_and_preserves_created_at() { assert_eq!(updated.graph.name, "renamed-graph"); } +/// `enabled_override: None` must leave the persisted `enabled` column +/// exactly as it was — `update_flow_graph` re-reads the current row and +/// falls back to `current.enabled`, not to whatever the caller might have +/// observed earlier. +#[test] +fn update_flow_graph_with_none_override_preserves_current_enabled_column() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow(&config, "demo".to_string(), trigger_graph(), false, true).unwrap(); + assert!(flow.enabled, "flow created enabled"); + + let updated = update_flow_graph( + &config, + &flow.id, + flow.name.clone(), + trigger_graph(), + false, + None, // enabled_override + None, + ) + .unwrap(); + + assert!( + updated.enabled, + "a None override must preserve the row's current enabled state" + ); + let reloaded = get_flow(&config, &flow.id).unwrap().unwrap(); + assert!(reloaded.enabled); +} + +/// `enabled_override: Some(false)` must force-persist `enabled=false` +/// regardless of what the row's `enabled` column currently holds — this is +/// the mechanism `flows_update`'s B29 Rule 1 analogue relies on to disarm a +/// manual→automatic trigger transition in the same guarded write. +#[test] +fn update_flow_graph_with_some_false_override_forces_disabled() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow(&config, "demo".to_string(), trigger_graph(), false, true).unwrap(); + assert!(flow.enabled, "flow created enabled"); + + let updated = update_flow_graph( + &config, + &flow.id, + flow.name.clone(), + trigger_graph(), + false, + Some(false), // enabled_override + None, + ) + .unwrap(); + + assert!( + !updated.enabled, + "a Some(false) override must force enabled=false even though the row was enabled" + ); + let reloaded = get_flow(&config, &flow.id).unwrap().unwrap(); + assert!(!reloaded.enabled); +} + +/// Regression for the silent live-arming race Codex flagged on this PR: +/// `flows_update` (ops.rs) makes its manual→automatic disarm decision from +/// an *outer* `existing` read taken before `update_flow_graph`'s own guarded +/// UPDATE re-reads the row. If a concurrent `flows_set_enabled(id, true)` +/// landed in that gap — which bumps `updated_at`, so it would NOT trip the +/// optimistic-concurrency conflict — the outer read would be stale while the +/// row is actually enabled by write time. This proves the mechanism the fix +/// relies on to close that race: an `enabled_override` of `Some(false)` +/// (what `flows_update` now passes unconditionally on a manual→automatic +/// transition, never gated on the stale outer read) always wins over +/// whatever the row's `enabled` column was concurrently flipped to, +/// simulated here by flipping it with `set_enabled` between the two calls. +#[test] +fn update_flow_graph_override_wins_over_concurrently_enabled_row() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow(&config, "demo".to_string(), trigger_graph(), false, false).unwrap(); + assert!(!flow.enabled, "flow created disabled"); + + // Simulates a concurrent `flows_set_enabled(id, true)` racing in after + // `flows_update`'s outer `existing` read observed `enabled: false`, but + // before its guarded `update_flow_graph` write below. + let raced = set_enabled(&config, &flow.id, true).unwrap(); + assert!(raced.enabled); + + let updated = update_flow_graph( + &config, + &flow.id, + flow.name.clone(), + trigger_graph(), + false, + Some(false), // the unconditional disarm override + None, + ) + .unwrap(); + + assert!( + !updated.enabled, + "the disarm override must win over a concurrently-enabled row, not the reverse" + ); + let reloaded = get_flow(&config, &flow.id).unwrap().unwrap(); + assert!(!reloaded.enabled); +} + #[test] fn record_run_sets_last_run_fields() { let tmp = TempDir::new().unwrap(); From e339e510bcea3d9db000febb60c5c6c3b60227f7 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:10:05 +0530 Subject: [PATCH 12/86] fix(chat): don't wipe fresh streaming/tool-timeline on a completed hydrate snapshot (#4903) --- app/src/store/chatRuntimeSlice.test.ts | 97 ++++++++++++++++++++++++++ app/src/store/chatRuntimeSlice.ts | 30 ++++++-- 2 files changed, 120 insertions(+), 7 deletions(-) diff --git a/app/src/store/chatRuntimeSlice.test.ts b/app/src/store/chatRuntimeSlice.test.ts index 05c97de39a..769a8baaae 100644 --- a/app/src/store/chatRuntimeSlice.test.ts +++ b/app/src/store/chatRuntimeSlice.test.ts @@ -17,6 +17,7 @@ import chatRuntimeReducer, { resetSessionTokenUsage, setPendingApprovalForThread, setQueueStatusForThread, + setStreamingAssistantForThread, setToolTimelineForThread, setWorkflowProposalForThread, } from './chatRuntimeSlice'; @@ -759,6 +760,102 @@ describe('hydrateRuntimeFromSnapshot — workflow proposal race guard', () => { }); }); +// Regression: a `completed` snapshot rehydration must not clobber streaming +// narration / a tool timeline that is fresher than the snapshot itself. This +// happens when the socket-disconnect reconciliation path (ChatRuntimeProvider) +// deliberately preserves `streamingAssistantByThread` across `endInferenceTurn` +// so a partial reply stays visible while the socket reconnects, and a +// `fetchAndHydrateTurnState` rehydration then lands with a `completed` +// snapshot for that same (now-settled) turn. Only `interrupted` (a genuine +// crash — nothing fresher can exist) should unconditionally clobber; a +// `completed` snapshot should only fill in when there is no live state to +// lose (cold boot / new window). +describe('hydrateRuntimeFromSnapshot — streaming/timeline race guard', () => { + function makeTimelineSnapshot( + threadId: string, + lifecycle: PersistedTurnState['lifecycle'] + ): PersistedTurnState { + return { + threadId, + requestId: 'req-snapshot', + lifecycle, + iteration: 3, + maxIterations: 10, + streamingText: '', + thinking: '', + toolTimeline: [{ id: 'c-snapshot', name: 'web_search', round: 1, status: 'success' }], + startedAt: '2026-06-23T00:00:00Z', + updatedAt: '2026-06-23T00:00:00Z', + }; + } + + it('does not wipe a fresher streaming/tool-timeline lane on a completed snapshot', () => { + const store = makeStore(); + // The live driver already has state for this thread — e.g. the + // disconnect-reconciliation path kept the streaming bubble around while + // the socket reconnects. + store.dispatch( + setStreamingAssistantForThread({ + threadId: 't-settled', + streaming: { requestId: 'req-live', content: 'partial reply so far', thinking: '' }, + }) + ); + store.dispatch( + setToolTimelineForThread({ + threadId: 't-settled', + entries: [{ id: 'c-live', name: 'read_file', round: 1, status: 'success' }], + }) + ); + + store.dispatch( + hydrateRuntimeFromSnapshot({ snapshot: makeTimelineSnapshot('t-settled', 'completed') }) + ); + + const state = store.getState().chatRuntime; + // The fresher live streaming bubble survives untouched… + expect(state.streamingAssistantByThread['t-settled']?.content).toBe('partial reply so far'); + // …and so does the live tool timeline, rather than being replaced by the + // (behind) snapshot's single row. + expect(state.toolTimelineByThread['t-settled'].map(e => e.id)).toEqual(['c-live']); + }); + + it('clears the streaming/tool-timeline lanes on an interrupted snapshot (crash cleanup)', () => { + const store = makeStore(); + store.dispatch( + setStreamingAssistantForThread({ + threadId: 't-crashed', + streaming: { requestId: 'req-stale', content: 'stale partial reply', thinking: '' }, + }) + ); + store.dispatch( + setToolTimelineForThread({ + threadId: 't-crashed', + entries: [{ id: 'c-stale', name: 'read_file', round: 1, status: 'running' }], + }) + ); + + store.dispatch( + hydrateRuntimeFromSnapshot({ snapshot: makeTimelineSnapshot('t-crashed', 'interrupted') }) + ); + + const state = store.getState().chatRuntime; + expect(state.streamingAssistantByThread['t-crashed']).toBeUndefined(); + expect(state.toolTimelineByThread['t-crashed'].map(e => e.id)).toEqual(['c-snapshot']); + }); + + it('still hydrates the timeline from a completed snapshot on cold boot (no prior live state)', () => { + const store = makeStore(); + + store.dispatch( + hydrateRuntimeFromSnapshot({ snapshot: makeTimelineSnapshot('t-cold-boot', 'completed') }) + ); + + const state = store.getState().chatRuntime; + expect(state.streamingAssistantByThread['t-cold-boot']).toBeUndefined(); + expect(state.toolTimelineByThread['t-cold-boot'].map(e => e.id)).toEqual(['c-snapshot']); + }); +}); + describe('hydrateRuntimeFromSnapshot — persisted tool result output', () => { it('maps the persisted output onto parent and sub-agent rows as result', () => { const store = makeStore(); diff --git a/app/src/store/chatRuntimeSlice.ts b/app/src/store/chatRuntimeSlice.ts index 5ca4e06890..d0cad6d808 100644 --- a/app/src/store/chatRuntimeSlice.ts +++ b/app/src/store/chatRuntimeSlice.ts @@ -1989,13 +1989,29 @@ const chatRuntimeSlice = createSlice({ // is still carried so "View processing" replays the full reasoning. if (snapshot.lifecycle === 'interrupted' || snapshot.lifecycle === 'completed') { delete state.inferenceStatusByThread[threadId]; - delete state.streamingAssistantByThread[threadId]; - // Settle any in-flight rows so their agent names stop pulsing - // (no-op for an already-completed snapshot whose rows are terminal). - state.toolTimelineByThread[threadId] = preserveLiveSubagentProse( - state.toolTimelineByThread[threadId], - snapshot.toolTimeline.map(toolTimelineFromPersisted).map(settleOrphanedTimelineEntry) - ); + + // A `completed` snapshot can still lag behind live state this session + // already has for the thread: the socket-disconnect reconciliation + // path (`ChatRuntimeProvider`) deliberately *keeps* + // `streamingAssistantByThread` set across `endInferenceTurn` so a + // partial reply stays visible while the socket reconnects, and a + // `fetchAndHydrateTurnState` rehydration can land moments later. The + // same applies to `toolTimelineByThread`, which the live event + // stream keeps richer/fresher than a flush-boundary persisted copy. + // Only let the snapshot clobber those lanes when it is unambiguously + // the authority: an `interrupted` snapshot (the process that was + // streaming is gone — nothing fresher can exist) or there is no live + // streaming state for this thread to lose (cold boot / new window). + const hasFresherLiveStream = Boolean(state.streamingAssistantByThread[threadId]); + if (snapshot.lifecycle === 'interrupted' || !hasFresherLiveStream) { + delete state.streamingAssistantByThread[threadId]; + // Settle any in-flight rows so their agent names stop pulsing + // (no-op for an already-completed snapshot whose rows are terminal). + state.toolTimelineByThread[threadId] = preserveLiveSubagentProse( + state.toolTimelineByThread[threadId], + snapshot.toolTimeline.map(toolTimelineFromPersisted).map(settleOrphanedTimelineEntry) + ); + } state.processingByThread[threadId] = snapshot.transcript ?? []; return; } From 1c741f6bc8b37568026071eee83401e2baa354e5 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:36:13 +0530 Subject: [PATCH 13/86] fix(flows): correct save_workflow + workflow_builder contract text (disarm/create accuracy) (#4904) --- .../flows/agents/workflow_builder/agent.toml | 15 ++-- .../agents/workflow_builder/builder_prompt.rs | 23 ++++++ .../flows/agents/workflow_builder/prompt.md | 41 +++++++---- src/openhuman/flows/builder_tools.rs | 36 +++++++--- src/openhuman/flows/builder_tools_tests.rs | 70 +++++++++++++++++++ 5 files changed, 158 insertions(+), 27 deletions(-) diff --git a/src/openhuman/flows/agents/workflow_builder/agent.toml b/src/openhuman/flows/agents/workflow_builder/agent.toml index 159839aee5..c004043381 100644 --- a/src/openhuman/flows/agents/workflow_builder/agent.toml +++ b/src/openhuman/flows/agents/workflow_builder/agent.toml @@ -1,13 +1,14 @@ id = "workflow_builder" display_name = "Workflow Builder" delegate_name = "build_workflow" -when_to_use = "Workflow authoring specialist — owns building tinyflows automation graphs from a natural-language description. Route any request to 'set up a workflow that…', 'automate…', 'when X happens do Y', 'build/create/edit an automation or flow', or to iterate on a proposed workflow here. It grounds nodes in real tool slugs + connections, dry-runs a draft in a sandbox to self-check, and returns a workflow PROPOSAL for review. Persistence stays with the user's Accept + Save; when the user explicitly asks the agent to save (or test-run) onto an existing flow id it may `save_workflow` / `run_flow` — but never on its own, and it can never create a new flow or enable/disable one. Not for running an already-saved flow (that is a direct flows_run) and not for the legacy skill 'workflows'." +when_to_use = "Workflow authoring specialist — owns building tinyflows automation graphs from a natural-language description. Route any request to 'set up a workflow that…', 'automate…', 'when X happens do Y', 'build/create/edit an automation or flow', or to iterate on a proposed workflow here. It grounds nodes in real tool slugs + connections, dry-runs a draft in a sandbox to self-check, and returns a workflow PROPOSAL for review. Persistence stays with the user's Accept + Save; when the user explicitly asks the agent to save (or test-run) onto an existing flow id it may `save_workflow` / `run_flow` — but never on its own. It CAN create a new flow (`create_workflow`) or clone one (`duplicate_flow`) when the user explicitly asks, but every flow it creates is always born DISABLED — it can never enable a flow, or run one without the user's confirmation first. Not for running an already-saved flow (that is a direct flows_run) and not for the legacy skill 'workflows'." temperature = 0.2 max_iterations = 12 iteration_policy = "extended" max_result_chars = 12000 -# No sandbox filter needed: the belt is propose/read/dry-run plus two +# No sandbox filter needed: the belt is propose/read/dry-run plus a handful of # explicitly-bounded writes (save_workflow onto an existing flow; +# create_workflow/duplicate_flow, always force-disabled at creation; # run_flow of a saved flow behind a confirm-first prompt rule), and # dry_run_workflow runs against tinyflows MOCK capabilities (no real effects). sandbox_mode = "none" @@ -28,10 +29,12 @@ hint = "reasoning" # DELIBERATELY NARROW: propose/revise (validate-only) + read (flows, runs, # connections, tool catalog) + sandbox dry-run + Composio discovery/connect + # a confirmed real test-run of a SAVED flow + save_workflow (persist a graph -# onto an EXISTING flow only). NO shell, NO file writes, NO channel sends, NO -# composio_execute, and NO flows_create/set_enabled — the agent can never -# create or enable a flow, or perform an arbitrary real integration action -# directly. Composio access is limited to LISTING toolkits/connections, +# onto an EXISTING flow only) + create_workflow/duplicate_flow (persist a +# BRAND NEW flow — always force-disabled at creation, see +# `builder_tools::CreateWorkflowTool`). NO shell, NO file writes, NO channel +# sends, NO composio_execute, and NO flows_set_enabled — the agent can create +# a flow but can never enable one, or perform an arbitrary real integration +# action directly. Composio access is limited to LISTING toolkits/connections, # raising the inline CONNECT card (an approval-gated OAuth hand-off), and one # narrow carve-out below. # `run_flow` executes a flow the user has ALREADY saved to test it — a real diff --git a/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs b/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs index 97f7a8f05f..80a6c1f008 100644 --- a/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs +++ b/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs @@ -335,6 +335,29 @@ mod tests { STANDING_PROMPT.contains("Read-only — you can't change their memory"), "standing prompt must state the memory read-only guarantee, not just mention memory_recall" ); + + // Negative (contract accuracy, issue #6): `create_workflow` and + // `duplicate_flow` are on this agent's belt (see agent.toml's `named` + // tool list), so the prompt must never claim the agent can't create a + // flow at all — only that it can't enable/run one unattended. + for banned in [ + "create a new flow, or enable/disable one", + "It cannot create flows,", + ] { + assert!( + !STANDING_PROMPT.contains(banned), + "standing prompt must not carry the stale \"can never create a flow\" claim \ + `{banned}` — create_workflow/duplicate_flow are on the belt (issue #6)" + ); + } + + // Positive: the accurate contract — the agent CAN create a flow, but + // every flow it creates is always born disabled. + assert!( + STANDING_PROMPT.contains("create_workflow") && STANDING_PROMPT.contains("born"), + "standing prompt must accurately teach that create_workflow exists and that \ + created flows are always born disabled (issue #6)" + ); } #[test] diff --git a/src/openhuman/flows/agents/workflow_builder/prompt.md b/src/openhuman/flows/agents/workflow_builder/prompt.md index 3a643e3496..0ab8b7e5cf 100644 --- a/src/openhuman/flows/agents/workflow_builder/prompt.md +++ b/src/openhuman/flows/agents/workflow_builder/prompt.md @@ -8,8 +8,11 @@ user to review and save. ## The invariants you must never break -You **cannot and must not** create a new flow, or enable/disable one. You have -no tool that does — by design. Your authoring outputs are: +You **can** create a new flow (`create_workflow`) or clone one +(`duplicate_flow`), but only when the user explicitly asks — and every flow +you create is always born **DISABLED**. Enabling a flow is not a tool you +have, by design: you **cannot and must not** enable or disable one, ever. +Your authoring outputs are: - **`propose_workflow`** / **`revise_workflow`** — these *validate* a candidate graph and hand back a proposal summary. They **never** save anything. @@ -28,7 +31,7 @@ exception is `save_workflow` on an **existing** flow id, and only when the user **explicitly asks** (see below). If a user says "just turn it on for me", explain that enabling stays in their hands — you cannot enable a flow. -## Saving your work: `save_workflow` (only on the user's explicit ask) +## Saving your work: `save_workflow` / `create_workflow` (only on the user's explicit ask) Every authoring turn — build, revise, or repair — is **propose-only** by default. Your arc is: @@ -41,20 +44,32 @@ default. Your arc is: card") — never recite every persist path, and never repeat it across turns. -**When the user says "save it":** if you have a `save_workflow` action -available — an **existing** `flow_id` plus their explicit ask ("save this", -"yes save it onto flow_X") — just call `save_workflow { flow_id, draft_id, -name? }` (pass the `draft_id` you've been iterating on; an inline `graph` -also works) and confirm in one plain line what you saved (trigger, steps, and — -if the flow is enabled with a schedule/app_event trigger — that it's now -live and will fire on its own). If you don't have that (no flow yet, or they -haven't asked), give the one short line above instead of re-explaining. +**When the user says "save it":** which tool depends on whether the flow +already exists: + +- **Existing flow** — you have a `flow_id` plus their explicit ask ("save + this", "yes save it onto flow_X") — just call `save_workflow { flow_id, + draft_id, name? }` (pass the `draft_id` you've been iterating on; an inline + `graph` also works) and confirm in one plain line what you saved (trigger, + steps, and — if the flow is enabled with a schedule/app_event trigger — + that it's now live and will fire on its own). +- **Brand-new flow** — no `flow_id` yet, but the user explicitly asked you to + create/save it as a new automation ("create this and save it", "make this a + new flow") — call `create_workflow` (or `duplicate_flow` to clone an + existing one) instead; it persists a NEW flow, always born **DISABLED**, + and confirm what you created plus that it's off until they enable it. +- **Neither** (no flow yet and no explicit save/create ask, or they haven't + asked at all) — give the one short line from step 2 above instead of + re-explaining. **Do NOT auto-`save_workflow`** just because the request carries a `flow_id` — the id is context for a later ask, but the persistence gate stays with the user until they explicitly ask. Never `save_workflow` onto a -flow the user did NOT ask you to build/update. It cannot create flows, and -it never changes `enabled` or the approval gate. +flow the user did NOT ask you to build/update. It only writes onto a flow +that already exists (creating one is `create_workflow`'s job, not +`save_workflow`'s) and it never touches the approval gate — but it CAN +auto-disable the flow if the graph's trigger just transitioned from manual +to automatic on an already-enabled flow; say so if it happens. ## Testing a saved flow: `run_flow` (ask first!) diff --git a/src/openhuman/flows/builder_tools.rs b/src/openhuman/flows/builder_tools.rs index 21a49b19a6..60495aca0b 100644 --- a/src/openhuman/flows/builder_tools.rs +++ b/src/openhuman/flows/builder_tools.rs @@ -3232,10 +3232,13 @@ impl Tool for SaveWorkflowTool { the usual case after editing with edit_workflow; draft_id wins if both are given) or \ an inline `graph`; `flow_id` is always required as the persistence TARGET. It \ validates and writes the graph (and optional new `name`) to that flow. It can NOT \ - create a new flow, and it never changes the flow's enabled state or its approval \ - gate. NOTE: if the flow is enabled and the graph has a schedule/app_event trigger, \ - saving arms it — it will start firing on its own. Always tell the user what you \ - saved. Params: { flow_id, draft_id? | graph?, name? }." + create a new flow, and it never touches the approval gate — but it CAN \ + auto-disable the flow when the trigger transitions from manual to automatic \ + (schedule/webhook/app_event), so a save never silently arms a trigger that wasn't \ + already live; the returned `warnings` will explain it when that happens. NOTE: if \ + the flow was ALREADY enabled with an automatic trigger and stays automatic, saving \ + re-arms it live — it will start firing on its own. Always tell the user what you \ + saved (including any auto-disable). Params: { flow_id, draft_id? | graph?, name? }." } fn parameters_schema(&self) -> Value { @@ -3385,12 +3388,29 @@ impl Tool for SaveWorkflowTool { enabled = flow.enabled, "[flows] save_workflow: persisted" ); + // Surface any explanatory logs `flows_update` produced — most + // notably the manual→automatic auto-disarm message (#4889) — + // to the agent. Skip the boilerplate "flow updated: " line, + // which just duplicates the `persisted`/`flow_id` fields this + // response already carries. + let flow_updated_boilerplate = format!("flow updated: {flow_id}"); + warnings.extend( + outcome + .logs + .into_iter() + .filter(|log| *log != flow_updated_boilerplate), + ); // Issue B29 (save/enable safety), Rule 3: `flows_create` only // gates the FIRST creation of a flow — an agent `save_workflow` - // targets an EXISTING flow via `flows_update`, which preserves - // whatever `enabled` state the flow already had. If the user - // already armed this flow (enabled it) and it has an automatic - // trigger, saving a new graph onto it re-arms it live with no + // targets an EXISTING flow via `flows_update`, which (since + // #4889) force-disables the flow whenever the trigger + // transitions from manual to automatic (schedule/webhook/ + // app_event) — so a save can never silently arm a trigger that + // wasn't already live (see the `warnings.extend` above for the + // explanatory log). Short of that transition, `flows_update` + // preserves whatever `enabled` state the flow already had: if + // it was ALREADY enabled with an automatic trigger and stays + // automatic, saving a new graph onto it re-arms it live with no // further confirmation. Surface that loudly so the copilot // relays it to the user instead of staying silent. if flow.enabled && ops::trigger_is_automatic(&flow.graph) { diff --git a/src/openhuman/flows/builder_tools_tests.rs b/src/openhuman/flows/builder_tools_tests.rs index e6a4d59dd1..da11ca8972 100644 --- a/src/openhuman/flows/builder_tools_tests.rs +++ b/src/openhuman/flows/builder_tools_tests.rs @@ -1759,6 +1759,76 @@ async fn save_workflow_rejects_invalid_graph_and_leaves_flow_intact() { ); } +/// A single-node graph with an automatic (schedule) trigger — enough to +/// exercise the manual→automatic transition without tripping any of +/// `run_builder_gates`' binding/connection/contract checks (no other nodes, +/// nothing to bind). +fn schedule_trigger_graph() -> Value { + json!({ + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Trigger", + "config": { "trigger_kind": "schedule", "schedule": "0 8 * * *" } } + ], + "edges": [] + }) +} + +#[tokio::test] +async fn save_workflow_surfaces_auto_disarm_warning_on_manual_to_automatic_transition() { + // Regression for #4889 + the stale-docs issue that motivated this test: + // `flows_update` auto-disables a flow whenever its trigger transitions + // from manual to automatic on an already-enabled flow, but `save_workflow` + // used to drop `flows_update`'s explanatory `RpcOutcome.logs` entirely — + // the agent had no way to relay the disarm to the user. Assert both the + // disarm itself and that its log now surfaces in `save_workflow`'s + // `warnings`. + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow_id = seed_flow(&config, "Manual flow").await; + let seeded = ops::flows_get(&config, &flow_id).await.unwrap().value; + assert!( + seeded.enabled, + "precondition: a manual-trigger flow persists enabled from create" + ); + + let tool = SaveWorkflowTool::new(config.clone()); + let result = tool + .execute(json!({ + "flow_id": flow_id, + "graph": schedule_trigger_graph(), + })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!( + parsed["enabled"], false, + "manual→automatic transition on an enabled flow must auto-disable it: {parsed}" + ); + let warnings = parsed["warnings"] + .as_array() + .expect("warnings must be an array"); + assert!( + warnings + .iter() + .any(|w| w.as_str().unwrap_or("").contains("auto-disabled")), + "save_workflow must surface flows_update's disarm log as a warning, got: {parsed}" + ); + let flow_updated_boilerplate = format!("flow updated: {flow_id}"); + assert!( + warnings + .iter() + .all(|w| w.as_str().unwrap_or("") != flow_updated_boilerplate), + "save_workflow must exclude the redundant \"flow updated: \" boilerplate \ + from warnings, got: {parsed}" + ); + + // Persisted, not just returned in-memory. + let reloaded = ops::flows_get(&config, &flow_id).await.unwrap().value; + assert!(!reloaded.enabled); +} + // ── save_workflow: enforcing binding-resolvability gate ───────────────────── /// The proven live-failure shape (same as From 9b2afb900471d067ba86469abc0d7020785ea04f Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:48:21 +0530 Subject: [PATCH 14/86] feat(core): compile-time media feature gate (media_generation + image) (#4804) (#4840) Co-authored-by: M3gA-Mind --- .github/workflows/ci-lite.yml | 8 +++-- AGENTS.md | 3 ++ Cargo.toml | 14 +++++++- app/src-tauri/Cargo.toml | 12 ++++++- src/openhuman/mod.rs | 2 ++ src/openhuman/tools/ops.rs | 3 ++ src/openhuman/tools/ops_tests.rs | 34 +++++++++++++++++++ .../agent_session_round24_raw_coverage_e2e.rs | 27 ++++++--------- 8 files changed, 82 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index 870276ae6d..f807ef536c 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -354,8 +354,10 @@ jobs: # Feature-gate smoke: proves the core still compiles with a domain gate turned # OFF. The disabled build is the ONLY thing that catches stub-facade signature # drift (see AGENTS.md "Compile-time domain gates"), so it must run in CI, not - # just locally. Pathfinder lane for #4803 (voice); extend the --features list - # as sibling gates (#4797–#4802, #4804) land. + # just locally. Pathfinder lane for #4803 (voice); the explicit --features + # list (only tokenjuice-treesitter) turns every default gate OFF, so it also + # covers #4804 (media) and each sibling gate as it lands — no edit needed + # unless a new gate must stay ON here. rust-feature-gate-smoke: name: Rust Feature-Gate Smoke (gates off) needs: [changes] @@ -382,7 +384,7 @@ jobs: cache-on-failure: true shared-key: pr-rust-feature-gate-smoke - - name: Check core builds with the voice gate disabled + - name: Check core builds with the voice + media gates disabled run: bash scripts/ci-cancel-aware.sh cargo check --manifest-path Cargo.toml --no-default-features --features tokenjuice-treesitter rust-core-coverage: diff --git a/AGENTS.md b/AGENTS.md index 03ecc7e77c..b279ed219f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -233,11 +233,14 @@ GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml \ | Feature | Default | Gates | Drops deps | | ------- | ------- | ----- | ---------- | | `voice` | ON | `openhuman::voice` + `openhuman::audio_toolkit` domains — STT/TTS providers, dictation server, always-on listening, podcast audio + email | `hound`, `lettre` | +| `media` | ON | `openhuman::media_generation` (the `media_generate_*` agent tools) + `openhuman::image` scaffold | none (surface-only) | **Facade pattern (pathfinder for the other gates).** `pub mod voice;` is **always compiled** as a facade: the real submodules are `#[cfg(feature = "voice")]`, and a `#[cfg(not(feature = "voice"))] mod stub;` (`src/openhuman/voice/stub.rs`) re-exposes the same public surface that always-on / other-gated callers use (`server`, `dictation_listener`, `streaming`, `reply_speech`, `cloud_transcribe`, `cli`, `create_stt_provider`, `effective_stt_provider`, `publish_ptt_transcript_committed`) with no-op / `None` / disabled-error bodies. Callers therefore do **not** need per-call `#[cfg]`. When voice is off: the voice/audio controllers are unregistered (unknown-method over `/rpc`, absent from `/schema`), the `audio_generate_podcast` agent tools are absent, and `openhuman voice` returns a "voice disabled" error. Stub signatures must match the real ones exactly — the disabled build (`--no-default-features --features tokenjuice-treesitter`) is the **only** thing that catches drift, so run it before pushing any change to the voice surface. **Scope note:** the `voice` gate does **not** drop `whisper-rs` / `llama` / `cpal`. Those live in the inference domain (`src/openhuman/inference/local/service/whisper_engine.rs`; `cpal` is shared with accessibility) and await a separate future `inference` gate. The issue-level DoD line claiming whisper is dropped is superseded by this scope correction. +**Leaf-gate variant (`media`, #4804).** Unlike `voice`, the `media` gate needs **no** stub facade: `media_generation` has a single caller (the `build_media_tools` call in `src/openhuman/tools/ops.rs`, itself `#[cfg(feature = "media")]`) and `openhuman::image` is unwired scaffold (#2997), so both modules are simply `#[cfg(feature = "media")] pub mod …`. It is a **surface-only** gate: media generation is backend-proxied (`reqwest`, shared) and the `image` crate is shared with channel upload, so no exclusive deps are shed — the issue's "sheds media processing dependencies" / "controllers unregistered" DoD lines are superseded (Media is agent-tools-only; no controller/store/subscriber is tagged `Media`). When a gated domain is a true leaf, prefer this over the facade+stub. + ### Event bus (`src/core/event_bus/`) Typed pub/sub + native request/response. Both singletons — use module-level functions. diff --git a/Cargo.toml b/Cargo.toml index 2b6bff7f03..cb12f559c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -334,7 +334,7 @@ tokio = { version = "1", features = ["test-util"] } proptest = "1" [features] -default = ["tokenjuice-treesitter", "voice"] +default = ["tokenjuice-treesitter", "voice", "media"] # AST-aware code compression (tree-sitter Rust/TS/Python grammars; C build). # On by default; disable to fall back to the brace-depth heuristic. tokenjuice-treesitter = [ @@ -351,6 +351,18 @@ tokenjuice-treesitter = [ # inference domain (shared with accessibility for cpal) and await a separate # `inference` gate. voice = ["dep:hound", "dep:lettre"] +# Media-generation + image domains: the `media_generate_*` agent tools +# (image/video via GMI through the backend) and the `openhuman::image` tool +# contracts scaffold. Default-ON. Slim builds opt out via +# `--no-default-features --features ""`. +# Composes with the runtime `DomainSet::media` flag (#4796). +# NOTE: this gate sheds NO exclusive dependencies — media generation is +# backend-proxied (reqwest, shared) and the `image` crate is shared with +# channel media upload. It is a surface-only gate (drops the tool code + +# module from the compile), not a dependency-shedding one. There are no +# controllers / stores / subscribers tagged `Media` (agent tools only), and +# `openhuman::image` is currently unwired scaffold (added #2997). +media = [] sandbox-landlock = ["dep:landlock"] sandbox-bubblewrap = [] peripheral-rpi = ["dep:rppal"] diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 177210c7e1..4c6bb5c6ef 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -132,7 +132,17 @@ cef = { version = "=146.4.1", default-features = false } # by tying the core's lifetime to the GUI process. The existing port-7788 # probe in `core_process::ensure_running` still attaches to a running # `openhuman-core run` harness when one is already listening. -openhuman_core = { path = "../..", package = "openhuman", default-features = false } +# +# `default-features = false` (set in #1061, before the compile-time domain +# gates existed) means the embedded core does NOT inherit the root crate's +# default gate set, so each default-ON gate must be forwarded explicitly to +# keep the shipped desktop build byte-identical (AGENTS.md "Compile-time +# domain gates"). `media` re-registers the `media_generate_*` agent tools +# that #4804 moved behind `#[cfg(feature = "media")]`; it sheds no deps, so +# this only restores the pre-gate desktop tool surface. +openhuman_core = { path = "../..", package = "openhuman", default-features = false, features = [ + "media", +] } tinyjuice = { version = "0.2.1", default-features = false } [target.'cfg(unix)'.dependencies] diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 74f8442046..601a569cfc 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -57,6 +57,7 @@ pub mod harness_init; pub mod health; pub mod heartbeat; pub mod http_host; +#[cfg(feature = "media")] pub mod image; pub mod inference; pub mod integrations; @@ -68,6 +69,7 @@ pub mod mcp_audit; pub mod mcp_client; pub mod mcp_registry; pub mod mcp_server; +#[cfg(feature = "media")] pub mod media_generation; pub mod meet; pub mod meet_agent; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 95fb2736bb..d9203d79b5 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -875,6 +875,9 @@ pub fn all_tools_with_runtime( // Media generation (image/video via GMI through the backend). Skipped when // no integration client is configured; artifacts land under `action_dir`. + // Gated by the `media` compile-time feature (#4804); absent from slim + // builds. Runtime `DomainSet::media` (#4796) still gates it when compiled. + #[cfg(feature = "media")] tools.extend(crate::openhuman::media_generation::build_media_tools( root_config, action_dir, diff --git a/src/openhuman/tools/ops_tests.rs b/src/openhuman/tools/ops_tests.rs index 3e1e0d5b7e..9ddb2cfe28 100644 --- a/src/openhuman/tools/ops_tests.rs +++ b/src/openhuman/tools/ops_tests.rs @@ -260,6 +260,40 @@ fn all_tools_always_registers_curl() { ); } +// Compile-time `media` feature gate (#4804). The media-generation agent tools +// (`media_generate_*`) are present only when the `media` feature is compiled +// in AND an integration client is configured. The disabled build proves the +// module + its single call site drop out entirely (leaf gate, no stub facade). +#[cfg(feature = "media")] +#[test] +fn media_tools_registered_when_feature_on() { + let tmp = TempDir::new().unwrap(); + let cfg = integration_test_config(&tmp, "http://127.0.0.1:1"); + store_test_session_token(&cfg); + let tools = integration_tools_for_config(&tmp, &cfg); + let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); + assert!( + names.contains(&"media_generate_image"), + "media tools must register with the `media` feature on + an integration \ + client; got: {names:?}" + ); +} + +#[cfg(not(feature = "media"))] +#[test] +fn media_tools_absent_when_feature_off() { + let tmp = TempDir::new().unwrap(); + let cfg = integration_test_config(&tmp, "http://127.0.0.1:1"); + store_test_session_token(&cfg); + let tools = integration_tools_for_config(&tmp, &cfg); + let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); + assert!( + !names.iter().any(|n| n.starts_with("media_")), + "no `media_*` tools may be registered when the `media` feature is off; \ + got: {names:?}" + ); +} + #[test] fn all_tools_registers_gitbooks_when_enabled() { let tmp = TempDir::new().unwrap(); diff --git a/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs b/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs index 6865e8da0c..0e0d455b69 100644 --- a/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs +++ b/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs @@ -411,17 +411,13 @@ async fn max_iteration_checkpoint_uses_deterministic_fallback_and_hooks() { let calls = Arc::new(AtomicUsize::new(0)); let hook_calls = Arc::new(AtomicUsize::new(0)); let hook_contexts = Arc::new(Mutex::new(Vec::new())); + // The wrap-up model call yields nothing (empty text + no streamed deltas), so + // `summarize_turn_wrapup` returns an empty summary and the cap path falls back + // to `build_deterministic_checkpoint` — the exact path this test covers. (A + // non-empty wrap-up would instead be surfaced verbatim as the answer.) let provider = ScriptedProvider::with_stream( - vec![ - xml_tool_response("alpha"), - text_response( - "{\"name\":\"round24_echo\",\"arguments\":{\"value\":\"again\"}}", - None, - ), - ], - vec![ProviderDelta::TextDelta { - delta: "checkpoint delta".to_string(), - }], + vec![xml_tool_response("alpha"), text_response("", None)], + vec![], ); let mut agent = Agent::builder() @@ -454,7 +450,7 @@ async fn max_iteration_checkpoint_uses_deterministic_fallback_and_hooks() { let answer = agent.turn("hit the cap").await.unwrap(); assert!(answer.contains("I reached the tool-call limit for this turn (1 steps)")); - // The unified TurnEngine digest uses `- round24_echo [ok]: ...` format (no backticks). + // The deterministic checkpoint lists each executed tool, e.g. ``- `round24_echo` — ok``. assert!(answer.contains("round24_echo")); assert_eq!(calls.load(Ordering::SeqCst), 1); wait_for_hook_calls(&hook_calls, 1).await; @@ -480,12 +476,11 @@ async fn max_iteration_checkpoint_uses_deterministic_fallback_and_hooks() { while let Ok(event) = progress_rx.try_recv() { streamed.push(event); } - assert!(streamed.iter().any(|event| matches!( + // The wrap-up produced no text, so no iteration-2 wrap-up delta is streamed; + // the deterministic fallback (asserted above) becomes the answer instead. + assert!(!streamed.iter().any(|event| matches!( event, - openhuman_core::openhuman::agent::progress::AgentProgress::TextDelta { - delta, - iteration: 2 - } if delta == "checkpoint delta" + openhuman_core::openhuman::agent::progress::AgentProgress::TextDelta { iteration: 2, .. } ))); } From eb490b540c696f87b30a5f3ac2512caaeb723853 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:08:16 +0530 Subject: [PATCH 15/86] fix(flows): canvas Accept persists the proposal (accept = review + save) (#4893) --- .../flows/WorkflowCopilotPanel.test.tsx | 93 +++++++- .../components/flows/WorkflowCopilotPanel.tsx | 59 ++++- app/src/lib/i18n/ar.ts | 2 + app/src/lib/i18n/bn.ts | 2 + app/src/lib/i18n/de.ts | 2 + app/src/lib/i18n/en.ts | 2 + app/src/lib/i18n/es.ts | 2 + app/src/lib/i18n/fr.ts | 2 + app/src/lib/i18n/hi.ts | 2 + app/src/lib/i18n/id.ts | 2 + app/src/lib/i18n/it.ts | 2 + app/src/lib/i18n/ko.ts | 2 + app/src/lib/i18n/pl.ts | 2 + app/src/lib/i18n/pt.ts | 2 + app/src/lib/i18n/ru.ts | 4 +- app/src/lib/i18n/zh-CN.ts | 2 + app/src/pages/FlowCanvasPage.tsx | 198 ++++++++++------ .../pages/__tests__/FlowCanvasPage.test.tsx | 221 ++++++++++++------ 18 files changed, 445 insertions(+), 156 deletions(-) diff --git a/app/src/components/flows/WorkflowCopilotPanel.test.tsx b/app/src/components/flows/WorkflowCopilotPanel.test.tsx index 0fe3fdea73..d46ae467ef 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.test.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.test.tsx @@ -301,8 +301,8 @@ describe('WorkflowCopilotPanel', () => { expect(screen.getByTestId('workflow-copilot-removed')).toBeInTheDocument(); }); - it('Accept applies to the draft and clears the proposal (never persists)', () => { - const onAccept = vi.fn(); + it('Accept calls onAccept (host applies + saves) and clears the proposal once it resolves', async () => { + const onAccept = vi.fn().mockResolvedValue(undefined); hookState.proposal = proposalWith(['a', 'c']); render( { ); fireEvent.click(screen.getByTestId('workflow-copilot-accept')); expect(onAccept).toHaveBeenCalledWith(hookState.proposal); - expect(hookState.clearProposal).toHaveBeenCalledTimes(1); + await waitFor(() => expect(hookState.clearProposal).toHaveBeenCalledTimes(1)); + }); + + it('shows the saving label and disables Accept while the host save is in flight', async () => { + // Deferred promise so the test controls exactly when the host's save + // (`onAccept`) resolves, to observe the in-between "saving" state. + let resolveSave!: () => void; + const savePromise = new Promise(resolve => { + resolveSave = resolve; + }); + const onAccept = vi.fn().mockReturnValue(savePromise); + hookState.proposal = proposalWith(['a', 'c']); + render( + + ); + + fireEvent.click(screen.getByTestId('workflow-copilot-accept')); + await waitFor(() => + expect(screen.getByTestId('workflow-copilot-accept')).toHaveTextContent( + 'flows.copilot.saving' + ) + ); + expect(screen.getByTestId('workflow-copilot-accept')).toBeDisabled(); + expect(hookState.clearProposal).not.toHaveBeenCalled(); + + resolveSave(); + await waitFor(() => expect(hookState.clearProposal).toHaveBeenCalledTimes(1)); + }); + + it('leaves the proposal visible for retry when the host save rejects', async () => { + const onAccept = vi.fn().mockRejectedValue(new Error('save failed')); + hookState.proposal = proposalWith(['a', 'c']); + render( + + ); + + fireEvent.click(screen.getByTestId('workflow-copilot-accept')); + await waitFor(() => expect(onAccept).toHaveBeenCalledTimes(1)); + // The button re-enables once the rejected save settles, and the proposal + // was never cleared — the card stays up so the user can retry. + await waitFor(() => expect(screen.getByTestId('workflow-copilot-accept')).not.toBeDisabled()); + expect(hookState.clearProposal).not.toHaveBeenCalled(); + }); + + it('disables Reject while an Accept save is in flight, so it cannot race the persisted save', async () => { + // Regression for the CodeRabbit finding: Reject must not stay clickable + // while `onAccept`'s save is still pending, otherwise the user's cancel + // can be silently overridden by the earlier Accept's save landing after. + let resolveSave!: () => void; + const savePromise = new Promise(resolve => { + resolveSave = resolve; + }); + const onAccept = vi.fn().mockReturnValue(savePromise); + const onReject = vi.fn(); + hookState.proposal = proposalWith(['a', 'c']); + render( + + ); + + fireEvent.click(screen.getByTestId('workflow-copilot-accept')); + await waitFor(() => expect(screen.getByTestId('workflow-copilot-reject')).toBeDisabled()); + + // A click while disabled is a no-op in jsdom/RTL — Reject must not fire. + fireEvent.click(screen.getByTestId('workflow-copilot-reject')); + expect(onReject).not.toHaveBeenCalled(); + expect(hookState.clearProposal).not.toHaveBeenCalled(); + + resolveSave(); + await waitFor(() => expect(hookState.clearProposal).toHaveBeenCalledTimes(1)); + expect(screen.getByTestId('workflow-copilot-reject')).not.toBeDisabled(); }); it('Reject discards the proposal without applying it', () => { diff --git a/app/src/components/flows/WorkflowCopilotPanel.tsx b/app/src/components/flows/WorkflowCopilotPanel.tsx index d897fcedcb..582f3ea7cd 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.tsx @@ -16,8 +16,13 @@ * `streamingAssistantByThread`, streamed here by Phase B). So the copilot reads * like a real chat rather than a one-shot form. * - * Invariant: the copilot only PROPOSES. Accept applies to the UNSAVED local - * draft (no `flows_update`); persistence stays behind the canvas's own Save. + * Invariant: the copilot only PROPOSES — the agent turn itself never + * persists. Accept applies the proposal to the local draft AND immediately + * saves it (review + save in one click) via the host's `onAccept`, which + * awaits the host's own persistence call; the panel shows a saving state + * meanwhile and, if the save fails, leaves the proposal visible for retry + * rather than silently discarding it. Reject remains local-only (revert the + * overlay, no persistence call). */ import createDebug from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -65,8 +70,13 @@ interface Props { * reflects it. */ onProposal: (proposal: WorkflowProposal) => void; - /** Accept the pending proposal into the local draft (host commits it). */ - onAccept: (proposal: WorkflowProposal) => void; + /** + * Accept the pending proposal (host applies it to the local draft AND + * persists it — "accept" is now review + save in one step). May return a + * promise the panel awaits to show a saving state; a rejected promise + * leaves the proposal visible so the user can retry. + */ + onAccept: (proposal: WorkflowProposal) => void | Promise; /** Reject the pending proposal (host reverts the overlay). */ onReject: () => void; /** Close the panel. */ @@ -376,12 +386,35 @@ export default function WorkflowCopilotPanel({ const noopAttach = useCallback(async () => {}, []); const noop = useCallback(() => {}, []); - const accept = useCallback(() => { - if (!proposal) return; - onAccept(proposal); - clearProposal(); - lastSurfacedRef.current = null; - }, [proposal, onAccept, clearProposal]); + // Accept now review-and-saves: `onAccept` (the host's `handleAcceptProposal`) + // applies the proposal to the draft AND persists it. Track a local + // `acceptSaving` flag so the button can show a saving state and disable + // re-clicks while that's in flight. If the host's save throws, leave the + // proposal card visible (don't `clearProposal()`) so the user can retry — + // otherwise a failed autosave would silently vanish the only affordance to + // try again from the copilot itself (the header Save button is a fallback, + // but this keeps the copilot's own flow self-contained). + const [acceptSaving, setAcceptSaving] = useState(false); + const accept = useCallback(async () => { + // Self-guard against re-entrance: the JSX `disabled={acceptSaving}` on + // the Accept button prevents a normal double-click, but `acceptSaving` + // only flips after the FIRST call's `setAcceptSaving(true)` commits — a + // second invocation racing ahead of that render (e.g. programmatic + // re-fire) must not start a second concurrent save. + if (!proposal || acceptSaving) return; + setAcceptSaving(true); + log('accept: saving proposal via host onAccept'); + try { + await onAccept(proposal); + log('accept: save succeeded, clearing proposal'); + clearProposal(); + lastSurfacedRef.current = null; + } catch (err) { + log('accept: save failed, leaving proposal visible for retry err=%o', err); + } finally { + setAcceptSaving(false); + } + }, [proposal, acceptSaving, onAccept, clearProposal]); const reject = useCallback(() => { onReject(); @@ -533,14 +566,16 @@ export default function WorkflowCopilotPanel({ type="button" variant="primary" size="sm" + disabled={acceptSaving} data-testid="workflow-copilot-accept" - onClick={accept}> - {t('flows.copilot.accept')} + onClick={() => void accept()}> + {acceptSaving ? t('flows.copilot.saving') : t('flows.copilot.acceptAndSave')} + ); +} + /** * The root-shell sidebar, split top-to-bottom into: * @@ -33,18 +81,48 @@ export default function AppSidebar() { const { t } = useT(); const location = useLocation(); const navigate = useNavigate(); + const { snapshot: coreSnapshot, isReady } = useCoreState(); + // Rewards is a cloud-only surface (credits/referrals/coupons live behind the + // backend rewards API); the page itself renders an "unavailable" state for + // local sessions, so there's no point offering the entry there. Mirrors the + // `cloudOnly` intent recorded for rewards in navConfig's AVATAR_MENU_ITEMS. + // + // Show it only once core state has bootstrapped to a real, non-local session. + // The initial snapshot is `{ isReady: false, sessionToken: null }`, and + // `isLocalSessionToken(null)` is `false`, so gating on the token alone would + // briefly flash Rewards for a local session until the first refresh resolves. + const showRewards = + isReady && + Boolean(coreSnapshot.sessionToken) && + !isLocalSessionToken(coreSnapshot.sessionToken); const feedbackActive = location.pathname === '/feedback'; + const rewardsActive = location.pathname === '/rewards'; + + // Log the gate outcome whenever it resolves/flips. Booleans only — never the + // session token or a raw path. + useEffect(() => { + log( + 'rewards footer entry visibility resolved: visible=%s isReady=%s hasSession=%s local=%s', + showRewards, + isReady, + Boolean(coreSnapshot.sessionToken), + isLocalSessionToken(coreSnapshot.sessionToken) + ); + }, [showRewards, isReady, coreSnapshot.sessionToken]); - const handleFeedbackClick = () => { - if (!feedbackActive) { + const handleFooterNav = (tab: string, path: string, active: boolean) => { + log('footer nav click: tab=%s active=%s', tab, active); + if (!active) { trackEvent('tab_bar_change', { from_tab: 'unknown', - to_tab: 'feedback', - from_path: location.pathname, - to_path: '/feedback', + to_tab: tab, + // Normalize to a route template so route-scoped entity IDs (thread, + // flow, team, …) never leave the app via analytics. + from_path: normalizeAnalyticsPagePath(location.pathname), + to_path: path, }); } - navigate('/feedback'); + navigate(path); }; return ( @@ -66,23 +144,24 @@ export default function AppSidebar() { app rail above its thread list) can order them via Tailwind `order-*`. */}
- {/* Slim feedback row — pinned just above the status bar. Kept thin and - low-profile so it reads as a footer affordance, not a primary nav tab - (it used to live in SidebarNav). */} - + {/* Slim account affordances pinned above the status bar — Rewards then + Feedback. Rewards is shown only for a resolved cloud session. */} + {showRewards && ( + handleFooterNav('rewards', '/rewards', rewardsActive)} + /> + )} + handleFooterNav('feedback', '/feedback', feedbackActive)} + /> {/* App-wide footer: connectivity status + build/version, pinned to the bottom of the sidebar. */}
diff --git a/app/src/components/layout/shell/navIcons.tsx b/app/src/components/layout/shell/navIcons.tsx index dc785dae60..fefd6299c9 100644 --- a/app/src/components/layout/shell/navIcons.tsx +++ b/app/src/components/layout/shell/navIcons.tsx @@ -193,6 +193,19 @@ export function NavIcon({ id, className = 'w-5 h-5' }: NavIconProps) { /> ); + case 'rewards': + // Gift box — mirrors the glyph the Rewards page uses for its own header + // and welcome icon, so the sidebar entry reads as the same destination. + return ( + + + + ); default: return null; } From 7d4770ef7672ceafe0ae7129136824cf5c24b278 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:24:17 +0530 Subject: [PATCH 20/86] fix(flows): show "awaiting approval" state in runs lists when a run is halted at an approval gate (#4932) --- .../components/flows/FlowRunsDrawer.test.tsx | 39 ++++ app/src/components/flows/FlowRunsDrawer.tsx | 12 +- .../components/flows/FlowRunsSidebar.test.tsx | 33 +++ app/src/components/flows/FlowRunsSidebar.tsx | 54 +++-- .../useRunsPendingApprovalSet.test.ts | 214 ++++++++++++++++++ app/src/hooks/useRunsPendingApprovalSet.ts | 118 ++++++++++ app/src/pages/WorkflowRunsPage.test.tsx | 42 ++++ app/src/pages/WorkflowRunsPage.tsx | 58 +++-- 8 files changed, 519 insertions(+), 51 deletions(-) create mode 100644 app/src/hooks/__tests__/useRunsPendingApprovalSet.test.ts create mode 100644 app/src/hooks/useRunsPendingApprovalSet.ts diff --git a/app/src/components/flows/FlowRunsDrawer.test.tsx b/app/src/components/flows/FlowRunsDrawer.test.tsx index 43cf8096a4..d762ff6806 100644 --- a/app/src/components/flows/FlowRunsDrawer.test.tsx +++ b/app/src/components/flows/FlowRunsDrawer.test.tsx @@ -23,6 +23,9 @@ import { FlowRunsDrawer } from './FlowRunsDrawer'; const listFlowRuns = vi.hoisted(() => vi.fn()); vi.mock('../../services/api/flowsApi', () => ({ listFlowRuns })); +const fetchPendingApprovals = vi.hoisted(() => vi.fn()); +vi.mock('../../services/api/approvalApi', () => ({ fetchPendingApprovals })); + const FlowRunInspectorDrawer = vi.hoisted(() => vi.fn()); vi.mock('./FlowRunInspectorDrawer', () => ({ FLOW_RUN_STATUS_ACCENT: { @@ -87,6 +90,7 @@ function renderDrawer(flowId: string | null, onClose: () => void, flowName?: str describe('FlowRunsDrawer', () => { beforeEach(() => { vi.clearAllMocks(); + fetchPendingApprovals.mockResolvedValue([]); }); it('renders null when flowId is null', () => { @@ -123,6 +127,41 @@ describe('FlowRunsDrawer', () => { expect(row).toHaveTextContent('Completed with warnings'); }); + it('shows "Awaiting approval" for a running run halted at a matching flow approval gate', async () => { + listFlowRuns.mockResolvedValue([makeRun({ id: 'run-1', status: 'running' })]); + fetchPendingApprovals.mockResolvedValue([ + { + request_id: 'req-1', + tool_name: 'SLACK_SEND_MESSAGE', + action_summary: 'Send Slack message', + args_redacted: {}, + session_id: 'session-1', + created_at: '2026-01-01T00:00:00Z', + expires_at: null, + source_context: { kind: 'flow', flow_id: 'flow-1', run_id: 'run-1' }, + }, + ]); + renderDrawer('flow-1', vi.fn()); + + const row = await screen.findByTestId('flow-run-row-run-1'); + await waitFor(() => expect(row).toHaveTextContent('Awaiting approval')); + expect(screen.getByTestId('flow-run-row-dot-run-1').className.includes('dot-pending')).toBe( + true + ); + }); + + it('leaves a running run without a matching flow approval labeled "Running"', async () => { + listFlowRuns.mockResolvedValue([makeRun({ id: 'run-1', status: 'running' })]); + fetchPendingApprovals.mockResolvedValue([]); + renderDrawer('flow-1', vi.fn()); + + const row = await screen.findByTestId('flow-run-row-run-1'); + await waitFor(() => expect(row).toHaveTextContent('Running')); + expect(screen.getByTestId('flow-run-row-dot-run-1').className.includes('dot-running')).toBe( + true + ); + }); + it('falls back to a generic title when no flowName is given', async () => { listFlowRuns.mockResolvedValue([]); renderDrawer('flow-1', vi.fn()); diff --git a/app/src/components/flows/FlowRunsDrawer.tsx b/app/src/components/flows/FlowRunsDrawer.tsx index 8f44848162..368c73b779 100644 --- a/app/src/components/flows/FlowRunsDrawer.tsx +++ b/app/src/components/flows/FlowRunsDrawer.tsx @@ -29,6 +29,10 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useEscapeKey } from '../../hooks/useEscapeKey'; import { useFlowRunsLiveRefresh } from '../../hooks/useFlowRunsLiveRefresh'; +import { + resolveDisplayStatus, + useRunsPendingApprovalSet, +} from '../../hooks/useRunsPendingApprovalSet'; import { useT } from '../../lib/i18n/I18nContext'; import { type FlowRun, listFlowRuns } from '../../services/api/flowsApi'; import { @@ -142,6 +146,7 @@ export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Pr }, [flowId]); useFlowRunsLiveRefresh(runs, refetch); + const pendingRunIds = useRunsPendingApprovalSet(runs); useEscapeKey( () => { @@ -213,6 +218,7 @@ export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Pr
    {runs.map(run => { const startedAt = formatTimestamp(run.started_at); + const displayStatus = resolveDisplayStatus(run, pendingRunIds); return (
  • -
  • - ))} + + + ); + })}
diff --git a/app/src/hooks/__tests__/useRunsPendingApprovalSet.test.ts b/app/src/hooks/__tests__/useRunsPendingApprovalSet.test.ts new file mode 100644 index 0000000000..80abba31e9 --- /dev/null +++ b/app/src/hooks/__tests__/useRunsPendingApprovalSet.test.ts @@ -0,0 +1,214 @@ +/** + * useRunsPendingApprovalSet — unit tests. + * + * Verifies: no poll when every run is terminal (or the list is empty); polls + * `approval_list_pending` every 3s while a run is `running`; collects only + * flow-origin (`source_context.kind === 'flow'`) matches into the returned + * Set; a chat-origin approval (no flow `source_context`) is excluded; a + * failed poll keeps the last-known set instead of clearing it; teardown once + * every run settles to terminal; cleanup on unmount. + */ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { PendingApproval } from '../../services/api/approvalApi'; +import type { FlowRun } from '../../services/api/flowsApi'; +import { resolveDisplayStatus, useRunsPendingApprovalSet } from '../useRunsPendingApprovalSet'; + +const fetchPendingApprovals = vi.hoisted(() => vi.fn()); +vi.mock('../../services/api/approvalApi', () => ({ fetchPendingApprovals })); + +function makeRun(overrides: Partial = {}): FlowRun { + return { + id: 'run-1', + flow_id: 'flow-1', + thread_id: 'run-1', + status: 'running', + started_at: '2026-01-01T00:00:00Z', + steps: [], + pending_approvals: [], + ...overrides, + }; +} + +function makeApproval(overrides: Partial = {}): PendingApproval { + return { + request_id: 'req-1', + tool_name: 'shell', + action_summary: 'Run `shell`', + args_redacted: {}, + session_id: 'session-1', + created_at: '2026-01-01T00:00:00Z', + expires_at: null, + source_context: { kind: 'flow', flow_id: 'flow-1', run_id: 'run-1' }, + ...overrides, + }; +} + +describe('useRunsPendingApprovalSet', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not poll when every run is terminal', async () => { + fetchPendingApprovals.mockResolvedValue([]); + renderHook(() => + useRunsPendingApprovalSet([ + makeRun({ status: 'completed' }), + makeRun({ id: 'run-2', status: 'failed' }), + ]) + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(20_000); + }); + expect(fetchPendingApprovals).not.toHaveBeenCalled(); + }); + + it('does not poll for an empty runs list', async () => { + fetchPendingApprovals.mockResolvedValue([]); + renderHook(() => useRunsPendingApprovalSet([])); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(fetchPendingApprovals).not.toHaveBeenCalled(); + }); + + it('polls every 3s while a run is running and includes it when a matching flow approval exists', async () => { + fetchPendingApprovals.mockResolvedValue([makeApproval({ request_id: 'req-a' })]); + const { result } = renderHook(() => + useRunsPendingApprovalSet([makeRun({ status: 'running' })]) + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(fetchPendingApprovals).toHaveBeenCalledTimes(1); + expect(result.current.has('run-1')).toBe(true); + + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(fetchPendingApprovals).toHaveBeenCalledTimes(2); + }); + + it('excludes a running run with no matching pending approval', async () => { + fetchPendingApprovals.mockResolvedValue([]); + const { result } = renderHook(() => + useRunsPendingApprovalSet([makeRun({ status: 'running' })]) + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.size).toBe(0); + }); + + it('excludes a chat-origin approval (no flow source_context)', async () => { + fetchPendingApprovals.mockResolvedValue([ + makeApproval({ request_id: 'req-chat', source_context: undefined }), + ]); + const { result } = renderHook(() => + useRunsPendingApprovalSet([makeRun({ status: 'running' })]) + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.size).toBe(0); + }); + + it('keeps the last-known set when a poll fails', async () => { + fetchPendingApprovals.mockResolvedValueOnce([makeApproval({ request_id: 'req-a' })]); + const { result, rerender } = renderHook( + ({ runs }: { runs: FlowRun[] }) => useRunsPendingApprovalSet(runs), + { initialProps: { runs: [makeRun({ status: 'running' })] } } + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.has('run-1')).toBe(true); + + fetchPendingApprovals.mockRejectedValueOnce(new Error('network down')); + rerender({ runs: [makeRun({ status: 'running' })] }); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(fetchPendingApprovals).toHaveBeenCalledTimes(2); + expect(result.current.has('run-1')).toBe(true); + + // A subsequent successful tick keeps polling on the same cadence. + fetchPendingApprovals.mockResolvedValueOnce([makeApproval({ request_id: 'req-a' })]); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(fetchPendingApprovals).toHaveBeenCalledTimes(3); + }); + + it('tears down polling once every run settles to terminal', async () => { + fetchPendingApprovals.mockResolvedValue([makeApproval({ request_id: 'req-a' })]); + const { rerender } = renderHook( + ({ runs }: { runs: FlowRun[] }) => useRunsPendingApprovalSet(runs), + { initialProps: { runs: [makeRun({ status: 'running' })] } } + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(fetchPendingApprovals).toHaveBeenCalledTimes(1); + + rerender({ runs: [makeRun({ status: 'completed' })] }); + + fetchPendingApprovals.mockClear(); + await act(async () => { + await vi.advanceTimersByTimeAsync(20_000); + }); + expect(fetchPendingApprovals).not.toHaveBeenCalled(); + }); + + it('cleans up pending timers on unmount', async () => { + fetchPendingApprovals.mockResolvedValue([]); + const { unmount } = renderHook(() => + useRunsPendingApprovalSet([makeRun({ status: 'running' })]) + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(fetchPendingApprovals).toHaveBeenCalledTimes(1); + + unmount(); + + fetchPendingApprovals.mockClear(); + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(fetchPendingApprovals).not.toHaveBeenCalled(); + }); +}); + +describe('resolveDisplayStatus', () => { + it('overrides to pending_approval when running and the run id is in the pending set', () => { + const run = makeRun({ status: 'running' }); + const pendingRunIds = new Set(['run-1']); + expect(resolveDisplayStatus(run, pendingRunIds)).toBe('pending_approval'); + }); + + it('leaves the status untouched when running but not in the pending set', () => { + const run = makeRun({ status: 'running' }); + expect(resolveDisplayStatus(run, new Set())).toBe('running'); + }); + + it('leaves non-running statuses untouched even if the id is (stale) in the pending set', () => { + const run = makeRun({ status: 'completed' }); + const pendingRunIds = new Set(['run-1']); + expect(resolveDisplayStatus(run, pendingRunIds)).toBe('completed'); + }); +}); diff --git a/app/src/hooks/useRunsPendingApprovalSet.ts b/app/src/hooks/useRunsPendingApprovalSet.ts new file mode 100644 index 0000000000..0d185a3ae7 --- /dev/null +++ b/app/src/hooks/useRunsPendingApprovalSet.ts @@ -0,0 +1,118 @@ +/** + * useRunsPendingApprovalSet — cross-references the shared approval queue + * against a runs LIST so surfaces that show many runs at once (sidebar, + * drawer, all-runs page) can distinguish a run merely `running` from one + * that's actually halted at an interactive `ApprovalGate` mid-run. + * + * The core has no separate DB status for "parked at an approval gate" — a + * run stays `status: "running"` in `FlowRun` while it waits; only the shared + * `openhuman.approval_list_pending` queue (see `useFlowPendingApprovals`, + * the single-run analogue used by `FlowRunInspectorDrawer`) knows it's + * parked, via `PendingApproval.source_context = { kind: "flow", run_id }`. + * + * While at least one run in the list is `running`, this hook polls that + * shared queue every {@link POLL_INTERVAL_MS} and returns the Set of + * `run_id`s with a matching flow-origin pending approval. Callers combine + * this with `run.status` via {@link resolveDisplayStatus} to override the + * DISPLAY status only — `run.status` itself, and `useFlowRunsLiveRefresh` + * (which reads the ORIGINAL runs array), are untouched, so `running` stays + * non-terminal there and the list keeps refetching until the run truly + * finishes. + * + * Mirrors the poll-loop + cleanup shape of `useFlowPendingApprovals` + * (cancelled flag + `setTimeout` reschedule + teardown) and the + * gate-on-activity contract of `useFlowRunsLiveRefresh`. Unlike + * `useFlowPendingApprovals`, a failed poll here does NOT surface an error or + * stop polling — this is a best-effort display hint, not a source of truth, + * so a transient failure just keeps showing the last-known set and retries + * on the next tick. + */ +import debug from 'debug'; +import { useEffect, useState } from 'react'; + +import { fetchPendingApprovals } from '../services/api/approvalApi'; +import type { FlowRun, FlowRunStatus } from '../services/api/flowsApi'; + +const log = debug('flows:runs-pending-approval-set'); + +/** How often to re-poll the shared approval queue while any run is `running`. */ +const POLL_INTERVAL_MS = 3000; + +/** + * Poll `openhuman.approval_list_pending` every {@link POLL_INTERVAL_MS}ms + * while `runs` contains at least one `status === 'running'` entry, and + * return the Set of `run_id`s with a flow-origin pending approval + * (`source_context.kind === 'flow'`). Stops polling — but does not clear the + * last-known set — once every run has left `running` (completed, failed, + * cancelled, or already reflected as `pending_approval`). Best-effort: a + * failed poll is logged and simply retried next tick, keeping the + * last-known set rather than surfacing an error to the caller. + */ +export function useRunsPendingApprovalSet(runs: FlowRun[]): Set { + const [pendingRunIds, setPendingRunIds] = useState>(() => new Set()); + + const hasRunning = runs.some(run => run.status === 'running'); + + useEffect(() => { + if (!hasRunning) { + log('approval polling skipped: no-running-runs'); + return; + } + log('approval polling started'); + + let cancelled = false; + let pollHandle: number | undefined; + + const tick = async () => { + if (cancelled) return; + try { + log('approval poll: calling fetchPendingApprovals'); + const all = await fetchPendingApprovals(); + if (cancelled) return; + const next = new Set(); + for (const approval of all) { + if (approval.source_context?.kind === 'flow') { + next.add(approval.source_context.run_id); + } + } + log('approval poll: succeeded total=%d flow-scoped=%d', all.length, next.size); + setPendingRunIds(next); + } catch (err) { + const errorType = err instanceof Error ? err.name : typeof err; + log('approval poll: failed error_type=%s; preserving-last-known-set', errorType); + // Best-effort — leave `pendingRunIds` as-is and retry next tick. + } finally { + if (!cancelled) { + log('approval poll: scheduling retry delay_ms=%d', POLL_INTERVAL_MS); + pollHandle = window.setTimeout(() => void tick(), POLL_INTERVAL_MS); + } + } + }; + + void tick(); + return () => { + cancelled = true; + if (pollHandle !== undefined) window.clearTimeout(pollHandle); + log('approval polling stopped'); + }; + }, [hasRunning]); + + return pendingRunIds; +} + +/** + * Overrides a run's DISPLAY status to `pending_approval` when it's currently + * `running` AND the shared approval queue (via {@link useRunsPendingApprovalSet}) + * has a flow-origin pending approval for it. Does not mutate `run` or touch + * any other status — callers substitute the result at render sites only + * (status dot / accent / label), leaving `run.status` itself (and anything + * keyed on it, like `useFlowRunsLiveRefresh`'s terminal-status check) intact. + */ +export function resolveDisplayStatus(run: FlowRun, pendingRunIds: Set): FlowRunStatus { + if (run.status === 'running' && pendingRunIds.has(run.id)) { + return 'pending_approval'; + } + return run.status; +} + +export default useRunsPendingApprovalSet; diff --git a/app/src/pages/WorkflowRunsPage.test.tsx b/app/src/pages/WorkflowRunsPage.test.tsx index d03d4e66bf..99ffd273a4 100644 --- a/app/src/pages/WorkflowRunsPage.test.tsx +++ b/app/src/pages/WorkflowRunsPage.test.tsx @@ -17,6 +17,9 @@ vi.mock('../services/api/flowsApi', () => ({ listFlows: (...a: unknown[]) => listFlows(...a), })); +const fetchPendingApprovals = vi.hoisted(() => vi.fn()); +vi.mock('../services/api/approvalApi', () => ({ fetchPendingApprovals })); + // PanelPage + LoadingState pull i18n/redux we don't need — stub to bare markup. vi.mock('../components/layout/PanelPage', () => ({ default: ({ children }: { children: React.ReactNode }) =>
{children}
, @@ -31,6 +34,8 @@ describe('WorkflowRunsPage', () => { navigateMock.mockReset(); listAllFlowRuns.mockReset(); listFlows.mockReset(); + fetchPendingApprovals.mockReset(); + fetchPendingApprovals.mockResolvedValue([]); }); it('renders aggregate runs with their workflow name and status', async () => { @@ -76,6 +81,43 @@ describe('WorkflowRunsPage', () => { await waitFor(() => expect(screen.getByText('rpc down')).toBeInTheDocument()); }); + it('shows "pending approval" for a running run halted at a matching flow approval gate', async () => { + listAllFlowRuns.mockResolvedValue([ + { id: 'r1', flow_id: 'f1', status: 'running', started_at: '2026-01-01T00:00:00Z' }, + ]); + listFlows.mockResolvedValue([{ id: 'f1', name: 'Daily digest' }]); + fetchPendingApprovals.mockResolvedValue([ + { + request_id: 'req-1', + tool_name: 'SLACK_SEND_MESSAGE', + action_summary: 'Send Slack message', + args_redacted: {}, + session_id: 'session-1', + created_at: '2026-01-01T00:00:00Z', + expires_at: null, + source_context: { kind: 'flow', flow_id: 'f1', run_id: 'r1' }, + }, + ]); + + render(); + + const row = await screen.findByTestId('workflow-run-r1'); + await waitFor(() => expect(row).toHaveTextContent('pending approval')); + }); + + it('leaves a running run without a matching flow approval labeled "running"', async () => { + listAllFlowRuns.mockResolvedValue([ + { id: 'r1', flow_id: 'f1', status: 'running', started_at: '2026-01-01T00:00:00Z' }, + ]); + listFlows.mockResolvedValue([{ id: 'f1', name: 'Daily digest' }]); + fetchPendingApprovals.mockResolvedValue([]); + + render(); + + const row = await screen.findByTestId('workflow-run-r1'); + await waitFor(() => expect(row).toHaveTextContent('running')); + }); + describe('live refresh (useFlowRunsLiveRefresh integration)', () => { afterEach(() => { vi.useRealTimers(); diff --git a/app/src/pages/WorkflowRunsPage.tsx b/app/src/pages/WorkflowRunsPage.tsx index 874dd0de37..756985400d 100644 --- a/app/src/pages/WorkflowRunsPage.tsx +++ b/app/src/pages/WorkflowRunsPage.tsx @@ -13,6 +13,10 @@ import { useNavigate } from 'react-router-dom'; import PanelPage from '../components/layout/PanelPage'; import { CenteredLoadingState, ErrorBanner } from '../components/ui/LoadingState'; import { useFlowRunsLiveRefresh } from '../hooks/useFlowRunsLiveRefresh'; +import { + resolveDisplayStatus, + useRunsPendingApprovalSet, +} from '../hooks/useRunsPendingApprovalSet'; import { useT } from '../lib/i18n/I18nContext'; import { type Flow, @@ -77,6 +81,7 @@ export default function WorkflowRunsPage() { }, []); useFlowRunsLiveRefresh(runs, refetchRuns); + const pendingRunIds = useRunsPendingApprovalSet(runs); const statusLabel = (status: FlowRunStatus) => t(`flows.allRuns.status.${status}`, status.replace(/_/g, ' ')); @@ -101,31 +106,34 @@ export default function WorkflowRunsPage() {
    - {runs.map(run => ( -
  • - - {run.error && ( -

    - {run.error} -

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

    + {run.error} +

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

+ {t('memorySources.codingSessions.title')} +

+

+ {t('memorySources.codingSessions.description')} +

+
+ +
+ +
+ {sources.map(source => ( +
+
+ {t(SOURCE_LABEL_KEYS[source.kind])} +
+
+ {source.available + ? t('memorySources.codingSessions.counts') + .replace('{files}', String(source.session_files)) + .replace('{evidence}', String(source.evidence_units)) + : t('memorySources.codingSessions.notFound')} +
+ {source.available && source.scan_truncated && ( +
+ {t('memorySources.codingSessions.truncated')} +
+ )} +
+ ))} +
+ + {loading && ( +

+ {t('memorySources.codingSessions.scanning')} +

+ )} + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/app/src/components/intelligence/__tests__/CodingSessionsCard.test.tsx b/app/src/components/intelligence/__tests__/CodingSessionsCard.test.tsx new file mode 100644 index 0000000000..0eea7131fc --- /dev/null +++ b/app/src/components/intelligence/__tests__/CodingSessionsCard.test.tsx @@ -0,0 +1,196 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import * as service from '../../../services/memorySourcesService'; +import { renderWithProviders } from '../../../test/test-utils'; +import { CodingSessionsCard } from '../CodingSessionsCard'; + +vi.mock('../../../services/memorySourcesService', async () => { + const actual = await vi.importActual( + '../../../services/memorySourcesService' + ); + return { ...actual, getCodingSessionStatus: vi.fn(), ingestCodingSessions: vi.fn() }; +}); + +const mockedStatus = vi.mocked(service.getCodingSessionStatus); +const mockedIngest = vi.mocked(service.ingestCodingSessions); + +describe('CodingSessionsCard', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedStatus.mockResolvedValue([ + { + kind: 'claude_code', + available: true, + session_files: 2, + evidence_units: 4, + invalid_files: 0, + }, + { kind: 'codex', available: true, session_files: 3, evidence_units: 7, invalid_files: 0 }, + ]); + }); + + it('shows discovered local session counts', async () => { + renderWithProviders(); + + expect(await screen.findByTestId('coding-session-source-claude_code')).toHaveTextContent( + '2 sessions · 4 human turns' + ); + expect(screen.getByTestId('coding-session-source-codex')).toHaveTextContent( + '3 sessions · 7 human turns' + ); + expect(screen.getByTestId('coding-sessions-ingest')).toBeEnabled(); + expect(screen.getByTestId('coding-sessions-ingest')).toHaveAttribute( + 'data-analytics-id', + 'brain-sources-coding-sessions-ingest' + ); + }); + + it('ingests incrementally and reports the distilled observations', async () => { + mockedIngest.mockResolvedValue({ + mode: 'incremental', + files_seen: 5, + sessions_processed: 4, + sessions_skipped: 1, + sessions_failed: 0, + evidence_units: 11, + observations: 6, + budget_hit: false, + pack_path: '/workspace/persona/PERSONA.md', + }); + const onToast = vi.fn(); + renderWithProviders(); + + fireEvent.click(await screen.findByTestId('coding-sessions-ingest')); + + await waitFor(() => expect(mockedIngest).toHaveBeenCalledWith(false)); + await waitFor(() => + expect(onToast).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'success', + message: '4 sessions produced 6 persona observations.', + }) + ) + ); + }); + + it('keeps ingestion disabled when no human-authored evidence exists', async () => { + mockedStatus.mockResolvedValue([ + { kind: 'codex', available: false, session_files: 0, evidence_units: 0, invalid_files: 0 }, + ]); + renderWithProviders(); + + expect(await screen.findByText('No local history found')).toBeInTheDocument(); + expect(screen.getByTestId('coding-sessions-ingest')).toBeDisabled(); + }); + + it('warns when more coding sessions remain after the current batch', async () => { + mockedIngest.mockResolvedValue({ + mode: 'incremental', + files_seen: 30, + sessions_processed: 15, + sessions_skipped: 0, + sessions_failed: 0, + evidence_units: 40, + observations: 20, + budget_hit: true, + pack_path: '/workspace/persona/PERSONA.md', + }); + const onToast = vi.fn(); + renderWithProviders(); + + fireEvent.click(await screen.findByTestId('coding-sessions-ingest')); + + await waitFor(() => + expect(onToast).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'warning', + message: + 'The session batch limit was reached. Run ingestion again to continue importing your history.', + }) + ) + ); + }); + + it('reports partial session failures in the warning toast', async () => { + mockedIngest.mockResolvedValue({ + mode: 'incremental', + files_seen: 5, + sessions_processed: 3, + sessions_skipped: 0, + sessions_failed: 2, + evidence_units: 8, + observations: 4, + budget_hit: false, + pack_path: '/workspace/persona/PERSONA.md', + }); + const onToast = vi.fn(); + renderWithProviders(); + + fireEvent.click(await screen.findByTestId('coding-sessions-ingest')); + + await waitFor(() => + expect(onToast).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'warning', + message: '2 sessions failed while 3 were processed. Run ingestion again to retry them.', + }) + ) + ); + }); + + it('shows status failures as an alert', async () => { + mockedStatus.mockRejectedValue(new Error('session scan failed')); + renderWithProviders(); + + expect(await screen.findByRole('alert')).toHaveTextContent('session scan failed'); + }); + + it('reports ingestion failures through the error toast', async () => { + mockedIngest.mockRejectedValue(new Error('persona pipeline failed')); + const onToast = vi.fn(); + renderWithProviders(); + + fireEvent.click(await screen.findByTestId('coding-sessions-ingest')); + + await waitFor(() => + expect(onToast).toHaveBeenCalledWith({ + type: 'error', + title: 'Coding-session ingestion failed', + message: 'persona pipeline failed', + }) + ); + }); + + it('warns when a source scan reaches its file cap', async () => { + mockedStatus.mockResolvedValue([ + { + kind: 'codex', + available: true, + session_files: 1000, + evidence_units: 1200, + invalid_files: 0, + scan_truncated: true, + }, + ]); + renderWithProviders(); + + expect(await screen.findByText('Scan limited to the first 1,000 session files.')).toBeVisible(); + }); + + it('keeps ingestion enabled when a capped scan has not found evidence yet', async () => { + mockedStatus.mockResolvedValue([ + { + kind: 'codex', + available: true, + session_files: 1000, + evidence_units: 0, + invalid_files: 1000, + scan_truncated: true, + }, + ]); + renderWithProviders(); + + expect(await screen.findByTestId('coding-sessions-ingest')).toBeEnabled(); + }); +}); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 9b9662d400..9488af5f37 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -7101,6 +7101,25 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'حذف', 'flows.delete.deleting': 'جارٍ الحذف…', 'flows.canvas.renameLabel': 'إعادة تسمية سير العمل', + 'memorySources.codingSessions.title': 'جلسات وكلاء البرمجة', + 'memorySources.codingSessions.description': + 'حوّل قرارات وتصحيحات Codex وClaude Code إلى ذاكرة شخصية خاصة.', + 'memorySources.codingSessions.ingest': 'استيعاب الجلسات الجديدة', + 'memorySources.codingSessions.ingesting': 'جارٍ الاستيعاب…', + 'memorySources.codingSessions.claude': 'كلود كود', + 'memorySources.codingSessions.codex': 'Codex', + 'memorySources.codingSessions.counts': '{files} جلسات · {evidence} مداخلات بشرية', + 'memorySources.codingSessions.notFound': 'لم يُعثر على سجل محلي', + 'memorySources.codingSessions.scanning': 'جارٍ فحص سجل الجلسات المحلي…', + 'memorySources.codingSessions.truncated': 'اقتصر الفحص على أول 1,000 ملف جلسة.', + 'memorySources.codingSessions.complete': 'تم استيعاب جلسات البرمجة', + 'memorySources.codingSessions.completeMessage': + 'أنتجت {processed} جلسات {observations} ملاحظات شخصية.', + 'memorySources.codingSessions.partialFailure': + 'فشلت {failed} جلسات بينما تمت معالجة {processed}. شغّل الاستيعاب مرة أخرى لإعادة المحاولة.', + 'memorySources.codingSessions.moreRemaining': + 'تم بلوغ حد دفعة الجلسات. شغّل الاستيعاب مرة أخرى لمتابعة استيراد سجلك.', + 'memorySources.codingSessions.failed': 'فشل استيعاب جلسات البرمجة', 'flows.canvas.sidePanelToggle': 'اللوحة الجانبية', 'flows.canvas.legendTab': 'يدوي', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 1e34ff6cc7..601efd3ced 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -7269,6 +7269,25 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'মুছুন', 'flows.delete.deleting': 'মুছে ফেলা হচ্ছে…', 'flows.canvas.renameLabel': 'ওয়ার্কফ্লো পুনঃনামকরণ করুন', + 'memorySources.codingSessions.title': 'কোডিং-এজেন্ট সেশন', + 'memorySources.codingSessions.description': + 'Codex ও Claude Code-এর সিদ্ধান্ত এবং সংশোধনকে ব্যক্তিগত পারসোনা মেমরিতে রূপ দিন।', + 'memorySources.codingSessions.ingest': 'নতুন সেশন গ্রহণ করুন', + 'memorySources.codingSessions.ingesting': 'গ্রহণ করা হচ্ছে…', + 'memorySources.codingSessions.claude': 'ক্লড কোড', + 'memorySources.codingSessions.codex': 'Codex', + 'memorySources.codingSessions.counts': '{files}টি সেশন · {evidence}টি মানব বার্তা', + 'memorySources.codingSessions.notFound': 'কোনো স্থানীয় ইতিহাস পাওয়া যায়নি', + 'memorySources.codingSessions.scanning': 'স্থানীয় সেশন ইতিহাস স্ক্যান করা হচ্ছে…', + 'memorySources.codingSessions.truncated': 'স্ক্যানটি প্রথম ১,০০০টি সেশন ফাইলে সীমাবদ্ধ ছিল।', + 'memorySources.codingSessions.complete': 'কোডিং সেশন গ্রহণ সম্পন্ন', + 'memorySources.codingSessions.completeMessage': + '{processed}টি সেশন থেকে {observations}টি পারসোনা পর্যবেক্ষণ তৈরি হয়েছে।', + 'memorySources.codingSessions.partialFailure': + '{processed}টি সেশন প্রক্রিয়া করার সময় {failed}টি ব্যর্থ হয়েছে। আবার চেষ্টা করতে গ্রহণ পুনরায় চালান।', + 'memorySources.codingSessions.moreRemaining': + 'সেশন ব্যাচের সীমা পূর্ণ হয়েছে। আপনার ইতিহাস আমদানি চালিয়ে যেতে আবার গ্রহণ চালান।', + 'memorySources.codingSessions.failed': 'কোডিং সেশন গ্রহণ ব্যর্থ হয়েছে', 'flows.canvas.sidePanelToggle': 'সাইড প্যানেল', 'flows.canvas.legendTab': 'ম্যানুয়াল', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 871f54a665..09ea9cdfe7 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -7482,6 +7482,26 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'Löschen', 'flows.delete.deleting': 'Wird gelöscht…', 'flows.canvas.renameLabel': 'Workflow umbenennen', + 'memorySources.codingSessions.title': 'Coding-Agent-Sitzungen', + 'memorySources.codingSessions.description': + 'Verwandle Entscheidungen und Korrekturen aus Codex und Claude Code in private Persona-Erinnerungen.', + 'memorySources.codingSessions.ingest': 'Neue Sitzungen einlesen', + 'memorySources.codingSessions.ingesting': 'Wird eingelesen…', + 'memorySources.codingSessions.claude': 'Claude-Code-Verlauf', + 'memorySources.codingSessions.codex': 'Codex', + 'memorySources.codingSessions.counts': '{files} Sitzungen · {evidence} menschliche Beiträge', + 'memorySources.codingSessions.notFound': 'Kein lokaler Verlauf gefunden', + 'memorySources.codingSessions.scanning': 'Lokaler Sitzungsverlauf wird durchsucht…', + 'memorySources.codingSessions.truncated': + 'Der Scan wurde auf die ersten 1.000 Sitzungsdateien begrenzt.', + 'memorySources.codingSessions.complete': 'Coding-Sitzungen eingelesen', + 'memorySources.codingSessions.completeMessage': + '{processed} Sitzungen ergaben {observations} Persona-Beobachtungen.', + 'memorySources.codingSessions.partialFailure': + '{failed} Sitzungen sind fehlgeschlagen, während {processed} verarbeitet wurden. Starten Sie das Einlesen erneut.', + 'memorySources.codingSessions.moreRemaining': + 'Das Sitzungslimit für diesen Durchlauf wurde erreicht. Starten Sie das Einlesen erneut, um den Import fortzusetzen.', + 'memorySources.codingSessions.failed': 'Einlesen der Coding-Sitzungen fehlgeschlagen', 'flows.canvas.sidePanelToggle': 'Seitenleiste', 'flows.canvas.legendTab': 'Manuell', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 0291d524c1..d74883b67e 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -7588,6 +7588,25 @@ 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', + 'memorySources.codingSessions.title': 'Coding-agent sessions', + 'memorySources.codingSessions.description': + 'Turn your Codex and Claude Code decisions and corrections into private persona memory.', + 'memorySources.codingSessions.ingest': 'Ingest new sessions', + 'memorySources.codingSessions.ingesting': 'Ingesting…', + 'memorySources.codingSessions.claude': 'Claude Code', + 'memorySources.codingSessions.codex': 'Codex', + 'memorySources.codingSessions.counts': '{files} sessions · {evidence} human turns', + 'memorySources.codingSessions.notFound': 'No local history found', + 'memorySources.codingSessions.scanning': 'Scanning local session history…', + 'memorySources.codingSessions.truncated': 'Scan limited to the first 1,000 session files.', + 'memorySources.codingSessions.complete': 'Coding sessions ingested', + 'memorySources.codingSessions.completeMessage': + '{processed} sessions produced {observations} persona observations.', + 'memorySources.codingSessions.partialFailure': + '{failed} sessions failed while {processed} were processed. Run ingestion again to retry them.', + 'memorySources.codingSessions.moreRemaining': + 'The session batch limit was reached. Run ingestion again to continue importing your history.', + 'memorySources.codingSessions.failed': 'Coding-session ingestion failed', }; export default en; diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index b41d056590..45921f527a 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -7420,6 +7420,26 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'Eliminar', 'flows.delete.deleting': 'Eliminando…', 'flows.canvas.renameLabel': 'Cambiar el nombre del flujo de trabajo', + 'memorySources.codingSessions.title': 'Sesiones de agentes de programación', + 'memorySources.codingSessions.description': + 'Convierte tus decisiones y correcciones de Codex y Claude Code en memoria privada de personalidad.', + 'memorySources.codingSessions.ingest': 'Ingerir sesiones nuevas', + 'memorySources.codingSessions.ingesting': 'Ingiriendo…', + 'memorySources.codingSessions.claude': 'Historial de Claude Code', + 'memorySources.codingSessions.codex': 'Codex', + 'memorySources.codingSessions.counts': '{files} sesiones · {evidence} intervenciones humanas', + 'memorySources.codingSessions.notFound': 'No se encontró historial local', + 'memorySources.codingSessions.scanning': 'Buscando historial local de sesiones…', + 'memorySources.codingSessions.truncated': + 'El análisis se limitó a los primeros 1000 archivos de sesión.', + 'memorySources.codingSessions.complete': 'Sesiones de programación ingeridas', + 'memorySources.codingSessions.completeMessage': + '{processed} sesiones produjeron {observations} observaciones de personalidad.', + 'memorySources.codingSessions.partialFailure': + 'Fallaron {failed} sesiones mientras se procesaron {processed}. Ejecuta la ingesta de nuevo para reintentarlas.', + 'memorySources.codingSessions.moreRemaining': + 'Se alcanzó el límite de sesiones del lote. Ejecuta la ingesta de nuevo para seguir importando tu historial.', + 'memorySources.codingSessions.failed': 'Falló la ingesta de sesiones de programación', 'flows.canvas.sidePanelToggle': 'Panel lateral', 'flows.canvas.legendTab': 'Manual', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 4d34ed6dfa..e3a8e1d5d6 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -7455,6 +7455,26 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'Supprimer', 'flows.delete.deleting': 'Suppression…', 'flows.canvas.renameLabel': 'Renommer le workflow', + 'memorySources.codingSessions.title': 'Sessions d’agents de programmation', + 'memorySources.codingSessions.description': + 'Transformez vos décisions et corrections Codex et Claude Code en mémoire de persona privée.', + 'memorySources.codingSessions.ingest': 'Ingérer les nouvelles sessions', + 'memorySources.codingSessions.ingesting': 'Ingestion…', + 'memorySources.codingSessions.claude': 'Historique Claude Code', + 'memorySources.codingSessions.codex': 'Codex', + 'memorySources.codingSessions.counts': '{files} sessions · {evidence} interventions humaines', + 'memorySources.codingSessions.notFound': 'Aucun historique local trouvé', + 'memorySources.codingSessions.scanning': 'Analyse de l’historique local…', + 'memorySources.codingSessions.truncated': + 'L’analyse a été limitée aux 1 000 premiers fichiers de session.', + 'memorySources.codingSessions.complete': 'Sessions de programmation ingérées', + 'memorySources.codingSessions.completeMessage': + '{processed} sessions ont produit {observations} observations de persona.', + 'memorySources.codingSessions.partialFailure': + '{failed} sessions ont échoué tandis que {processed} ont été traitées. Relancez l’ingestion pour réessayer.', + 'memorySources.codingSessions.moreRemaining': + 'La limite de sessions du lot a été atteinte. Relancez l’ingestion pour continuer à importer votre historique.', + 'memorySources.codingSessions.failed': 'Échec de l’ingestion des sessions de programmation', 'flows.canvas.sidePanelToggle': 'Panneau latéral', 'flows.canvas.legendTab': 'Manuel', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index ce2250f929..73799ce912 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -7265,6 +7265,25 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'हटाएं', 'flows.delete.deleting': 'हटाया जा रहा है…', 'flows.canvas.renameLabel': 'वर्कफ़्लो का नाम बदलें', + 'memorySources.codingSessions.title': 'कोडिंग-एजेंट सत्र', + 'memorySources.codingSessions.description': + 'Codex और Claude Code के निर्णयों व सुधारों को निजी व्यक्तित्व स्मृति में बदलें।', + 'memorySources.codingSessions.ingest': 'नए सत्र शामिल करें', + 'memorySources.codingSessions.ingesting': 'शामिल किया जा रहा है…', + 'memorySources.codingSessions.claude': 'क्लॉड कोड', + 'memorySources.codingSessions.codex': 'Codex', + 'memorySources.codingSessions.counts': '{files} सत्र · {evidence} मानवीय संदेश', + 'memorySources.codingSessions.notFound': 'कोई स्थानीय इतिहास नहीं मिला', + 'memorySources.codingSessions.scanning': 'स्थानीय सत्र इतिहास स्कैन हो रहा है…', + 'memorySources.codingSessions.truncated': 'स्कैन पहले 1,000 सत्र फ़ाइलों तक सीमित था।', + 'memorySources.codingSessions.complete': 'कोडिंग सत्र शामिल हो गए', + 'memorySources.codingSessions.completeMessage': + '{processed} सत्रों से {observations} व्यक्तित्व अवलोकन बने।', + 'memorySources.codingSessions.partialFailure': + '{processed} सत्र संसाधित हुए, जबकि {failed} विफल रहे। दोबारा प्रयास करने के लिए अंतर्ग्रहण फिर चलाएँ।', + 'memorySources.codingSessions.moreRemaining': + 'सत्र बैच की सीमा पूरी हो गई है। अपना इतिहास आयात करना जारी रखने के लिए फिर से अंतर्ग्रहण चलाएँ।', + 'memorySources.codingSessions.failed': 'कोडिंग सत्र शामिल करना विफल रहा', 'flows.canvas.sidePanelToggle': 'साइड पैनल', 'flows.canvas.legendTab': 'मैनुअल', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 2c9eddecaf..e18630330a 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -7303,6 +7303,25 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'Hapus', 'flows.delete.deleting': 'Menghapus…', 'flows.canvas.renameLabel': 'Ganti nama alur kerja', + 'memorySources.codingSessions.title': 'Sesi agen pemrograman', + 'memorySources.codingSessions.description': + 'Ubah keputusan dan koreksi Codex serta Claude Code menjadi memori persona pribadi.', + 'memorySources.codingSessions.ingest': 'Serap sesi baru', + 'memorySources.codingSessions.ingesting': 'Menyerap…', + 'memorySources.codingSessions.claude': 'Riwayat Claude Code', + 'memorySources.codingSessions.codex': 'Codex', + 'memorySources.codingSessions.counts': '{files} sesi · {evidence} masukan manusia', + 'memorySources.codingSessions.notFound': 'Riwayat lokal tidak ditemukan', + 'memorySources.codingSessions.scanning': 'Memindai riwayat sesi lokal…', + 'memorySources.codingSessions.truncated': 'Pemindaian dibatasi pada 1.000 file sesi pertama.', + 'memorySources.codingSessions.complete': 'Sesi pemrograman telah diserap', + 'memorySources.codingSessions.completeMessage': + '{processed} sesi menghasilkan {observations} pengamatan persona.', + 'memorySources.codingSessions.partialFailure': + '{failed} sesi gagal sementara {processed} berhasil diproses. Jalankan penyerapan lagi untuk mencoba ulang.', + 'memorySources.codingSessions.moreRemaining': + 'Batas batch sesi tercapai. Jalankan penyerapan lagi untuk melanjutkan impor riwayat Anda.', + 'memorySources.codingSessions.failed': 'Gagal menyerap sesi pemrograman', 'flows.canvas.sidePanelToggle': 'Panel samping', 'flows.canvas.legendTab': 'Manual', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 6ca7a6820e..55ff7e2113 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -7408,6 +7408,27 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'Elimina', 'flows.delete.deleting': 'Eliminazione…', 'flows.canvas.renameLabel': 'Rinomina flusso di lavoro', + 'memorySources.codingSessions.title': 'Sessioni degli agenti di programmazione', + 'memorySources.codingSessions.description': + 'Trasforma decisioni e correzioni di Codex e Claude Code in memoria privata della persona.', + 'memorySources.codingSessions.ingest': 'Acquisisci nuove sessioni', + 'memorySources.codingSessions.ingesting': 'Acquisizione…', + 'memorySources.codingSessions.claude': 'Cronologia Claude Code', + 'memorySources.codingSessions.codex': 'Codex', + 'memorySources.codingSessions.counts': '{files} sessioni · {evidence} interventi umani', + 'memorySources.codingSessions.notFound': 'Nessuna cronologia locale trovata', + 'memorySources.codingSessions.scanning': 'Scansione della cronologia locale…', + 'memorySources.codingSessions.truncated': + 'La scansione è stata limitata ai primi 1.000 file di sessione.', + 'memorySources.codingSessions.complete': 'Sessioni di programmazione acquisite', + 'memorySources.codingSessions.completeMessage': + '{processed} sessioni hanno prodotto {observations} osservazioni della persona.', + 'memorySources.codingSessions.partialFailure': + '{failed} sessioni non sono riuscite mentre {processed} sono state elaborate. Avvia di nuovo l’acquisizione per riprovare.', + 'memorySources.codingSessions.moreRemaining': + 'È stato raggiunto il limite di sessioni del batch. Avvia di nuovo l’acquisizione per continuare a importare la cronologia.', + 'memorySources.codingSessions.failed': + 'Acquisizione delle sessioni di programmazione non riuscita', 'flows.canvas.sidePanelToggle': 'Pannello laterale', 'flows.canvas.legendTab': 'Manuale', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 0f484310e5..3ad8643e37 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -7180,6 +7180,25 @@ const messages: TranslationMap = { 'flows.delete.confirm': '삭제', 'flows.delete.deleting': '삭제 중…', 'flows.canvas.renameLabel': '워크플로 이름 바꾸기', + 'memorySources.codingSessions.title': '코딩 에이전트 세션', + 'memorySources.codingSessions.description': + 'Codex와 Claude Code의 결정 및 수정 사항을 비공개 페르소나 메모리로 변환합니다.', + 'memorySources.codingSessions.ingest': '새 세션 수집', + 'memorySources.codingSessions.ingesting': '수집 중…', + 'memorySources.codingSessions.claude': '클로드 코드', + 'memorySources.codingSessions.codex': 'Codex', + 'memorySources.codingSessions.counts': '세션 {files}개 · 사용자 입력 {evidence}개', + 'memorySources.codingSessions.notFound': '로컬 기록을 찾지 못했습니다', + 'memorySources.codingSessions.scanning': '로컬 세션 기록을 검색하는 중…', + 'memorySources.codingSessions.truncated': '스캔이 처음 1,000개 세션 파일로 제한되었습니다.', + 'memorySources.codingSessions.complete': '코딩 세션 수집 완료', + 'memorySources.codingSessions.completeMessage': + '세션 {processed}개에서 페르소나 관찰 {observations}개를 만들었습니다.', + 'memorySources.codingSessions.partialFailure': + '세션 {processed}개를 처리하는 동안 {failed}개가 실패했습니다. 다시 시도하려면 수집을 다시 실행하세요.', + 'memorySources.codingSessions.moreRemaining': + '세션 배치 한도에 도달했습니다. 기록 가져오기를 계속하려면 수집을 다시 실행하세요.', + 'memorySources.codingSessions.failed': '코딩 세션 수집 실패', 'flows.canvas.sidePanelToggle': '사이드 패널', 'flows.canvas.legendTab': '수동', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 71a6700c09..f67a8ffaa7 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -7378,6 +7378,26 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'Usuń', 'flows.delete.deleting': 'Usuwanie…', 'flows.canvas.renameLabel': 'Zmień nazwę przepływu pracy', + 'memorySources.codingSessions.title': 'Sesje agentów programistycznych', + 'memorySources.codingSessions.description': + 'Zamień decyzje i poprawki z Codex oraz Claude Code w prywatną pamięć persony.', + 'memorySources.codingSessions.ingest': 'Wczytaj nowe sesje', + 'memorySources.codingSessions.ingesting': 'Wczytywanie…', + 'memorySources.codingSessions.claude': 'Historia Claude Code', + 'memorySources.codingSessions.codex': 'Codex', + 'memorySources.codingSessions.counts': '{files} sesji · {evidence} wypowiedzi użytkownika', + 'memorySources.codingSessions.notFound': 'Nie znaleziono lokalnej historii', + 'memorySources.codingSessions.scanning': 'Skanowanie lokalnej historii sesji…', + 'memorySources.codingSessions.truncated': + 'Skanowanie ograniczono do pierwszych 1000 plików sesji.', + 'memorySources.codingSessions.complete': 'Sesje programistyczne wczytane', + 'memorySources.codingSessions.completeMessage': + '{processed} sesji utworzyło {observations} obserwacji persony.', + 'memorySources.codingSessions.partialFailure': + 'Nie udało się przetworzyć {failed} sesji, a {processed} przetworzono. Uruchom import ponownie, aby spróbować jeszcze raz.', + 'memorySources.codingSessions.moreRemaining': + 'Osiągnięto limit sesji w partii. Uruchom import ponownie, aby kontynuować wczytywanie historii.', + 'memorySources.codingSessions.failed': 'Nie udało się wczytać sesji programistycznych', 'flows.canvas.sidePanelToggle': 'Panel boczny', 'flows.canvas.legendTab': 'Ręczny', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index f88efaeb90..76ee0c2b12 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -7389,6 +7389,26 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'Excluir', 'flows.delete.deleting': 'Excluindo…', 'flows.canvas.renameLabel': 'Renomear fluxo de trabalho', + 'memorySources.codingSessions.title': 'Sessões de agentes de programação', + 'memorySources.codingSessions.description': + 'Transforme decisões e correções do Codex e Claude Code em memória privada de persona.', + 'memorySources.codingSessions.ingest': 'Ingerir novas sessões', + 'memorySources.codingSessions.ingesting': 'Ingerindo…', + 'memorySources.codingSessions.claude': 'Histórico do Claude Code', + 'memorySources.codingSessions.codex': 'Codex', + 'memorySources.codingSessions.counts': '{files} sessões · {evidence} mensagens humanas', + 'memorySources.codingSessions.notFound': 'Nenhum histórico local encontrado', + 'memorySources.codingSessions.scanning': 'Verificando o histórico local…', + 'memorySources.codingSessions.truncated': + 'A verificação foi limitada aos primeiros 1.000 arquivos de sessão.', + 'memorySources.codingSessions.complete': 'Sessões de programação ingeridas', + 'memorySources.codingSessions.completeMessage': + '{processed} sessões produziram {observations} observações de persona.', + 'memorySources.codingSessions.partialFailure': + '{failed} sessões falharam enquanto {processed} foram processadas. Execute a ingestão novamente para tentar de novo.', + 'memorySources.codingSessions.moreRemaining': + 'O limite de sessões do lote foi atingido. Execute a ingestão novamente para continuar importando seu histórico.', + 'memorySources.codingSessions.failed': 'Falha ao ingerir sessões de programação', 'flows.canvas.sidePanelToggle': 'Painel lateral', 'flows.canvas.legendTab': 'Manual', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 45ea259140..e3c1319a08 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -7350,6 +7350,25 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'Удалить', 'flows.delete.deleting': 'Удаление…', 'flows.canvas.renameLabel': 'Переименовать рабочий процесс', + 'memorySources.codingSessions.title': 'Сеансы агентов программирования', + 'memorySources.codingSessions.description': + 'Превратите решения и исправления из Codex и Claude Code в приватную память персоны.', + 'memorySources.codingSessions.ingest': 'Загрузить новые сеансы', + 'memorySources.codingSessions.ingesting': 'Загрузка…', + 'memorySources.codingSessions.claude': 'Клод Код', + 'memorySources.codingSessions.codex': 'Codex', + 'memorySources.codingSessions.counts': 'Сеансы: {files} · Сообщения пользователя: {evidence}', + 'memorySources.codingSessions.notFound': 'Локальная история не найдена', + 'memorySources.codingSessions.scanning': 'Сканирование локальной истории…', + 'memorySources.codingSessions.truncated': 'Сканирование ограничено первыми 1000 файлами сеансов.', + 'memorySources.codingSessions.complete': 'Сеансы программирования загружены', + 'memorySources.codingSessions.completeMessage': + 'Обработано сеансов: {processed}; наблюдений персоны: {observations}.', + 'memorySources.codingSessions.partialFailure': + 'Не удалось обработать сеансов: {failed}; обработано: {processed}. Запустите загрузку ещё раз для повтора.', + 'memorySources.codingSessions.moreRemaining': + 'Достигнут лимит сеансов в пакете. Запустите загрузку ещё раз, чтобы продолжить импорт истории.', + 'memorySources.codingSessions.failed': 'Не удалось загрузить сеансы программирования', 'flows.canvas.sidePanelToggle': 'Боковая панель', 'flows.canvas.legendTab': 'Вручную', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 517b1b8fc0..2a411462b3 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -6869,6 +6869,25 @@ const messages: TranslationMap = { 'flows.delete.confirm': '删除', 'flows.delete.deleting': '正在删除…', 'flows.canvas.renameLabel': '重命名工作流', + 'memorySources.codingSessions.title': '编程智能体会话', + 'memorySources.codingSessions.description': + '将 Codex 和 Claude Code 中的决策与纠正转化为私有人格记忆。', + 'memorySources.codingSessions.ingest': '摄取新会话', + 'memorySources.codingSessions.ingesting': '正在摄取…', + 'memorySources.codingSessions.claude': '克劳德代码', + 'memorySources.codingSessions.codex': 'Codex', + 'memorySources.codingSessions.counts': '{files} 个会话 · {evidence} 条证据', + 'memorySources.codingSessions.notFound': '未找到本地历史记录', + 'memorySources.codingSessions.scanning': '正在扫描本地会话历史…', + 'memorySources.codingSessions.truncated': '扫描仅限前 1,000 个会话文件。', + 'memorySources.codingSessions.complete': '编程会话已摄取', + 'memorySources.codingSessions.completeMessage': + '{processed} 个会话生成了 {observations} 条人格观察。', + 'memorySources.codingSessions.partialFailure': + '{processed} 个会话已处理,{failed} 个失败。请再次运行摄取以重试。', + 'memorySources.codingSessions.moreRemaining': + '已达到本批次的会话上限。请再次运行摄取以继续导入历史记录。', + 'memorySources.codingSessions.failed': '编程会话摄取失败', 'flows.canvas.sidePanelToggle': '侧边栏', 'flows.canvas.legendTab': '手动', diff --git a/app/src/pages/Brain.tsx b/app/src/pages/Brain.tsx index e803a1b849..291493ad5b 100644 --- a/app/src/pages/Brain.tsx +++ b/app/src/pages/Brain.tsx @@ -8,6 +8,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; +import { CodingSessionsCard } from '../components/intelligence/CodingSessionsCard'; import GoalsPanel from '../components/intelligence/GoalsPanel'; import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab'; import { MemoryControls } from '../components/intelligence/MemoryControls'; @@ -293,6 +294,7 @@ export default function Brain() { {activeTab === 'sources' && (
+
)} diff --git a/app/src/services/memorySourcesService.test.ts b/app/src/services/memorySourcesService.test.ts index ff45196442..ac2b3ca814 100644 --- a/app/src/services/memorySourcesService.test.ts +++ b/app/src/services/memorySourcesService.test.ts @@ -4,6 +4,8 @@ import { callCoreRpc } from './coreRpcClient'; import { addMemorySource, applyAllIn, + getCodingSessionStatus, + ingestCodingSessions, listMemorySources, type MemorySourceEntry, removeMemorySource, @@ -190,4 +192,47 @@ describe('memorySourcesService', () => { expect(entry.max_tokens_per_sync).toBe(100_000); expect(entry.max_cost_per_sync_usd).toBe(1.5); }); + + it('discovers Codex and Claude Code session sources', async () => { + mockedCall.mockResolvedValue({ + result: { + sources: [ + { kind: 'codex', available: true, session_files: 2, evidence_units: 5, invalid_files: 0 }, + ], + }, + logs: [], + } as never); + + const sources = await getCodingSessionStatus(); + + expect(mockedCall).toHaveBeenCalledWith({ + method: 'openhuman.memory_sources_coding_session_status', + }); + expect(sources[0]).toMatchObject({ kind: 'codex', evidence_units: 5 }); + }); + + it('requests a timeout-aligned incremental coding-session batch', async () => { + mockedCall.mockResolvedValue({ + result: { + mode: 'incremental', + files_seen: 2, + sessions_processed: 2, + sessions_skipped: 0, + sessions_failed: 0, + evidence_units: 5, + observations: 3, + budget_hit: false, + }, + logs: [], + } as never); + + const result = await ingestCodingSessions(false, 25); + + expect(mockedCall).toHaveBeenCalledWith({ + method: 'openhuman.memory_sources_ingest_coding_sessions', + params: { backfill: false, max_sessions: 15 }, + timeoutMs: 585_000, + }); + expect(result.sessions_processed).toBe(2); + }); }); diff --git a/app/src/services/memorySourcesService.ts b/app/src/services/memorySourcesService.ts index bcd646a5b7..82db7ac964 100644 --- a/app/src/services/memorySourcesService.ts +++ b/app/src/services/memorySourcesService.ts @@ -201,6 +201,78 @@ export async function applyAllIn(): Promise { return { sources: data.sources ?? [], sync_triggered: data.sync_triggered ?? 0 }; } +export interface CodingSessionSourceStatus { + kind: 'claude_code' | 'codex'; + available: boolean; + session_files: number; + evidence_units: number; + invalid_files: number; + scan_truncated?: boolean; +} + +export interface CodingSessionIngestResult { + mode: 'backfill' | 'incremental'; + files_seen: number; + sessions_processed: number; + sessions_skipped: number; + sessions_failed: number; + evidence_units: number; + observations: number; + budget_hit: boolean; + pack_path?: string | null; +} + +// Keep interactive imports below the core RPC client's bounded ten-minute +// ceiling. Larger histories are intentionally processed in repeatable batches; +// `budget_hit` tells the card to invite the user to continue. +const CODING_SESSION_BATCH_MAX = 15; +const CODING_SESSION_BASE_TIMEOUT_MS = 120_000; +const CODING_SESSION_PER_SESSION_TIMEOUT_MS = 30_000; +const CODING_SESSION_RPC_GRACE_MS = 15_000; + +export async function getCodingSessionStatus(): Promise { + log('coding_session_status: entry'); + const resp = await callCoreRpc<{ sources: CodingSessionSourceStatus[] }>({ + method: 'openhuman.memory_sources_coding_session_status', + }); + const data = unwrap<{ sources: CodingSessionSourceStatus[] }>(resp); + log('coding_session_status: exit sources=%d', data.sources?.length ?? 0); + return data.sources ?? []; +} + +export async function ingestCodingSessions( + backfill = false, + maxSessions = CODING_SESSION_BATCH_MAX +): Promise { + const boundedMaxSessions = Number.isFinite(maxSessions) + ? Math.min(Math.max(Math.trunc(maxSessions), 1), CODING_SESSION_BATCH_MAX) + : CODING_SESSION_BATCH_MAX; + const timeoutMs = + CODING_SESSION_BASE_TIMEOUT_MS + + boundedMaxSessions * CODING_SESSION_PER_SESSION_TIMEOUT_MS + + CODING_SESSION_RPC_GRACE_MS; + log( + 'ingest_coding_sessions: entry backfill=%s max_sessions=%d requested=%d timeout_ms=%d', + backfill, + boundedMaxSessions, + maxSessions, + timeoutMs + ); + const resp = await callCoreRpc({ + method: 'openhuman.memory_sources_ingest_coding_sessions', + params: { backfill, max_sessions: boundedMaxSessions }, + timeoutMs, + }); + const data = unwrap(resp); + log( + 'ingest_coding_sessions: exit processed=%d failed=%d budget_hit=%s', + data.sessions_processed, + data.sessions_failed, + data.budget_hit + ); + return data; +} + /// i18n keys for each source kind's user-visible label. Resolve via /// `t(SOURCE_KIND_LABEL_KEYS[kind])` in components — keeping the keys /// as a constant lets the dialog kind-picker render the same labels diff --git a/app/test/e2e/specs/coding-session-memory.spec.ts b/app/test/e2e/specs/coding-session-memory.spec.ts new file mode 100644 index 0000000000..365e04b653 --- /dev/null +++ b/app/test/e2e/specs/coding-session-memory.spec.ts @@ -0,0 +1,18 @@ +import { waitForApp } from '../helpers/app-helpers'; +import { navigateViaHash } from '../helpers/shared-flows'; + +describe('Coding-agent session memory', () => { + before(async () => { + await waitForApp(); + await navigateViaHash('/brain?tab=sources'); + }); + + it('surfaces Codex and Claude Code as private local memory sources', async () => { + const card = await $('[data-testid="coding-sessions-card"]'); + await card.waitForDisplayed({ timeout: 20_000 }); + expect(await card.getText()).toContain('Coding-agent sessions'); + await expect($('[data-testid="coding-session-source-claude_code"]')).toBeDisplayed(); + await expect($('[data-testid="coding-session-source-codex"]')).toBeDisplayed(); + await expect($('[data-testid="coding-sessions-ingest"]')).toBeDisplayed(); + }); +}); diff --git a/app/test/playwright/specs/coding-session-memory.spec.ts b/app/test/playwright/specs/coding-session-memory.spec.ts new file mode 100644 index 0000000000..88bf35d56f --- /dev/null +++ b/app/test/playwright/specs/coding-session-memory.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from '@playwright/test'; + +import { bootAuthenticatedPage, waitForAppReady } from '../helpers/core-rpc'; + +test.describe('Coding-agent session memory', () => { + test('shows Codex and Claude Code discovery on the Brain sources page', async ({ page }) => { + await bootAuthenticatedPage(page, 'pw-coding-session-memory', '/brain?tab=sources'); + await waitForAppReady(page); + + const card = page.getByTestId('coding-sessions-card'); + await expect(card).toBeVisible({ timeout: 20_000 }); + await expect(card).toContainText('Coding-agent sessions'); + await expect(page.getByTestId('coding-session-source-claude_code')).toBeVisible(); + await expect(page.getByTestId('coding-session-source-codex')).toBeVisible(); + await expect(page.getByTestId('coding-sessions-ingest')).toBeVisible(); + }); +}); diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index 0bc8f16b98..171ad792a9 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -187,7 +187,7 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil | 4.2.6 | Background-activity panel (chat-header Background tasks button) | VU+WD | `app/src/pages/conversations/hooks/useBackgroundActivity.test.ts`, `app/src/pages/conversations/components/__tests__/BackgroundActivityRows.test.tsx`, `app/test/e2e/specs/chat-background-activity-panel.spec.ts` | ✅ | View-only panel surfacing this chat's async sub-agents + global cron jobs, subconscious/heartbeat status, and memory syncing; freshness-only "Syncing now" labeling; E2E opens the panel and asserts its sections render and close | | 4.2.7 | Plan-mode review (Approve / Reject / Send-feedback before execute) | RU+RI+VU | `src/openhuman/plan_review/gate.rs`, `src/openhuman/plan_review/tool.rs`, `src/openhuman/plan_review/schemas.rs`, `tests/json_rpc_e2e.rs`, `app/src/pages/conversations/components/PlanReviewCard.test.tsx`, `app/src/pages/__tests__/Conversations.render.test.tsx` | ✅ | Interactive turns call `request_plan_review`, which parks the LIVE turn on the in-memory `PlanReviewGate` (oneshot) until the user decides; `plan_review_request` socket event drives `PlanReviewCard`, which resolves via `openhuman.plan_review_decide` (approve resumes-and-executes / reject resumes-and-stops / revise resumes-with-feedback). RU covers gate park/resolve/timeout + tool auto-approve + parking; RI covers the decide RPC; VU covers the card + provider wiring. WD E2E (agent-driven park flow) tracked as follow-up | -| 4.2.8 | Composer attachments (image / video / document; drag-drop + paste) | VU | `app/src/lib/attachments.test.ts`, `app/src/components/chat/__tests__/ChatComposer.test.tsx`, `app/src/pages/__tests__/Conversations.attachments.test.tsx` | 🟡 | Attach affordance gated on the resolved vision tier (images/video need vision; documents flow on any model); video is sampled into still frames client-side and forwarded through the existing `[IMAGE:]` vision path; drag-drop + clipboard-paste reuse the picker ingest. VU covers MIME/kind/limits/marker building + drag-drop + paste; real video decode and the frames→vision round-trip are manual-smoke only (jsdom has no video codec). WD E2E is a follow-up | +| 4.2.8 | Composer attachments (image / video / document; drag-drop + paste) | VU | `app/src/lib/attachments.test.ts`, `app/src/components/chat/__tests__/ChatComposer.test.tsx`, `app/src/pages/__tests__/Conversations.attachments.test.tsx` | 🟡 | Attach affordance gated on the resolved vision tier (images/video need vision; documents flow on any model); video is sampled into still frames client-side and forwarded through the existing `[IMAGE:]` vision path; drag-drop + clipboard-paste reuse the picker ingest. VU covers MIME/kind/limits/marker building + drag-drop + paste; real video decode and the frames→vision round-trip are manual-smoke only (jsdom has no video codec). WD E2E is a follow-up | ### 4.3 Tool Invocation @@ -280,27 +280,27 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an ### 6.3 Sub-agent Orchestration -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ----- | ---------------------------------------------------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 6.3.1 | Steer a running sub-agent | RU | `src/openhuman/agent_orchestration/running_subagents.rs`, `src/openhuman/agent_orchestration/tools/steer_subagent.rs` | ✅ | `steer_subagent` injects a steer/collect message into a running async sub-agent's run-queue; registry enforces parent ownership + terminal guard. | -| 6.3.2 | Wait for a sub-agent result | RU | `src/openhuman/agent_orchestration/running_subagents.rs`, `src/openhuman/agent_orchestration/tools/wait_subagent.rs` | ✅ | `wait_subagent` blocks on the completion `watch` with a timeout; prunes terminal entries, leaves entries intact on timeout. | -| 6.3.3 | Steer lands in child history | RU | `src/openhuman/agent/harness/subagent_runner/ops_tests.rs::run_queue_steer_lands_in_subagent_history` | ✅ | End-to-end: a queued steer is drained by the child `run_turn_engine` and appears as a `[User steering message]` user turn in the provider request. | -| 6.3.4 | Subconscious trigger pipeline (normalize → dedupe/rate → gate → queue) | RU+RI | `src/openhuman/subconscious_triggers/`, `tests/subconscious_triggers_e2e.rs` | ✅ | Event→Trigger normalization for cron/user/composio/sub-agent, dedupe TTL + per-source rate limit, LLM gate over `agent::triage`, priority queue with overflow eviction. | -| 6.3.5 | Long-lived subconscious orchestrator session | RU | `src/openhuman/subconscious/session.rs`, `src/openhuman/subconscious/user_thread.rs` | ✅ | Persistent compressed session backed by a reserved thread; `notify_user` handoff to the user-facing thread; mode→autonomy config parity. | -| 6.3.6 | Multi-party human↔subconscious↔sub-agent conversation | RI | `tests/subconscious_conversation_e2e.rs` | ✅ | Scripted Gate/SessionExecutor seam drives delegate→sub-agent→merge, failure/retry, interleaving, dedupe, and rate-limit scenarios through the real orchestrator. | -| 6.3.7 | Full-stack trigger pipeline with mocked LLM | RI | `tests/subconscious_fullstack_e2e.rs` (feature `e2e-test-support`) | ✅ | Real `GatePass`+`LongLivedSession`+`Agent`+sub-agent run against a provider-layer mock (no network); promote/drop, persistence, real `spawn_subagent`. | -| 6.3.8 | Subconscious Triggers debug/manage panel (Brain) | WD | `app/test/playwright/specs/subconscious-triggers.spec.ts` | ✅ | Brain→Subconscious panel: renders disabled baseline + hint + reserved thread ids; enable toggle → Pipeline Enabled + event_driven + orchestrator running; disable; refresh re-fetches. | -| 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. | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ------ | ---------------------------------------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 6.3.1 | Steer a running sub-agent | RU | `src/openhuman/agent_orchestration/running_subagents.rs`, `src/openhuman/agent_orchestration/tools/steer_subagent.rs` | ✅ | `steer_subagent` injects a steer/collect message into a running async sub-agent's run-queue; registry enforces parent ownership + terminal guard. | +| 6.3.2 | Wait for a sub-agent result | RU | `src/openhuman/agent_orchestration/running_subagents.rs`, `src/openhuman/agent_orchestration/tools/wait_subagent.rs` | ✅ | `wait_subagent` blocks on the completion `watch` with a timeout; prunes terminal entries, leaves entries intact on timeout. | +| 6.3.3 | Steer lands in child history | RU | `src/openhuman/agent/harness/subagent_runner/ops_tests.rs::run_queue_steer_lands_in_subagent_history` | ✅ | End-to-end: a queued steer is drained by the child `run_turn_engine` and appears as a `[User steering message]` user turn in the provider request. | +| 6.3.4 | Subconscious trigger pipeline (normalize → dedupe/rate → gate → queue) | RU+RI | `src/openhuman/subconscious_triggers/`, `tests/subconscious_triggers_e2e.rs` | ✅ | Event→Trigger normalization for cron/user/composio/sub-agent, dedupe TTL + per-source rate limit, LLM gate over `agent::triage`, priority queue with overflow eviction. | +| 6.3.5 | Long-lived subconscious orchestrator session | RU | `src/openhuman/subconscious/session.rs`, `src/openhuman/subconscious/user_thread.rs` | ✅ | Persistent compressed session backed by a reserved thread; `notify_user` handoff to the user-facing thread; mode→autonomy config parity. | +| 6.3.6 | Multi-party human↔subconscious↔sub-agent conversation | RI | `tests/subconscious_conversation_e2e.rs` | ✅ | Scripted Gate/SessionExecutor seam drives delegate→sub-agent→merge, failure/retry, interleaving, dedupe, and rate-limit scenarios through the real orchestrator. | +| 6.3.7 | Full-stack trigger pipeline with mocked LLM | RI | `tests/subconscious_fullstack_e2e.rs` (feature `e2e-test-support`) | ✅ | Real `GatePass`+`LongLivedSession`+`Agent`+sub-agent run against a provider-layer mock (no network); promote/drop, persistence, real `spawn_subagent`. | +| 6.3.8 | Subconscious Triggers debug/manage panel (Brain) | WD | `app/test/playwright/specs/subconscious-triggers.spec.ts` | ✅ | Brain→Subconscious panel: renders disabled baseline + hint + reserved thread ids; enable toggle → Pipeline Enabled + event_driven + orchestrator running; disable; refresh re-fetches. | +| 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.4 Managed Cloud File Storage -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ----- | ---------------------------------------------------------------- | ----- | ------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 6.4.1 | `storage_upload_file` (multipart, quota/TTL args, path safety) | RU | `src/openhuman/file_storage/tools_tests.rs` | ✅ | wiremock upload against the backend envelope; rejects workspace-escaping/symlinked paths, bad visibility/ttl; surfaces backend errors (e.g. insufficient balance). | -| 6.4.2 | `storage_download_file` (redirect follow + persist to workspace) | RU | `src/openhuman/file_storage/tools_tests.rs` | ✅ | Follows the 302 to the presigned URL, persists bytes under the action dir, honors explicit filename. | -| 6.4.3 | `storage_list_files` / `storage_get_link` | RU | `src/openhuman/file_storage/tools_tests.rs` | ✅ | List + usage rendering; presigned link generation with expiry arg validation. | -| 6.4.4 | `storage_set_visibility` / `storage_delete_file` | RU | `src/openhuman/file_storage/tools_tests.rs` | ✅ | Public/private toggle surfaces the stable public URL; delete confirms backend `deleted` flag; both are Write-level tools with external effect. | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ----- | ---------------------------------------------------------------- | ----- | ------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 6.4.1 | `storage_upload_file` (multipart, quota/TTL args, path safety) | RU | `src/openhuman/file_storage/tools_tests.rs` | ✅ | wiremock upload against the backend envelope; rejects workspace-escaping/symlinked paths, bad visibility/ttl; surfaces backend errors (e.g. insufficient balance). | +| 6.4.2 | `storage_download_file` (redirect follow + persist to workspace) | RU | `src/openhuman/file_storage/tools_tests.rs` | ✅ | Follows the 302 to the presigned URL, persists bytes under the action dir, honors explicit filename. | +| 6.4.3 | `storage_list_files` / `storage_get_link` | RU | `src/openhuman/file_storage/tools_tests.rs` | ✅ | List + usage rendering; presigned link generation with expiry arg validation. | +| 6.4.4 | `storage_set_visibility` / `storage_delete_file` | RU | `src/openhuman/file_storage/tools_tests.rs` | ✅ | Public/private toggle surfaces the stable public URL; delete confirms backend `deleted` flag; both are Write-level tools with external effect. | --- @@ -335,12 +335,13 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an ### 8.2 Memory Handling -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ----- | -------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------- | -| 8.2.1 | Context Injection | RI | `tests/autocomplete_memory_e2e.rs` | ✅ | | -| 8.2.2 | Memory Consistency | RI | `tests/memory_graph_sync_e2e.rs`, `tests/worker_c_modules_e2e.rs` | ✅ | Worker C RPC E2E verifies memory-tree ingest is reflected by `memory_sync_status_list` | -| 8.2.3 | Memory Scaling | RU | `src/openhuman/memory/ingestion_tests.rs` | 🟡 | Soak/scale benchmark not asserted | -| 8.2.4 | Raw-archive sync reconcile | RU+RI | `src/openhuman/memory_sync/sources/rebuild.rs`, `src/openhuman/memory_sync/workspace/periodic.rs`, `tests/json_rpc_e2e.rs` (`json_rpc_memory_sources_reconcile_reports_pending_raw_files`), `tests/memory_sync_pipeline_e2e.rs` | ✅ | Coverage gate + incremental rebuild + workspace periodic scheduler + `memory_sources_reconcile` RPC | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ----- | -------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 8.2.1 | Context Injection | RI | `tests/autocomplete_memory_e2e.rs` | ✅ | | +| 8.2.2 | Memory Consistency | RI | `tests/memory_graph_sync_e2e.rs`, `tests/worker_c_modules_e2e.rs` | ✅ | Worker C RPC E2E verifies memory-tree ingest is reflected by `memory_sync_status_list` | +| 8.2.3 | Memory Scaling | RU | `src/openhuman/memory/ingestion_tests.rs` | 🟡 | Soak/scale benchmark not asserted | +| 8.2.4 | Raw-archive sync reconcile | RU+RI | `src/openhuman/memory_sync/sources/rebuild.rs`, `src/openhuman/memory_sync/workspace/periodic.rs`, `tests/json_rpc_e2e.rs` (`json_rpc_memory_sources_reconcile_reports_pending_raw_files`), `tests/memory_sync_pipeline_e2e.rs` | ✅ | Coverage gate + incremental rebuild + workspace periodic scheduler + `memory_sources_reconcile` RPC | +| 8.2.5 | Coding-session persona ingestion | RU+RI+VU+WD | `src/openhuman/tinycortex/persona.rs`, `tests/coding_sessions_feature.rs`, `tests/json_rpc_e2e.rs`, `app/src/components/intelligence/__tests__/CodingSessionsCard.test.tsx`, `app/src/services/memorySourcesService.test.ts`, `app/test/e2e/specs/coding-session-memory.spec.ts`, `app/test/playwright/specs/coding-session-memory.spec.ts` | ✅ | Discovers Codex and Claude Code histories, excludes machine-authored turns, exposes status/ingest RPCs, and surfaces incremental ingestion on Brain > Sources | ### 8.3 Memory Retrieval Benchmarks @@ -409,13 +410,13 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an ### 10.1 Integration Setup -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ------ | ------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 10.1.1 | Telegram Connection | WD | `telegram-flow.spec.ts` | ✅ | | -| 10.1.2 | WhatsApp Connection | WD | `app/test/e2e/specs/whatsapp-flow.spec.ts` | ✅ | Was ❌ | -| 10.1.3 | Gmail Connection | WD | `gmail-flow.spec.ts` | ✅ | | -| 10.1.4 | Slack Connection | WD | `app/test/e2e/specs/slack-flow.spec.ts` | ✅ | Was ❌ | -| 10.1.5 | Yuanbao Connection | RU | `src/openhuman/channels/providers/yuanbao/`, `src/openhuman/channels/controllers/ops.rs::tests::connect_yuanbao_*`, `src/openhuman/channels/runtime/startup.rs::yuanbao_secret_tests` | 🟡 | New API-key channel for Tencent Yuanbao. RU covers sign-token preflight (valid/invalid creds, env-override cluster routing), credentials store hydration (incl. stale app_key guard), and WS reconnect/shutdown. No WDIO spec yet — connect-flow UI is rendered via the generic `ChannelSetupModal` already exercised by other channel flow specs. | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ------ | ---------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 10.1.1 | Telegram Connection | WD | `telegram-flow.spec.ts` | ✅ | | +| 10.1.2 | WhatsApp Connection | WD | `app/test/e2e/specs/whatsapp-flow.spec.ts` | ✅ | Was ❌ | +| 10.1.3 | Gmail Connection | WD | `gmail-flow.spec.ts` | ✅ | | +| 10.1.4 | Slack Connection | WD | `app/test/e2e/specs/slack-flow.spec.ts` | ✅ | Was ❌ | +| 10.1.5 | Yuanbao Connection | RU | `src/openhuman/channels/providers/yuanbao/`, `src/openhuman/channels/controllers/ops.rs::tests::connect_yuanbao_*`, `src/openhuman/channels/runtime/startup.rs::yuanbao_secret_tests` | 🟡 | New API-key channel for Tencent Yuanbao. RU covers sign-token preflight (valid/invalid creds, env-override cluster routing), credentials store hydration (incl. stale app_key guard), and WS reconnect/shutdown. No WDIO spec yet — connect-flow UI is rendered via the generic `ChannelSetupModal` already exercised by other channel flow specs. | | 10.1.6 | Email (IMAP/SMTP) Connection | RU+VU | `src/openhuman/channels/controllers/definitions_tests.rs::email_*`, `src/openhuman/channels/controllers/ops/connect.rs::email_config_tests`, `src/openhuman/channels/controllers/ops_tests.rs::{persist_email_config_*,disconnect_email_*,connect_email_rejects_invalid_port_*,test_channel_email_rejects_invalid_port_*}`, `app/src/components/channels/CredentialChannelConfig.test.tsx`, `app/src/components/channels/ChannelConfigPanel.test.tsx` | 🟡 | #4280 — native IMAP/SMTP for non-Gmail/Outlook mailboxes surfacing the existing `EmailChannel`. RU covers credentials→`EmailConfig` mapping/defaults, port/sender parsing, definition/validation, config persist + disconnect, and pre-network invalid-port rejection. VU covers the connect form rendering/submit + panel routing. Live IMAP verify + WDIO connect-flow are follow-ups. | ### 10.2 Authentication & Authorization @@ -428,12 +429,12 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an ### 10.3 Message Sync & Ingestion -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ------ | ------------------------- | ----- | ------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| 10.3.1 | Incoming Message Sync | RU+WD | `src/openhuman/channels/tests/`, `gmail-flow.spec.ts` | ✅ | | -| 10.3.2 | Message Deduplication | RU | `src/openhuman/channels/tests/` | ✅ | | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ------ | ------------------------- | ----- | ------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 10.3.1 | Incoming Message Sync | RU+WD | `src/openhuman/channels/tests/`, `gmail-flow.spec.ts` | ✅ | | +| 10.3.2 | Message Deduplication | RU | `src/openhuman/channels/tests/` | ✅ | | | 10.3.3 | WhatsApp Agent Retrieval | RU | `src/openhuman/whatsapp_data/tools/`, `tests/json_rpc_e2e.rs::whatsapp_data_agent_tools_e2e_1341` | ✅ | Three read-only agent tools wrap the local SQLite store; ingest stays internal-only. See [`src/openhuman/whatsapp_data/README.md`](../src/openhuman/whatsapp_data/README.md). | -| 10.3.4 | Real-Time vs Delayed Sync | RU | `src/openhuman/channels/tests/runtime_dispatch.rs` | ✅ | | +| 10.3.4 | Real-Time vs Delayed Sync | RU | `src/openhuman/channels/tests/runtime_dispatch.rs` | ✅ | | ### 10.4 Messaging Operations @@ -489,7 +490,7 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an | 11.1.11 | MCP env reconfigure + registry creds | RI/VU | `tests/json_rpc_e2e.rs` (`mcp_clients_registry_settings_roundtrip`), `src/openhuman/mcp_registry/registries/mcp_official.rs`, `app/src/components/channels/mcp/InstalledServerDetail.test.tsx` (#3039) | ✅ | `update_env` persist+reconnect; `registry_settings` get/set with secrets write-only (config-first, env-fallback); reconfigure form validation | | 11.1.12 | MCP UI surface + setup-agent client | VU/RU | `app/src/components/channels/mcp/InstallDialog.test.tsx`, `app/src/components/channels/mcp/McpServersTab.test.tsx`, `app/src/services/api/mcpClientsApi.test.ts`, `app/src/services/api/mcpSetupApi.test.ts`, `src/openhuman/mcp_registry/{curation,registry,registries/mcp_official}.rs` (#3039, #4272) | ✅ | Skills `?tab=mcp` renders `McpServersTab` (not Coming Soon); auto-connect on install (best-effort); typed `mcpSetupApi` wrapper; curated "perfect server" catalog (declared website + named credential) with official-vendor badge + official-first order; namespace-stripped relevance search + server-side Stdio/Hosted transport filter; clickable Website/Repo links; wired connection health toolbar (Retry all / Disconnect all) (#4272) | | 11.1.13 | MCP HTTP-remote auth (token / Bearer / OAuth) + redirect resolution | RU/VU | `src/openhuman/mcp_registry/connections.rs` (`build_http_auth*`, `resolve_final_url`), `src/openhuman/mcp_registry/oauth.rs` (PKCE/token/bundle/callback port), `app/src/components/channels/mcp/ConnectAuthModal.test.tsx` (#3495) | ✅ | Bearer/raw scheme + custom headers; redirect-final-URL resolved before auth; OAuth dynamic client registration + PKCE + refresh; tokens MERGED into stored env; credentials stored encrypted locally, never sent to backend | -| 11.1.14 | MCP "Help & configure" assistant | VU/RU | `app/src/components/channels/mcp/ConfigAssistantPanel.test.tsx`, `app/src/components/channels/mcp/ConfigHelpModal.test.tsx`, `src/openhuman/mcp_registry/ops.rs` (`invoke_config_assist_agent`) (#3495) | ✅ | Server-specific prompt offered as a one-click "Get step-by-step setup help" action (on-demand, no longer auto-run on open — #4272), running an agentic turn scoped to web_search_tool/web_fetch/curl only; markdown-rendered reply; per-MCP chat persisted while on the detail page | +| 11.1.14 | MCP "Help & configure" assistant | VU/RU | `app/src/components/channels/mcp/ConfigAssistantPanel.test.tsx`, `app/src/components/channels/mcp/ConfigHelpModal.test.tsx`, `src/openhuman/mcp_registry/ops.rs` (`invoke_config_assist_agent`) (#3495) | ✅ | Server-specific prompt offered as a one-click "Get step-by-step setup help" action (on-demand, no longer auto-run on open — #4272), running an agentic turn scoped to web_search_tool/web_fetch/curl only; markdown-rendered reply; per-MCP chat persisted while on the detail page | | 11.1.15 | Agent uses connected MCP servers in chat | RU | `src/openhuman/agent_registry/agents/loader.rs` (`orchestrator_subagents_include_mcp_agent`, `mcp_agent_drives_connected_servers_without_install_or_shell`, `planner_has_readonly_mcp_discovery_not_execute`), `src/openhuman/agent_registry/agents/orchestrator/prompt.rs` (`connected_mcp_block_*`), `src/openhuman/agent/harness/session/turn_tests.rs` (`mcp_announcement_fires_once_for_new_server`), `src/openhuman/mcp_registry/{tools,connections}.rs` (#3495) | ✅ | `use_mcp_server` delegate → `mcp_agent` worker (discover→list→call); `mcp_registry_list_tools` read-only discovery; orchestrator `## Connected MCP Servers` prompt block + mid-session connect announcement on the user turn; planner read-only MCP discovery (no `tool_call`) | @@ -504,10 +505,10 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an ### 11.3 Hosted Orchestration -| ID | Feature | Layer | Test path(s) | Status | Notes | -| ------ | ---------------------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ------ | --------------------------------------------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | 11.3.1 | Hosted-only orchestration (client = trigger + effects + render) | RI+VU | `tests/orchestration_hosted_client.rs`, `app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx`, `app/src/components/orchestration/__tests__/AgentChatPanel.test.tsx` | ✅ | Local wake-graph brain retired (frontend_agent/graph/master_agent/reasoning_agent deleted). Client forwards events to the hosted brain (`POST /orchestration/v1/events`), uploads world-diffs, syncs hosted sessions/messages/steering into the render cache, and executes `send_dm`/`evict` socket effects; cloud-unreachable banner on outage. | -| 11.3.2 | Direct paid Medulla orchestration with local OpenHuman tools | RU | `src/openhuman/orchestration/medulla.rs`, `src/openhuman/orchestration/schemas.rs` | ✅ | `openhuman.orchestration_run` checks the active paid plan, starts a hosted Medulla cycle, executes requested contact/session/send tools locally, and continues pending/tool-use events to a final result. Mocked HTTP tests cover direct and tool-loop success plus plan, pending, backend-error, unknown-tool, and tool-failure paths. | +| 11.3.2 | Direct paid Medulla orchestration with local OpenHuman tools | RU | `src/openhuman/orchestration/medulla.rs`, `src/openhuman/orchestration/schemas.rs` | ✅ | `openhuman.orchestration_run` checks the active paid plan, starts a hosted Medulla cycle, executes requested contact/session/send tools locally, and continues pending/tool-use events to a final result. Mocked HTTP tests cover direct and tool-loop success plus plan, pending, backend-error, unknown-tool, and tool-failure paths. | --- diff --git a/src/openhuman/about_app/catalog_data.rs b/src/openhuman/about_app/catalog_data.rs index a5ce31534d..fb9bad1b24 100644 --- a/src/openhuman/about_app/catalog_data.rs +++ b/src/openhuman/about_app/catalog_data.rs @@ -14,6 +14,12 @@ const DERIVED_TO_BACKEND: Option = Some(CapabilityPrivacy { destinations: &["OpenHuman backend", "TinyHumans Neocortex"], }); +const CODING_SESSION_TO_BACKEND: 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 { @@ -507,6 +513,16 @@ pub(super) const CAPABILITIES: &[Capability] = &[ status: CapabilityStatus::Beta, privacy: LOCAL_RAW, }, + Capability { + id: "intelligence.coding_session_memory", + name: "Coding-Agent Session Memory", + domain: "memory_sources", + category: CapabilityCategory::Intelligence, + description: "Discover local Codex and Claude Code session histories, retain only human-authored decisions and corrections, and distill them into a durable TinyCortex persona memory pack. Tool output, reasoning, developer prompts, and subagent traffic are excluded before inference.", + how_to: "Brain > Sources > Coding-agent sessions > Ingest new sessions. Programmatic: openhuman.memory_sources_coding_session_status and openhuman.memory_sources_ingest_coding_sessions (RPC).", + status: CapabilityStatus::Beta, + privacy: CODING_SESSION_TO_BACKEND, + }, Capability { id: "intelligence.memory_sync_schedule", name: "Memory Sync Schedule", diff --git a/src/openhuman/about_app/catalog_tests.rs b/src/openhuman/about_app/catalog_tests.rs index 8618cf65a4..ca5fcb4246 100644 --- a/src/openhuman/about_app/catalog_tests.rs +++ b/src/openhuman/about_app/catalog_tests.rs @@ -158,6 +158,7 @@ fn catalog_includes_additional_user_facing_surfaces() { "intelligence.embedding_provider_test", "intelligence.github_repo_memory_source", "intelligence.memory_source_sync_controls", + "intelligence.coding_session_memory", "conversation.subagent_mascots", ] { assert!( @@ -167,6 +168,22 @@ fn catalog_includes_additional_user_facing_surfaces() { } } +#[test] +fn coding_session_memory_discloses_inference_boundary() { + let capability = lookup("intelligence.coding_session_memory") + .expect("coding-session memory capability registered"); + assert_eq!(capability.domain, "memory_sources"); + assert!(capability.description.contains("Codex")); + assert!(capability.description.contains("Claude Code")); + let privacy = capability.privacy.expect("privacy disclosure"); + assert!(privacy.leaves_device); + assert_eq!(privacy.data_kind, PrivacyDataKind::Raw); + assert_eq!( + privacy.destinations, + &["Configured OpenHuman inference provider"] + ); +} + /// The two embeddings entries surface a Settings-side configuration panel. /// They share the same domain (`embeddings`) but are listed under the /// Intelligence umbrella so they sit next to memory_tree_retrieval / mcp_server diff --git a/src/openhuman/agent/harness/archivist/recap.rs b/src/openhuman/agent/harness/archivist/recap.rs index ab7cf2d5bc..ce670be3bb 100644 --- a/src/openhuman/agent/harness/archivist/recap.rs +++ b/src/openhuman/agent/harness/archivist/recap.rs @@ -118,6 +118,9 @@ impl ArchivistHook { tree_kind: TreeKind::Source, target_level: 0, token_budget: 2_000, + input_token_budget: tinycortex::memory::config::INPUT_TOKEN_BUDGET, + overhead_reserve_tokens: tinycortex::memory::config::SUMMARY_OVERHEAD_RESERVE_TOKENS, + ask: None, }; let first = entries.first().map(|e| e.content.as_str()).unwrap_or(""); diff --git a/src/openhuman/agent/harness/session/turn/session_io.rs b/src/openhuman/agent/harness/session/turn/session_io.rs index e98911a9a2..195d7ccc86 100644 --- a/src/openhuman/agent/harness/session/turn/session_io.rs +++ b/src/openhuman/agent/harness/session/turn/session_io.rs @@ -131,13 +131,11 @@ impl Agent { }; let mut streamed_text = String::new(); - let mut streamed_deltas = Vec::new(); let mut completed = None; while let Some(item) = stream.next().await { match item { ModelStreamItem::MessageDelta(delta) if !delta.text.is_empty() => { streamed_text.push_str(&delta.text); - streamed_deltas.push(delta.text); } ModelStreamItem::Completed(response) => completed = Some(response), ModelStreamItem::Failed(error) => { @@ -157,52 +155,84 @@ impl Agent { }; let usage = crate::openhuman::tinyagents::model::usage_info_from_response(&response); let text = response.text(); - let checkpoint = if !text.trim().is_empty() { - text - } else if response.tool_calls().is_empty() { - streamed_text.clone() + // Tools are disabled for wrap-up calls, but text-protocol models can + // still ignore that instruction. Parse through the active dispatcher + // so XML/JSON and registry-backed P-Format calls are all rejected. The + // completed response and buffered deltas are checked independently: + // some providers only preserve one of those representations. + let parsed_call_count = |candidate: &str| { + self.tool_dispatcher + .parse_response(&ChatResponse { + text: Some(candidate.to_string()), + ..ChatResponse::default() + }) + .1 + .len() + }; + let parsed_response_calls = parsed_call_count(&text); + let parsed_stream_calls = if streamed_text == text { + parsed_response_calls } else { - String::new() + parsed_call_count(&streamed_text) }; - if !checkpoint.trim().is_empty() { - let mut provider_response = ChatResponse { - text: Some(checkpoint.clone()), - tool_calls: Vec::new(), - usage: None, - reasoning_content: None, - }; - let (_, mut prompt_tool_calls) = - self.tool_dispatcher.parse_response(&provider_response); - if streamed_text != checkpoint { - provider_response.text = Some(streamed_text); - let (_, streamed_tool_calls) = - self.tool_dispatcher.parse_response(&provider_response); - prompt_tool_calls.extend(streamed_tool_calls); - } - if !prompt_tool_calls.is_empty() { - tracing::warn!( - parsed_tool_calls = prompt_tool_calls.len(), - "[agent::session] wrap-up model returned a prompt-formatted tool call; using deterministic fallback" - ); - return (String::new(), usage); - } + let native_tool_calls = response.tool_calls().len(); + let attempted_tool_call = + native_tool_calls > 0 || parsed_response_calls > 0 || parsed_stream_calls > 0; + let checkpoint = if attempted_tool_call { + tracing::warn!( + model = effective_model, + iteration = iteration_for_stream, + native_tool_calls, + parsed_response_calls, + parsed_stream_calls, + "[agent::session] wrap-up attempted a tool call; using deterministic fallback" + ); + String::new() + } else if !text.trim().is_empty() { tracing::debug!( - checkpoint_chars = checkpoint.chars().count(), - buffered_deltas = streamed_deltas.len(), + model = effective_model, iteration = iteration_for_stream, - "[agent::session] wrap-up checkpoint validation passed" + text_len = text.len(), + "[agent::session] wrap-up selected completed response text" ); + text + } else { + tracing::debug!( + model = effective_model, + iteration = iteration_for_stream, + text_len = streamed_text.len(), + "[agent::session] wrap-up selected buffered stream text" + ); + streamed_text + }; + // Hold wrap-up deltas until protocol validation completes. Otherwise a + // rejected XML/P-Format tool call briefly renders in chat even though + // the caller subsequently replaces it with a deterministic fallback. + if !checkpoint.is_empty() { if let Some(sink) = &self.on_progress { - for delta in streamed_deltas { - let _ = sink - .send(AgentProgress::TextDelta { - delta, - iteration: iteration_for_stream, - }) - .await; + if let Err(error) = sink + .send(AgentProgress::TextDelta { + delta: checkpoint.clone(), + iteration: iteration_for_stream, + }) + .await + { + tracing::debug!( + model = effective_model, + iteration = iteration_for_stream, + error = %error, + "[agent::session] wrap-up progress sink closed" + ); } } } + tracing::debug!( + model = effective_model, + iteration = iteration_for_stream, + checkpoint_len = checkpoint.len(), + used_deterministic_fallback = attempted_tool_call, + "[agent::session] wrap-up checkpoint selection complete" + ); (checkpoint, usage) } diff --git a/src/openhuman/agent/harness/session/turn_tests.rs b/src/openhuman/agent/harness/session/turn_tests.rs index c837f21c98..5323af2cb9 100644 --- a/src/openhuman/agent/harness/session/turn_tests.rs +++ b/src/openhuman/agent/harness/session/turn_tests.rs @@ -1,5 +1,7 @@ use super::*; -use crate::openhuman::agent::dispatcher::XmlToolDispatcher; +use crate::openhuman::agent::dispatcher::{ + PFormatToolDispatcher, ToolDispatcher, XmlToolDispatcher, +}; use crate::openhuman::agent::hooks::{PostTurnHook, TurnContext}; use crate::openhuman::agent::tool_policy::{ GeneratedToolRuntimeContext, GeneratedToolRuntimeRisk, ToolPolicy, ToolPolicyDecision, @@ -340,6 +342,26 @@ fn make_agent_with_builder( post_turn_hooks: Vec>, config: crate::openhuman::config::AgentConfig, context_config: crate::openhuman::config::ContextConfig, +) -> Agent { + make_agent_with_builder_and_dispatcher( + provider, + tools, + memory_loader, + post_turn_hooks, + config, + context_config, + Box::new(XmlToolDispatcher), + ) +} + +fn make_agent_with_builder_and_dispatcher( + provider: Arc, + tools: Vec>, + memory_loader: Box, + post_turn_hooks: Vec>, + config: crate::openhuman::config::AgentConfig, + context_config: crate::openhuman::config::ContextConfig, + tool_dispatcher: Box, ) -> Agent { let workspace = tempfile::TempDir::new().expect("temp workspace"); let workspace_path = workspace.path().to_path_buf(); @@ -357,7 +379,7 @@ fn make_agent_with_builder( .tools(tools) .memory(mem) .memory_loader(memory_loader) - .tool_dispatcher(Box::new(XmlToolDispatcher)) + .tool_dispatcher(tool_dispatcher) .post_turn_hooks(post_turn_hooks) .config(config) .context_config(context_config) @@ -1036,6 +1058,63 @@ async fn turn_checkpoint_falls_back_to_deterministic_summary_when_model_summary_ ); } +#[tokio::test] +async fn turn_checkpoint_rejects_pformat_wrapup_without_streaming_it() { + let provider: Arc = Arc::new(SequenceProvider { + responses: AsyncMutex::new(vec![ + Ok(ChatResponse { + text: Some("{\"name\":\"echo\",\"arguments\":{}}".into()), + ..ChatResponse::default() + }), + Ok(ChatResponse { + text: Some("echo[]".into()), + ..ChatResponse::default() + }), + ]), + requests: AsyncMutex::new(Vec::new()), + }); + let tools: Vec> = vec![Box::new(EchoTool)]; + let registry = crate::openhuman::agent::pformat::build_registry(&tools); + let mut agent = make_agent_with_builder_and_dispatcher( + provider, + tools, + Box::new(FixedMemoryLoader { + context: String::new(), + }), + vec![], + crate::openhuman::config::AgentConfig { + max_tool_iterations: 1, + ..crate::openhuman::config::AgentConfig::default() + }, + crate::openhuman::config::ContextConfig::default(), + Box::new(PFormatToolDispatcher::new(registry)), + ); + let (progress_tx, mut progress_rx) = tokio::sync::mpsc::channel(16); + agent.set_on_progress(Some(progress_tx)); + + let reply = agent + .turn("hello") + .await + .expect("P-Format wrap-up call should use the deterministic checkpoint"); + assert!( + reply.contains("tool-call limit"), + "P-Format wrap-up must be rejected, got: {reply}" + ); + + agent.set_on_progress(None); + let mut rendered_invalid_wrapup = false; + while let Ok(progress) = progress_rx.try_recv() { + if let crate::openhuman::agent::progress::AgentProgress::TextDelta { delta, .. } = progress + { + rendered_invalid_wrapup |= delta.contains("echo[]"); + } + } + assert!( + !rendered_invalid_wrapup, + "rejected P-Format wrap-up must not be emitted to the progress sink" + ); +} + #[tokio::test] async fn turn_synthesizes_final_answer_when_tool_turn_yields_no_text() { // #4093: the model runs a tool and then yields a terminating response with diff --git a/src/openhuman/composio/ops_tests.rs b/src/openhuman/composio/ops_tests.rs index a2ce40467d..be050c62b8 100644 --- a/src/openhuman/composio/ops_tests.rs +++ b/src/openhuman/composio/ops_tests.rs @@ -1017,6 +1017,10 @@ async fn composio_get_user_profile_via_mock_returns_provider_profile() { }), ); let base = start_mock_backend(app).await; + // ProviderContext reloads the saved config and applies runtime env + // overlays. Pin the backend override to the mock so CI's BACKEND_URL + // cannot redirect this request to the hosted API. + let _backend_url_guard = EnvVarGuard::set("BACKEND_URL", &base); let tmp = tempfile::tempdir().unwrap(); let config = config_with_backend(&tmp, base); let _workspace_env_guard = WorkspaceEnvGuard::set(tmp.path()); @@ -1168,6 +1172,9 @@ async fn composio_sync_gmail_via_mock_stores_skill_document_and_updates_outcome( }), ); let base = start_mock_backend(app).await; + // The provider action reloads config with env overlays before executing. + // Keep that reload on the mock even when the runner exports BACKEND_URL. + let _backend_url_guard = EnvVarGuard::set("BACKEND_URL", &base); let tmp = tempfile::tempdir().unwrap(); let mut config = config_with_backend(&tmp, base); config.memory_tree.embedding_strict = false; diff --git a/src/openhuman/composio/tools_tests.rs b/src/openhuman/composio/tools_tests.rs index 5a8849f03c..ec9779480f 100644 --- a/src/openhuman/composio/tools_tests.rs +++ b/src/openhuman/composio/tools_tests.rs @@ -881,10 +881,12 @@ async fn list_tools_in_direct_mode_returns_empty_without_hitting_backend() { let tmp = tempfile::tempdir().expect("tempdir"); let _workspace_guard = WorkspaceEnvGuard::set(tmp.path()); + let _home_guard = HomeEnvGuard::set(tmp.path()); let mut config = crate::openhuman::config::Config::default(); config.config_path = tmp.path().join("config.toml"); config.workspace_dir = tmp.path().join("workspace"); + std::fs::create_dir_all(&config.workspace_dir).expect("create workspace dir"); config.composio.mode = crate::openhuman::config::schema::COMPOSIO_MODE_DIRECT.to_string(); config.composio.api_key = Some("test-direct-key".to_string()); config.save().await.expect("save fake config to disk"); diff --git a/src/openhuman/flows/ops_tests.rs b/src/openhuman/flows/ops_tests.rs index 448b35fb12..319445e1da 100644 --- a/src/openhuman/flows/ops_tests.rs +++ b/src/openhuman/flows/ops_tests.rs @@ -2851,7 +2851,7 @@ fn seeded_slack_send_message_contract_with_schema() -> ToolContract { output_fields: vec![], output_schema: None, primary_array_path: None, - is_curated: true, + is_curated: false, } } diff --git a/src/openhuman/memory/tools/store.rs b/src/openhuman/memory/tools/store.rs index deda5b58fd..afe784563b 100644 --- a/src/openhuman/memory/tools/store.rs +++ b/src/openhuman/memory/tools/store.rs @@ -80,7 +80,16 @@ impl Tool for MemoryStoreTool { Some("core") | None => MemoryCategory::Core, Some("daily") => MemoryCategory::Daily, Some("conversation") => MemoryCategory::Conversation, - Some(other) => MemoryCategory::Custom(other.to_string()), + // Route custom categories through `FromStr` so a `custom:` + // wire value — the form `memory_recall`/`Display` now emit — resolves + // back to `Custom("")` instead of `Custom("custom:")` + // (which would `Display` as `custom:custom:` and stop matching + // the original category on recall/filter). Legacy bare names still + // parse to the same `Custom(name)`; an unparseable value falls back + // to the raw string. (review: prefixed-custom round-trip) + Some(other) => other + .parse() + .unwrap_or_else(|_| MemoryCategory::Custom(other.to_string())), }; if let Err(error) = self @@ -204,6 +213,33 @@ mod tests { assert_eq!(entry.category, MemoryCategory::Custom("project".into())); } + /// Regression: a `custom:` wire value (the form `memory_recall` and + /// `Display` now emit) must store as `Custom("")`, not the + /// double-prefixed `Custom("custom:")` — otherwise it would `Display` + /// as `custom:custom:` and stop matching the original category. + #[tokio::test] + async fn store_strips_custom_prefix_from_wire_category() { + let (_tmp, mem) = test_mem(); + let tool = MemoryStoreTool::new(mem.clone(), test_security()); + let result = tool + .execute(json!({ + "namespace": "global", + "key": "proj_note", + "content": "Uses async runtime", + "category": "custom:project" + })) + .await + .unwrap(); + assert!(!result.is_error); + + let entry = mem.get("global", "proj_note").await.unwrap().unwrap(); + assert_eq!( + entry.category, + MemoryCategory::Custom("project".into()), + "the `custom:` wire prefix must be stripped, not double-stored" + ); + } + #[tokio::test] async fn store_rejects_secret_like_content() { let (_tmp, mem) = test_mem(); diff --git a/src/openhuman/memory/traits.rs b/src/openhuman/memory/traits.rs index 6c63498e0a..3d6040425a 100644 --- a/src/openhuman/memory/traits.rs +++ b/src/openhuman/memory/traits.rs @@ -36,9 +36,25 @@ mod tests { assert_eq!(MemoryCategory::Core.to_string(), "core"); assert_eq!(MemoryCategory::Daily.to_string(), "daily"); assert_eq!(MemoryCategory::Conversation.to_string(), "conversation"); + // TinyCortex renders `Custom(name)` with a `custom:` prefix so it stays + // distinct from the built-in variants and `Display`/`FromStr` are true + // inverses (see `memory_category_from_stored`). assert_eq!( MemoryCategory::Custom("project_notes".into()).to_string(), - "project_notes" + "custom:project_notes" + ); + } + + #[test] + fn memory_category_custom_wire_values_round_trip_and_accept_legacy_bare_values() { + let current: MemoryCategory = "custom:project_notes".parse().unwrap(); + let legacy: MemoryCategory = "project_notes".parse().unwrap(); + + assert_eq!(current, MemoryCategory::Custom("project_notes".into())); + assert_eq!(legacy, MemoryCategory::Custom("project_notes".into())); + assert_eq!( + serde_json::to_string(¤t).unwrap(), + "\"custom:project_notes\"" ); } diff --git a/src/openhuman/memory/tree_source/file.rs b/src/openhuman/memory/tree_source/file.rs index c7981150ab..080b17a584 100644 --- a/src/openhuman/memory/tree_source/file.rs +++ b/src/openhuman/memory/tree_source/file.rs @@ -150,6 +150,7 @@ mod tests { id: "source:abc".into(), kind: TreeKind::Source, scope: scope.into(), + ask: None, root_id: None, max_level: 0, status: TreeStatus::Active, diff --git a/src/openhuman/memory_queue/worker.rs b/src/openhuman/memory_queue/worker.rs index 67b6137450..eb7b4835e1 100644 --- a/src/openhuman/memory_queue/worker.rs +++ b/src/openhuman/memory_queue/worker.rs @@ -1030,10 +1030,32 @@ mod tests { .unwrap() .expect("enqueue backfill job"); - let processed = run_once(&cfg).await.unwrap(); - assert!(processed); - - let job = get_job(&cfg, &id).unwrap().expect("job should still exist"); + // The TinyCortex LLM gate is process-global, so a parallel libtest can + // briefly own its single permit. In that case `run_once` legitimately + // defers this row for 50 ms with `llm concurrency gate busy` before the + // re-embed handler is reached. Retry that transient gate deferral so + // this test continues to pin the handler's own defer/reschedule path. + let mut job = None; + for _ in 0..20 { + let processed = run_once(&cfg).await.unwrap(); + assert!(processed); + let current = get_job(&cfg, &id).unwrap().expect("job should still exist"); + if current + .last_error + .as_deref() + .is_some_and(|reason| reason.contains("re-embed backfill")) + { + job = Some(current); + break; + } + assert_eq!( + current.last_error.as_deref(), + Some("llm concurrency gate busy"), + "unexpected defer reason before re-embed handler" + ); + tokio::time::sleep(Duration::from_millis(60)).await; + } + let job = job.expect("re-embed handler should run after transient gate contention"); assert_eq!(job.kind, JobKind::ReembedBackfill); assert_eq!(job.status, JobStatus::Ready); assert_eq!( diff --git a/src/openhuman/memory_search/tools/hybrid_search.rs b/src/openhuman/memory_search/tools/hybrid_search.rs index 462c4d777b..eda4b46987 100644 --- a/src/openhuman/memory_search/tools/hybrid_search.rs +++ b/src/openhuman/memory_search/tools/hybrid_search.rs @@ -110,7 +110,16 @@ impl Tool for MemoryHybridSearchTool { )); } - let profile = WeightProfile::by_name(&parsed.mode); + let profile = WeightProfile::by_name(&parsed.mode).ok_or_else(|| { + log::warn!( + "[tool][memory_hybrid_search] rejected unknown mode={}", + parsed.mode + ); + anyhow::anyhow!( + "memory_hybrid_search: unknown mode '{}'; expected balanced, semantic, lexical, or graph_first", + parsed.mode + ) + })?; let limit = parsed.limit.clamp(1, 50); log::debug!( @@ -209,3 +218,26 @@ impl Tool for MemoryHybridSearchTool { Ok(ToolResult::success(output)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn rejects_unknown_mode_before_opening_external_search_resources() { + let error = MemoryHybridSearchTool + .execute(json!({ + "query": "release checklist", + "namespace": "global", + "mode": "mystery" + })) + .await + .expect_err("an unknown mode must fail validation"); + + let message = error.to_string(); + assert!(message.contains("unknown mode 'mystery'"), "{message}"); + // Validation runs before config, provider, and store setup. Reaching any + // external search path would replace this precise validation error. + assert!(!message.contains("load config failed"), "{message}"); + } +} diff --git a/src/openhuman/memory_search/tools/vector_search.rs b/src/openhuman/memory_search/tools/vector_search.rs index ad04fa4121..4fa1065512 100644 --- a/src/openhuman/memory_search/tools/vector_search.rs +++ b/src/openhuman/memory_search/tools/vector_search.rs @@ -152,6 +152,7 @@ impl Tool for MemoryVectorSearchTool { since_ms, until_ms: None, limit: Some(1000), + offset: None, source_scope: crate::openhuman::memory::source_scope::current_source_scope(), exclude_dropped: false, }; diff --git a/src/openhuman/memory_sources/rpc.rs b/src/openhuman/memory_sources/rpc.rs index 1910115940..64f41fc67c 100644 --- a/src/openhuman/memory_sources/rpc.rs +++ b/src/openhuman/memory_sources/rpc.rs @@ -6,6 +6,82 @@ use crate::openhuman::memory_sources::registry::{self, MemorySourcePatch}; use crate::openhuman::memory_sources::types::{MemorySourceEntry, SourceKind}; use crate::rpc::RpcOutcome; +#[derive(Debug, serde::Serialize)] +pub struct CodingSessionStatusResponse { + pub sources: Vec, +} + +pub async fn coding_session_status_rpc() -> Result, String> +{ + tracing::debug!("[memory_sources] coding_session_status_rpc: entry"); + let sources = tokio::task::spawn_blocking(crate::openhuman::tinycortex::coding_session_status) + .await + .map_err(|error| format!("join coding-session discovery: {error}"))?; + tracing::debug!( + sources = sources.len(), + files = sources + .iter() + .map(|source| source.session_files) + .sum::(), + "[memory_sources] coding_session_status_rpc: exit" + ); + Ok(RpcOutcome::new( + CodingSessionStatusResponse { sources }, + vec![], + )) +} + +pub async fn ingest_coding_sessions_rpc( + req: crate::openhuman::tinycortex::CodingSessionIngestRequest, +) -> Result, String> { + tracing::info!("[memory_sources] ingest_coding_sessions_rpc: entry"); + let config = crate::openhuman::config::Config::load_or_init() + .await + .map_err(|error| format!("load config for coding-session ingestion: {error}"))?; + // TinyCortex's persona pipeline intentionally carries borrowed path state + // and is not `Send`. Drive it from a blocking worker while its async I/O + // remains attached to the ambient Tokio runtime, keeping the controller + // future itself Send-safe for the registry. + let runtime = tokio::runtime::Handle::current(); + // Wall-clock ceiling so a stalled provider call or a wedged session step + // can't keep the RPC (and its blocking worker) waiting indefinitely (#4863 + // review). Scale to the requested budget — each session drives at most one + // LLM call — so a large backfill isn't killed mid-flight while a genuine + // infinite hang still terminates. `max_sessions` is untrusted, so cap the + // multiplier before computing the budget. + let ingest_timeout = + std::time::Duration::from_secs(120 + (req.max_sessions.min(1_000) as u64) * 30); + let response = tokio::task::spawn_blocking(move || { + runtime.block_on(async move { + tokio::time::timeout( + ingest_timeout, + crate::openhuman::tinycortex::ingest_coding_sessions(&config, req), + ) + .await + }) + }) + .await + .map_err(|error| format!("join coding-session ingestion: {error}"))? + .map_err(|_elapsed| { + tracing::error!( + timeout_secs = ingest_timeout.as_secs(), + "[memory_sources] ingest_coding_sessions_rpc: timed out" + ); + format!( + "ingest coding sessions: timed out after {}s", + ingest_timeout.as_secs() + ) + })? + .map_err(|error| format!("ingest coding sessions: {error:#}"))?; + tracing::info!( + processed = response.sessions_processed, + failed = response.sessions_failed, + budget_hit = response.budget_hit, + "[memory_sources] ingest_coding_sessions_rpc: exit" + ); + Ok(RpcOutcome::new(response, vec![])) +} + // ── List ── #[derive(Debug, serde::Serialize)] diff --git a/src/openhuman/memory_sources/schemas.rs b/src/openhuman/memory_sources/schemas.rs index 176a733c2c..f3f8c46a48 100644 --- a/src/openhuman/memory_sources/schemas.rs +++ b/src/openhuman/memory_sources/schemas.rs @@ -135,6 +135,8 @@ pub fn all_controller_schemas() -> Vec { schemas("estimate_sync_cost"), schemas("monthly_cost_summary"), schemas("apply_all_in"), + schemas("coding_session_status"), + schemas("ingest_coding_sessions"), ] } @@ -200,6 +202,14 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("apply_all_in"), handler: handle_apply_all_in, }, + RegisteredController { + schema: schemas("coding_session_status"), + handler: handle_coding_session_status, + }, + RegisteredController { + schema: schemas("ingest_coding_sessions"), + handler: handle_ingest_coding_sessions, + }, ] } @@ -586,6 +596,48 @@ pub fn schemas(function: &str) -> ControllerSchema { }, ], }, + "coding_session_status" => ControllerSchema { + namespace: NAMESPACE, + function: "coding_session_status", + description: "Discover local Codex and Claude Code session histories and report the human-authored evidence available for memory ingestion.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "sources", + ty: TypeSchema::Array(Box::new(TypeSchema::Ref("CodingSessionSourceStatus"))), + comment: "Discovery and evidence counts for each supported coding-agent session source.", + required: true, + }], + }, + "ingest_coding_sessions" => ControllerSchema { + namespace: NAMESPACE, + function: "ingest_coding_sessions", + description: "Distill human-authored turns from local Codex and Claude Code sessions into the TinyCortex persona memory layer.", + inputs: vec![ + FieldSchema { + name: "backfill", + ty: TypeSchema::Bool, + comment: "When true, reprocess all discovered sessions; otherwise ingest only changed sessions.", + required: false, + }, + FieldSchema { + name: "max_sessions", + ty: TypeSchema::U64, + comment: "Maximum session digests for this run (clamped to 1,000).", + required: false, + }, + ], + outputs: vec![ + FieldSchema { name: "mode", ty: TypeSchema::String, comment: "Executed run mode.", required: true }, + FieldSchema { name: "files_seen", ty: TypeSchema::U64, comment: "Discovered coding-session files.", required: true }, + FieldSchema { name: "sessions_processed", ty: TypeSchema::U64, comment: "Coding sessions distilled successfully.", required: true }, + FieldSchema { name: "sessions_skipped", ty: TypeSchema::U64, comment: "Unchanged sessions skipped during an incremental run.", required: true }, + FieldSchema { name: "sessions_failed", ty: TypeSchema::U64, comment: "Sessions retained for retry after provider failure.", required: true }, + FieldSchema { name: "evidence_units", ty: TypeSchema::U64, comment: "Human-authored evidence units extracted.", required: true }, + FieldSchema { name: "observations", ty: TypeSchema::U64, comment: "Persona observations distilled.", required: true }, + FieldSchema { name: "budget_hit", ty: TypeSchema::Bool, comment: "Whether the run stopped at its session/call budget.", required: true }, + FieldSchema { name: "pack_path", ty: TypeSchema::Option(Box::new(TypeSchema::String)), comment: "Compiled persona pack path when written.", required: false }, + ], + }, other => panic!("unknown memory_sources schema function: {other}"), } } @@ -677,6 +729,19 @@ fn handle_apply_all_in(_params: Map) -> ControllerFuture { Box::pin(async move { to_json(rpc::apply_all_in_rpc().await?) }) } +fn handle_coding_session_status(_params: Map) -> ControllerFuture { + Box::pin(async move { to_json(rpc::coding_session_status_rpc().await?) }) +} + +fn handle_ingest_coding_sessions(params: Map) -> ControllerFuture { + Box::pin(async move { + let req = parse_value::( + Value::Object(params), + )?; + to_json(rpc::ingest_coding_sessions_rpc(req).await?) + }) +} + fn parse_value(v: Value) -> Result { serde_json::from_value(v).map_err(|e| format!("invalid params: {e}")) } diff --git a/src/openhuman/memory_store/memory_trait.rs b/src/openhuman/memory_store/memory_trait.rs index d209aa04a4..64f9d2cbc6 100644 --- a/src/openhuman/memory_store/memory_trait.rs +++ b/src/openhuman/memory_store/memory_trait.rs @@ -45,13 +45,24 @@ fn normalize_namespace(namespace: Option<&str>) -> &str { } /// Helper to convert a raw string category from the database into a `MemoryCategory`. +/// +/// The store persists a category via its `Display` form, and the current +/// TinyCortex format renders `Custom(name)` as `custom:{name}` (so `Custom("core")` +/// stays distinct from `Core`). Parse back through `FromStr` — the true inverse of +/// `Display` — so the `custom:` prefix is stripped symmetrically. Wrapping the raw +/// string in `Custom(_)` instead (the previous behaviour) double-prefixed on +/// read-back once the wire format gained the prefix. An empty stored value has no +/// `FromStr` mapping, so it falls back to an empty `Custom` (matching the prior +/// catch-all for that degenerate case). fn memory_category_from_stored(raw: &str) -> MemoryCategory { - match raw { - "core" => MemoryCategory::Core, - "daily" => MemoryCategory::Daily, - "conversation" => MemoryCategory::Conversation, - other => MemoryCategory::Custom(other.to_string()), - } + raw.parse().unwrap_or_else(|error| { + tracing::debug!( + category_chars = raw.chars().count(), + reason = %error, + "[memory_store] invalid stored category; preserving as custom" + ); + MemoryCategory::Custom(raw.to_string()) + }) } #[async_trait] diff --git a/src/openhuman/memory_store/retrieval/mod.rs b/src/openhuman/memory_store/retrieval/mod.rs index 1e7fd632be..b819362b6a 100644 --- a/src/openhuman/memory_store/retrieval/mod.rs +++ b/src/openhuman/memory_store/retrieval/mod.rs @@ -128,6 +128,7 @@ impl RetrievalFacade { since_ms: filters.since_ms, until_ms: filters.until_ms, limit: filters.limit, + offset: None, source_scope: None, exclude_dropped: false, }; diff --git a/src/openhuman/memory_store/tools/raw_chunks.rs b/src/openhuman/memory_store/tools/raw_chunks.rs index cdcc3ef430..88ca2e03a2 100644 --- a/src/openhuman/memory_store/tools/raw_chunks.rs +++ b/src/openhuman/memory_store/tools/raw_chunks.rs @@ -101,6 +101,7 @@ impl Tool for MemoryStoreRawChunksTool { since_ms: parsed.since_ms, until_ms: parsed.until_ms, limit: parsed.limit, + offset: None, source_scope: crate::openhuman::memory::source_scope::current_source_scope(), exclude_dropped: false, }; diff --git a/src/openhuman/memory_store/traits.rs b/src/openhuman/memory_store/traits.rs index 7a5819fc78..2b9002a56e 100644 --- a/src/openhuman/memory_store/traits.rs +++ b/src/openhuman/memory_store/traits.rs @@ -252,6 +252,7 @@ mod tests { id: "tree-1".into(), kind: crate::openhuman::memory_store::trees::TreeKind::Topic, scope: "topic:phoenix".into(), + ask: None, root_id: Some("summary-root".into()), max_level: 2, status: crate::openhuman::memory_store::trees::TreeStatus::Active, diff --git a/src/openhuman/memory_store/trees/store_tests.rs b/src/openhuman/memory_store/trees/store_tests.rs index e498710efd..50f929ce19 100644 --- a/src/openhuman/memory_store/trees/store_tests.rs +++ b/src/openhuman/memory_store/trees/store_tests.rs @@ -16,6 +16,7 @@ fn sample_tree(id: &str, scope: &str) -> Tree { id: id.to_string(), kind: TreeKind::Source, scope: scope.to_string(), + ask: None, root_id: None, max_level: 0, status: TreeStatus::Active, diff --git a/src/openhuman/memory_tree/tree/registry.rs b/src/openhuman/memory_tree/tree/registry.rs index d11ff6e943..a317664b34 100644 --- a/src/openhuman/memory_tree/tree/registry.rs +++ b/src/openhuman/memory_tree/tree/registry.rs @@ -35,6 +35,7 @@ pub fn get_or_create_tree(config: &Config, kind: TreeKind, scope: &str) -> Resul id: new_tree_id(kind), kind, scope: scope.to_string(), + ask: None, root_id: None, max_level: 0, status: TreeStatus::Active, @@ -201,6 +202,7 @@ mod tests { id: "source:preexisting".into(), kind: TreeKind::Source, scope: "slack:#eng".into(), + ask: None, root_id: None, max_level: 0, status: TreeStatus::Active, diff --git a/src/openhuman/memory_tree/tree/rpc.rs b/src/openhuman/memory_tree/tree/rpc.rs index c277a850f4..03be61a56f 100644 --- a/src/openhuman/memory_tree/tree/rpc.rs +++ b/src/openhuman/memory_tree/tree/rpc.rs @@ -139,6 +139,7 @@ pub async fn list_chunks_rpc( since_ms: req.since_ms, until_ms: req.until_ms, limit: req.limit, + offset: None, source_scope: None, exclude_dropped: false, }; diff --git a/src/openhuman/tinycortex/ingest.rs b/src/openhuman/tinycortex/ingest.rs index 0420013e84..fac6dd00e2 100644 --- a/src/openhuman/tinycortex/ingest.rs +++ b/src/openhuman/tinycortex/ingest.rs @@ -1,29 +1,48 @@ //! Host adapters for tinycortex on-demand ingestion. -use tinycortex::memory::ingest::TreeJobSink; +use rusqlite::Transaction; +use tinycortex::memory::ingest::{QueueJobSink, TreeJobSink}; use tinycortex::memory::score::extract::{LlmEntityExtractor, LlmExtractorConfig}; use tinycortex::memory::score::ScoringConfig; use crate::openhuman::config::Config; -use crate::openhuman::memory_queue::{self, ExtractChunkPayload, NewJob}; -pub struct HostTreeJobSink { - config: Config, -} +#[derive(Default)] +pub struct HostTreeJobSink; impl HostTreeJobSink { - pub fn new(config: Config) -> Self { - Self { config } + pub fn new() -> Self { + Self } } impl TreeJobSink for HostTreeJobSink { - fn enqueue_extract(&self, chunk_id: &str) -> anyhow::Result<()> { - let job = NewJob::extract_chunk(&ExtractChunkPayload { - chunk_id: chunk_id.into(), - })?; - memory_queue::enqueue(&self.config, &job)?; - Ok(()) + fn enqueue_extract_tx( + &self, + tx: &Transaction<'_>, + chunk_id: &str, + default_max_attempts: u32, + ) -> anyhow::Result { + tracing::trace!( + chunk_id, + default_max_attempts, + "[memory:ingest] enqueue extract job in chunk transaction" + ); + let enqueued = QueueJobSink + .enqueue_extract_tx(tx, chunk_id, default_max_attempts) + .inspect_err(|error| { + tracing::error!( + chunk_id, + error = %error, + "[memory:ingest] enqueue extract job failed" + ); + })?; + tracing::trace!( + chunk_id, + enqueued, + "[memory:ingest] enqueue extract job outcome (false = already queued)" + ); + Ok(enqueued) } } @@ -52,7 +71,7 @@ pub fn context( ) { ( super::memory_config_from(config, config.workspace_dir.clone()), - HostTreeJobSink::new(config.clone()), + HostTreeJobSink::new(), scoring_config(config), ) } diff --git a/src/openhuman/tinycortex/mod.rs b/src/openhuman/tinycortex/mod.rs index b013e42d94..99d5a36d86 100644 --- a/src/openhuman/tinycortex/mod.rs +++ b/src/openhuman/tinycortex/mod.rs @@ -39,6 +39,7 @@ mod embeddings; mod ingest; #[cfg(test)] mod parity; +mod persona; mod queue_driver; mod seal; mod summariser; @@ -48,6 +49,10 @@ pub use chat::{build_chat_provider, SeamChatProvider}; pub use config::memory_config_from; pub use embeddings::SeamEmbedder; pub use ingest::{context as ingest_context, HostTreeJobSink}; +pub use persona::{ + coding_session_status, coding_session_status_for_roots, ingest_coding_sessions, + CodingSessionIngestRequest, CodingSessionIngestResponse, CodingSessionSourceStatus, +}; pub use queue_driver::{ classify_worker_error, HostQueueDelegates, WorkerErrorAction, WorkerReport, }; diff --git a/src/openhuman/tinycortex/parity.rs b/src/openhuman/tinycortex/parity.rs index aed98124f9..e152c17de7 100644 --- a/src/openhuman/tinycortex/parity.rs +++ b/src/openhuman/tinycortex/parity.rs @@ -69,7 +69,7 @@ mod tests { assert_eq!(bytes.len(), v.len() * 4); assert_eq!(hex(&bytes), "0000803f000000c00000003f"); // Round-trips exactly. - assert_eq!(bytes_to_vec(&bytes), v); + assert_eq!(bytes_to_vec(&bytes).expect("valid packed f32 bytes"), v); } /// P6 — vault paths sanitize IDs to cross-platform-safe filenames. Chunk IDs diff --git a/src/openhuman/tinycortex/persona.rs b/src/openhuman/tinycortex/persona.rs new file mode 100644 index 0000000000..0438ee2dad --- /dev/null +++ b/src/openhuman/tinycortex/persona.rs @@ -0,0 +1,446 @@ +//! Host orchestration for TinyCortex coding-session persona ingestion. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use tinycortex::memory::persona::readers::{claude_code, codex, RawSession}; +use tinycortex::memory::persona::state::FileStateStore; +use tinycortex::memory::persona::{PersonaConfig, Pipeline, RunMode}; +use walkdir::WalkDir; + +use crate::openhuman::config::Config; + +const DEFAULT_MAX_SESSIONS: usize = 100; +const MAX_MAX_SESSIONS: usize = 1_000; +const MAX_STATUS_SESSION_FILES: usize = 1_000; +const MAX_STATUS_SESSION_FILE_BYTES: u64 = 4 * 1024 * 1024; +const MAX_STATUS_TOTAL_BYTES: u64 = 16 * 1024 * 1024; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct CodingSessionSourceStatus { + pub kind: String, + pub available: bool, + pub session_files: usize, + pub evidence_units: usize, + pub invalid_files: usize, + pub scan_truncated: bool, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CodingSessionIngestRequest { + #[serde(default)] + pub backfill: bool, + #[serde(default = "default_max_sessions")] + pub max_sessions: usize, +} + +fn default_max_sessions() -> usize { + DEFAULT_MAX_SESSIONS +} + +#[derive(Debug, Clone, Serialize)] +pub struct CodingSessionIngestResponse { + pub mode: String, + pub files_seen: usize, + pub sessions_processed: usize, + pub sessions_skipped: usize, + pub sessions_failed: usize, + pub evidence_units: usize, + pub observations: usize, + pub budget_hit: bool, + pub pack_path: Option, +} + +fn roots_from_environment() -> (PathBuf, PathBuf) { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + let claude_home = std::env::var_os("CLAUDE_CONFIG_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".claude")); + let codex_home = std::env::var_os("CODEX_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".codex")); + (claude_home.join("projects"), codex_home.join("sessions")) +} + +fn source_status( + kind: &str, + root: &Path, + max_files: usize, + discover: impl Fn(&Path, usize) -> (Vec, bool), + read: impl Fn(&Path) -> anyhow::Result, +) -> CodingSessionSourceStatus { + let (files, mut scan_truncated) = discover(root, max_files); + if scan_truncated { + tracing::debug!( + source = kind, + max_files, + "[memory_persona] coding session status scan capped" + ); + } + let mut evidence_units = 0; + let mut invalid_files = 0; + let mut bytes_scheduled = 0_u64; + for path in &files { + if let Ok(metadata) = path.metadata() { + let file_bytes = metadata.len(); + if file_bytes > MAX_STATUS_SESSION_FILE_BYTES + || bytes_scheduled.saturating_add(file_bytes) > MAX_STATUS_TOTAL_BYTES + { + scan_truncated = true; + tracing::debug!( + source = kind, + file_bytes, + bytes_scheduled, + max_file_bytes = MAX_STATUS_SESSION_FILE_BYTES, + max_total_bytes = MAX_STATUS_TOTAL_BYTES, + reason = "status-byte-budget", + "[memory_persona] skipped coding session during bounded status scan" + ); + continue; + } + bytes_scheduled += file_bytes; + } + match read(path) { + Ok(session) => evidence_units += session.evidence.len(), + Err(_error) => { + invalid_files += 1; + tracing::debug!( + source = kind, + reason = "read-or-parse-failed", + "[memory_persona] skipped unreadable coding session" + ); + } + } + } + CodingSessionSourceStatus { + kind: kind.to_string(), + available: root.is_dir(), + session_files: files.len(), + evidence_units, + invalid_files, + scan_truncated, + } +} + +fn discover_session_files( + root: &Path, + max_files: usize, + is_candidate: impl Fn(&Path) -> bool, +) -> (Vec, bool) { + let mut files = Vec::with_capacity(max_files.min(64)); + // Keep traversal unsorted: `sort_by_file_name` buffers and sorts every + // directory before yielding its first entry, which defeats `max_files` + // for users with very large Codex day or Claude project directories. + for entry in WalkDir::new(root) + .into_iter() + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_file()) + { + let path = entry.path(); + if !is_candidate(path) { + continue; + } + if files.len() == max_files { + return (files, true); + } + files.push(path.to_path_buf()); + } + (files, false) +} + +fn discover_claude_sessions(root: &Path, max_files: usize) -> (Vec, bool) { + discover_session_files(root, max_files, |path| { + path.extension() + .is_some_and(|extension| extension == "jsonl") + }) +} + +fn discover_codex_sessions(root: &Path, max_files: usize) -> (Vec, bool) { + discover_session_files(root, max_files, |path| { + path.extension() + .is_some_and(|extension| extension == "jsonl") + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("rollout-")) + }) +} + +pub fn coding_session_status_for_roots( + claude_root: &Path, + codex_root: &Path, +) -> Vec { + tracing::debug!("[memory_persona] coding session scan: entry"); + let statuses = vec![ + source_status( + "claude_code", + claude_root, + MAX_STATUS_SESSION_FILES, + discover_claude_sessions, + claude_code::read_session, + ), + source_status( + "codex", + codex_root, + MAX_STATUS_SESSION_FILES, + discover_codex_sessions, + codex::read_session, + ), + ]; + tracing::debug!( + files = statuses + .iter() + .map(|status| status.session_files) + .sum::(), + evidence = statuses + .iter() + .map(|status| status.evidence_units) + .sum::(), + invalid = statuses + .iter() + .map(|status| status.invalid_files) + .sum::(), + "[memory_persona] coding session scan: exit" + ); + statuses +} + +pub fn coding_session_status() -> Vec { + let (claude_root, codex_root) = roots_from_environment(); + coding_session_status_for_roots(&claude_root, &codex_root) +} + +pub async fn ingest_coding_sessions( + config: &Config, + request: CodingSessionIngestRequest, +) -> anyhow::Result { + let (claude_root, codex_root) = roots_from_environment(); + let max_sessions = request.max_sessions.clamp(1, MAX_MAX_SESSIONS); + let mode = if request.backfill { + RunMode::Backfill + } else { + RunMode::Incremental + }; + tracing::info!( + mode = if request.backfill { + "backfill" + } else { + "incremental" + }, + max_sessions, + "[memory_persona] coding session ingestion: entry" + ); + + let memory_config = super::memory_config_from(config, config.workspace_dir.clone()); + let mut persona = PersonaConfig::with_home( + dirs::home_dir() + .as_deref() + .unwrap_or_else(|| Path::new(".")), + "OpenHuman user", + ); + persona.claude_code_root = Some(claude_root); + persona.codex_root = Some(codex_root); + // This product surface is deliberately scoped to coding-session history. + // Repository history and instruction files can be wired separately with + // their own disclosure and cost controls. + persona.project_roots.clear(); + persona.global_instruction_files.clear(); + persona.author_emails.clear(); + persona.run_budget.max_sessions = max_sessions; + persona.run_budget.max_llm_calls = max_sessions as u32; + + let provider = super::build_chat_provider(config).inspect_err(|error| { + tracing::error!( + error = %error, + "[memory_persona] coding session ingestion: build_chat_provider failed" + ); + })?; + let summariser = super::HostSummariser::new(config.clone()); + let store = FileStateStore::open_in_workspace(&config.workspace_dir).inspect_err(|error| { + tracing::error!( + error = %error, + "[memory_persona] coding session ingestion: open state store failed" + ); + })?; + let report = Pipeline { + config: &memory_config, + persona: &persona, + provider: provider.as_ref(), + summariser: &summariser, + store: &store, + } + .run(mode) + .await + .inspect_err(|error| { + tracing::error!( + error = %error, + "[memory_persona] coding session ingestion: pipeline run failed" + ); + })?; + + tracing::info!( + files_seen = report.files_seen, + sessions_processed = report.sessions_processed, + sessions_failed = report.sessions_failed, + evidence_units = report.evidence_units, + observations = report.observations, + budget_hit = report.budget_hit, + "[memory_persona] coding session ingestion: exit" + ); + Ok(CodingSessionIngestResponse { + mode: report.mode, + files_seen: report.files_seen, + sessions_processed: report.sessions_processed, + sessions_skipped: report.sessions_skipped, + sessions_failed: report.sessions_failed, + evidence_units: report.evidence_units, + observations: report.observations, + budget_hit: report.budget_hit, + pack_path: report.pack_path, + }) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::*; + + #[test] + fn scans_codex_and_claude_sessions_and_filters_machine_content() { + let temp = tempdir().unwrap(); + let claude = temp.path().join("claude"); + let codex = temp.path().join("codex/2026/07/14"); + fs::create_dir_all(&claude).unwrap(); + fs::create_dir_all(&codex).unwrap(); + fs::write( + claude.join("session.jsonl"), + concat!( + "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"machine\"}]}}\n", + "{\"type\":\"user\",\"sessionId\":\"c1\",\"cwd\":\"/repo\",\"timestamp\":\"2026-07-14T00:00:00Z\",\"message\":{\"content\":\"Prefer small modules\"}}\n" + ), + ) + .unwrap(); + fs::write( + codex.join("rollout-test.jsonl"), + concat!( + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"x1\",\"cwd\":\"/repo\"}}\n", + "{\"type\":\"response_item\",\"timestamp\":\"2026-07-14T00:00:00Z\",\"payload\":{\"type\":\"message\",\"role\":\"developer\",\"content\":[{\"type\":\"input_text\",\"text\":\"secret scaffolding\"}]}}\n", + "{\"type\":\"response_item\",\"timestamp\":\"2026-07-14T00:00:01Z\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Run focused tests first\"}]}}\n" + ), + ) + .unwrap(); + + let statuses = coding_session_status_for_roots(&claude, &temp.path().join("codex")); + assert_eq!(statuses.len(), 2); + assert_eq!(statuses[0].session_files, 1); + assert_eq!(statuses[0].evidence_units, 1); + assert_eq!(statuses[1].session_files, 1); + assert_eq!(statuses[1].evidence_units, 1); + assert_eq!(statuses[0].invalid_files + statuses[1].invalid_files, 0); + } + + #[test] + fn status_scan_stops_parsing_at_the_configured_limit() { + let paths = vec![PathBuf::from("one"), PathBuf::from("two")]; + let reads = std::cell::Cell::new(0); + let status = source_status( + "fixture", + Path::new("."), + 1, + |_, max_files| (paths[..max_files].to_vec(), paths.len() > max_files), + |_| { + reads.set(reads.get() + 1); + Ok(RawSession::new( + tinycortex::memory::persona::types::EvidenceSource::new( + tinycortex::memory::persona::types::PersonaSourceKind::Codex, + ), + )) + }, + ); + + assert_eq!(reads.get(), 1); + assert_eq!(status.session_files, 1); + assert!(status.scan_truncated); + } + + #[test] + fn bounded_discovery_stops_after_finding_one_extra_candidate_without_ordering() { + let temp = tempdir().unwrap(); + fs::write(temp.path().join("a.jsonl"), "").unwrap(); + fs::write(temp.path().join("b.jsonl"), "").unwrap(); + fs::write(temp.path().join("ignored.txt"), "").unwrap(); + + let (files, truncated) = discover_claude_sessions(temp.path(), 1); + + assert_eq!(files.len(), 1); + assert_eq!(files[0].extension().unwrap(), "jsonl"); + assert!(truncated); + } + + #[test] + fn status_scan_skips_oversized_sessions_without_parsing_them() { + let temp = tempdir().unwrap(); + let oversized = temp.path().join("oversized.jsonl"); + let small = temp.path().join("small.jsonl"); + let file = fs::File::create(&oversized).unwrap(); + file.set_len(MAX_STATUS_SESSION_FILE_BYTES + 1).unwrap(); + fs::write(&small, "{}\n").unwrap(); + let reads = std::cell::Cell::new(0); + + let status = source_status( + "fixture", + temp.path(), + 2, + |_, _| (vec![oversized.clone(), small.clone()], false), + |_| { + reads.set(reads.get() + 1); + Ok(RawSession::new( + tinycortex::memory::persona::types::EvidenceSource::new( + tinycortex::memory::persona::types::PersonaSourceKind::Codex, + ), + )) + }, + ); + + assert_eq!(reads.get(), 1); + assert_eq!(status.session_files, 2); + assert_eq!(status.invalid_files, 0); + assert!(status.scan_truncated); + } + + #[test] + fn status_scan_enforces_the_aggregate_byte_budget() { + let temp = tempdir().unwrap(); + let paths = (0..5) + .map(|index| { + let path = temp.path().join(format!("session-{index}.jsonl")); + let file = fs::File::create(&path).unwrap(); + file.set_len(MAX_STATUS_SESSION_FILE_BYTES).unwrap(); + path + }) + .collect::>(); + let reads = std::cell::Cell::new(0); + + let status = source_status( + "fixture", + temp.path(), + paths.len(), + |_, _| (paths.clone(), false), + |_| { + reads.set(reads.get() + 1); + Ok(RawSession::new( + tinycortex::memory::persona::types::EvidenceSource::new( + tinycortex::memory::persona::types::PersonaSourceKind::Codex, + ), + )) + }, + ); + + assert_eq!(reads.get(), 4); + assert_eq!(status.session_files, 5); + assert!(status.scan_truncated); + } +} diff --git a/src/openhuman/tool_registry/denials.rs b/src/openhuman/tool_registry/denials.rs index 4e6ae832b1..9c6a1f6cb0 100644 --- a/src/openhuman/tool_registry/denials.rs +++ b/src/openhuman/tool_registry/denials.rs @@ -77,6 +77,12 @@ fn truncate_reason(reason: &str) -> String { mod tests { use super::*; + // These tests intentionally mutate the process-global denial buffer. Keep + // their clear/record/assert sequences atomic with respect to one another; + // the parallel libtest runner can otherwise clear a sibling's freshly + // recorded value between `record` and `list`. + static DENIAL_TEST_LOCK: Mutex<()> = Mutex::new(()); + fn clear_denials_for_test() { let mut buf = RECENT_DENIALS.lock().unwrap_or_else(|p| p.into_inner()); buf.clear(); @@ -84,6 +90,7 @@ mod tests { #[test] fn record_truncates_and_bounds() { + let _guard = DENIAL_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); clear_denials_for_test(); let long = "a".repeat(10_000); for _ in 0..(MAX_DENIALS + 5) { @@ -97,6 +104,7 @@ mod tests { #[test] fn record_ignores_empty_tool() { + let _guard = DENIAL_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); clear_denials_for_test(); record(" ", "policy", "denied", "reason"); // list() should not panic; we can't reliably assert length because tests may run in parallel. @@ -105,6 +113,7 @@ mod tests { #[test] fn record_redacts_sensitive_reason_fragments() { + let _guard = DENIAL_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); clear_denials_for_test(); record( "tool.secret", diff --git a/tests/coding_sessions_feature.rs b/tests/coding_sessions_feature.rs new file mode 100644 index 0000000000..e63f54b753 --- /dev/null +++ b/tests/coding_sessions_feature.rs @@ -0,0 +1,49 @@ +//! Feature contract for TinyCortex Codex/Claude session discovery through the +//! OpenHuman adapter seam. + +use std::fs; + +use tempfile::tempdir; + +use openhuman_core::openhuman::tinycortex::coding_session_status_for_roots; + +#[test] +fn coding_session_sources_extract_human_turns_from_both_harnesses() { + let temp = tempdir().expect("tempdir"); + let claude_root = temp.path().join("claude/projects/repo"); + let codex_root = temp.path().join("codex/sessions/2026/07/14"); + fs::create_dir_all(&claude_root).expect("claude root"); + fs::create_dir_all(&codex_root).expect("codex root"); + + fs::write( + claude_root.join("claude-session.jsonl"), + concat!( + "{\"type\":\"user\",\"sessionId\":\"claude-1\",\"cwd\":\"/repo\",\"timestamp\":\"2026-07-14T10:00:00Z\",\"message\":{\"content\":\"Use behavior-driven tests\"}}\n", + "{\"type\":\"user\",\"isSidechain\":true,\"message\":{\"content\":\"subagent machine traffic\"}}\n" + ), + ) + .expect("claude fixture"); + fs::write( + codex_root.join("rollout-codex-session.jsonl"), + concat!( + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"codex-1\",\"cwd\":\"/repo\"}}\n", + "{\"type\":\"response_item\",\"timestamp\":\"2026-07-14T10:00:00Z\",\"payload\":{\"type\":\"message\",\"role\":\"developer\",\"content\":[{\"type\":\"input_text\",\"text\":\"machine policy\"}]}}\n", + "{\"type\":\"response_item\",\"timestamp\":\"2026-07-14T10:00:01Z\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Keep modules below 500 lines\"}]}}\n" + ), + ) + .expect("codex fixture"); + + let statuses = coding_session_status_for_roots( + &temp.path().join("claude/projects"), + &temp.path().join("codex/sessions"), + ); + + assert_eq!(statuses.len(), 2); + assert_eq!(statuses[0].kind, "claude_code"); + assert_eq!(statuses[0].evidence_units, 1, "sidechain must be excluded"); + assert_eq!(statuses[1].kind, "codex"); + assert_eq!( + statuses[1].evidence_units, 1, + "developer policy must be excluded" + ); +} diff --git a/tests/config_auth_app_state_connectivity_e2e.rs b/tests/config_auth_app_state_connectivity_e2e.rs index 33b5064657..841563d163 100644 --- a/tests/config_auth_app_state_connectivity_e2e.rs +++ b/tests/config_auth_app_state_connectivity_e2e.rs @@ -2878,8 +2878,10 @@ async fn worker_a_controller_schemas_are_fully_exposed() { vec![ "openhuman.memory_sources_add", "openhuman.memory_sources_apply_all_in", + "openhuman.memory_sources_coding_session_status", "openhuman.memory_sources_estimate_sync_cost", "openhuman.memory_sources_get", + "openhuman.memory_sources_ingest_coding_sessions", "openhuman.memory_sources_list", "openhuman.memory_sources_list_items", "openhuman.memory_sources_monthly_cost_summary", diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index bcd7c0e0d6..73580df669 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -1113,6 +1113,51 @@ fn ensure_test_rpc_auth() { }); } +#[tokio::test] +async fn json_rpc_discovers_codex_and_claude_sessions_for_memory_ingestion() { + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + let claude_home = tmp.path().join("claude"); + let codex_home = tmp.path().join("codex"); + let claude_root = claude_home.join("projects/repo"); + let codex_root = codex_home.join("sessions/2026/07/14"); + std::fs::create_dir_all(&claude_root).expect("claude fixture root"); + std::fs::create_dir_all(&codex_root).expect("codex fixture root"); + std::fs::write( + claude_root.join("session.jsonl"), + "{\"type\":\"user\",\"timestamp\":\"2026-07-14T00:00:00Z\",\"message\":{\"content\":\"Prefer focused tests\"}}\n", + ) + .expect("claude fixture"); + std::fs::write( + codex_root.join("rollout-session.jsonl"), + "{\"type\":\"response_item\",\"timestamp\":\"2026-07-14T00:00:00Z\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Prefer small modules\"}]}}\n", + ) + .expect("codex fixture"); + + let _claude_guard = EnvVarGuard::set_to_path("CLAUDE_CONFIG_DIR", &claude_home); + let _codex_guard = EnvVarGuard::set_to_path("CODEX_HOME", &codex_home); + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let rpc_base = format!("http://{rpc_addr}"); + + let response = post_json_rpc( + &rpc_base, + 4_914_001, + "openhuman.memory_sources_coding_session_status", + json!({}), + ) + .await; + let result = peel_logs_envelope(assert_no_jsonrpc_error( + &response, + "memory_sources_coding_session_status", + )); + let sources = result["sources"].as_array().expect("sources array"); + assert_eq!(sources.len(), 2); + assert!(sources.iter().all(|source| source["session_files"] == 1)); + assert!(sources.iter().all(|source| source["evidence_units"] == 1)); + + rpc_join.abort(); +} + #[tokio::test] async fn json_rpc_config_update_browser_settings_persists_backend() { let _env_lock = json_rpc_e2e_env_lock(); 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 37de29f215..05156edc69 100644 --- a/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs +++ b/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs @@ -484,13 +484,16 @@ async fn max_iteration_checkpoint_uses_deterministic_fallback_and_hooks() { while let Ok(event) = progress_rx.try_recv() { streamed.push(event); } - assert!(!streamed.iter().any(|event| matches!( - event, - openhuman_core::openhuman::agent::progress::AgentProgress::TextDelta { - delta, - iteration: 2 - } if delta == "checkpoint delta" - )), "rejected checkpoint deltas must not leak to progress consumers"); + assert!( + !streamed.iter().any(|event| matches!( + event, + openhuman_core::openhuman::agent::progress::AgentProgress::TextDelta { + iteration: 2, + .. + } + )), + "wrap-up deltas must stay buffered when the completed response contains an invalid tool call" + ); } #[tokio::test] diff --git a/tests/raw_coverage/memory_raw_coverage_e2e.rs b/tests/raw_coverage/memory_raw_coverage_e2e.rs index 5ca4c65b1d..cd9b2777dd 100644 --- a/tests/raw_coverage/memory_raw_coverage_e2e.rs +++ b/tests/raw_coverage/memory_raw_coverage_e2e.rs @@ -270,6 +270,9 @@ fn memory_tree_types_and_fallback_summary_cover_budget_and_legacy_parse_paths() tree_kind: openhuman_core::openhuman::memory_store::trees::types::TreeKind::Global, target_level: 2, token_budget: 128, + input_token_budget: tinycortex::memory::config::INPUT_TOKEN_BUDGET, + overhead_reserve_tokens: tinycortex::memory::config::SUMMARY_OVERHEAD_RESERVE_TOKENS, + ask: None, }; assert_eq!(ctx.tree_id, "tree-coverage"); assert_eq!(ctx.target_level, 2); diff --git a/tests/raw_coverage/memory_sources_closure_round23_raw_coverage_e2e.rs b/tests/raw_coverage/memory_sources_closure_round23_raw_coverage_e2e.rs index 1c5d017327..6eaebb9e7d 100644 --- a/tests/raw_coverage/memory_sources_closure_round23_raw_coverage_e2e.rs +++ b/tests/raw_coverage/memory_sources_closure_round23_raw_coverage_e2e.rs @@ -167,7 +167,7 @@ async fn round23_memory_sources_status_registry_and_readers_cover_remaining_edge MemorySourcePatch { label: Some("Round23 Folder Updated".to_string()), enabled: Some(false), - glob: Some("**/*.md".to_string()), + glob: Some(Some("**/*.md".to_string())), ..MemorySourcePatch::default() }, ) diff --git a/tests/raw_coverage/memory_sync_sources_raw_coverage_e2e.rs b/tests/raw_coverage/memory_sync_sources_raw_coverage_e2e.rs index 82ddecd2f4..1b1b052ca8 100644 --- a/tests/raw_coverage/memory_sync_sources_raw_coverage_e2e.rs +++ b/tests/raw_coverage/memory_sync_sources_raw_coverage_e2e.rs @@ -166,7 +166,7 @@ async fn memory_sources_registry_persists_crud_and_composio_upserts() { MemorySourcePatch { label: Some("Renamed notes".to_string()), enabled: Some(false), - glob: Some("*.txt".to_string()), + glob: Some(Some("*.txt".to_string())), ..MemorySourcePatch::default() }, ) diff --git a/tests/raw_coverage/memory_sync_tree_round21_raw_coverage_e2e.rs b/tests/raw_coverage/memory_sync_tree_round21_raw_coverage_e2e.rs index 538d652b96..e6fdc9434e 100644 --- a/tests/raw_coverage/memory_sync_tree_round21_raw_coverage_e2e.rs +++ b/tests/raw_coverage/memory_sync_tree_round21_raw_coverage_e2e.rs @@ -513,6 +513,7 @@ fn seed_source_summary( id: format!("tree:{summary_id}"), kind: TreeKind::Source, scope: scope.to_string(), + ask: None, root_id: Some(summary_id.to_string()), max_level: 1, status: TreeStatus::Active, diff --git a/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs b/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs index ce3f478019..890267a67a 100644 --- a/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs +++ b/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs @@ -1601,11 +1601,18 @@ fn memory_tree_runtime_store_buffers_and_retrieval_wire_helpers() { let summaries = tree_runtime_store::collect_root_summaries_with_caps(tmp.path(), 10, 12); assert_eq!(summaries.len(), 1); - assert_eq!(summaries[0].0, "slack_#eng"); + let stored_namespace = tree_runtime_store::tree_dir(&config, namespace) + .parent() + .and_then(std::path::Path::file_name) + .and_then(std::ffi::OsStr::to_str) + .expect("sanitized namespace directory") + .to_string(); + assert!(stored_namespace.starts_with("slack_#eng-")); + assert_eq!(summaries[0].0, stored_namespace); assert!(summaries[0].1.contains("[... truncated]")); assert_eq!( tree_runtime_store::list_namespaces_with_root(&config).unwrap(), - vec!["slack_#eng".to_string()] + vec![stored_namespace] ); let ts = Utc.with_ymd_and_hms(2026, 5, 29, 13, 0, 0).unwrap(); @@ -1921,6 +1928,9 @@ async fn memory_read_rpc_score_index_and_summary_helpers_cover_dashboard_paths() tree_kind: TreeKind::Global, target_level: 1, token_budget: 100, + input_token_budget: tinycortex::memory::config::INPUT_TOKEN_BUDGET, + overhead_reserve_tokens: tinycortex::memory::config::SUMMARY_OVERHEAD_RESERVE_TOKENS, + ask: None, }; let empty = openhuman_core::openhuman::memory_tree::summarise::summarise(&config, &[], &empty_ctx) @@ -1967,6 +1977,7 @@ fn memory_retrieval_embedding_and_rpc_model_helpers_round_trip() { id: "tree-1".into(), kind: TreeKind::Topic, scope: "topic:coverage".into(), + ask: None, root_id: Some("sum-1".into()), max_level: 2, status: StoredTreeStatus::Active, @@ -2071,7 +2082,7 @@ fn memory_retrieval_embedding_and_rpc_model_helpers_round_trip() { score: Some(0.9), taint: Default::default(), }; - assert_eq!(entry.category.to_string(), "testing"); + assert_eq!(entry.category.to_string(), "custom:testing"); let opts = RecallOpts { namespace: Some("default"), category: Some(MemoryCategory::Conversation), @@ -2777,6 +2788,7 @@ fn memory_tree_io_contract_types_round_trip_leaf_read_and_write_shapes() { id: "empty-tree".into(), kind: TreeKind::Source, scope: "source:contract".into(), + ask: None, root_id: None, max_level: 0, status: StoredTreeStatus::Active, @@ -3951,8 +3963,7 @@ async fn memory_sources_registry_rpc_and_schema_handlers_cover_crud_edges() { patch: serde_json::from_value(json!({ "label": "Disabled folder", "enabled": false, - "glob": "**/*.md", - "max_items": 2 + "glob": "**/*.md" })) .expect("patch"), }) @@ -4698,17 +4709,15 @@ async fn memory_sources_types_registry_and_sync_state_cover_public_persistence_e let patch: registry::MemorySourcePatch = serde_json::from_value(json!({ "label": "Updated repo", "enabled": false, - "toolkit": "github", - "connection_id": "conn_repo", - "path": "/tmp/repo", - "glob": "**/*.md", "url": "https://github.com/tinyhumansai/openhuman-skills", "branch": "main", "paths": ["skills", "README.md"], - "query": "is:open", - "since_days": 14, - "max_items": 9, - "selector": "main" + "max_tokens_per_sync": 1000, + "max_cost_per_sync_usd": 0.5, + "sync_depth_days": 30, + "max_commits": 5, + "max_issues": 6, + "max_prs": 7 })) .expect("patch"); let updated = registry::update_source("src_repo", patch) @@ -4716,20 +4725,18 @@ async fn memory_sources_types_registry_and_sync_state_cover_public_persistence_e .expect("update repo source"); assert_eq!(updated.label, "Updated repo"); assert!(!updated.enabled); - assert_eq!(updated.toolkit.as_deref(), Some("github")); - assert_eq!(updated.connection_id.as_deref(), Some("conn_repo")); - assert_eq!(updated.path.as_deref(), Some("/tmp/repo")); - assert_eq!(updated.glob.as_deref(), Some("**/*.md")); assert_eq!( updated.url.as_deref(), Some("https://github.com/tinyhumansai/openhuman-skills") ); assert_eq!(updated.branch.as_deref(), Some("main")); assert_eq!(updated.paths, vec!["skills", "README.md"]); - assert_eq!(updated.query.as_deref(), Some("is:open")); - assert_eq!(updated.since_days, Some(14)); - assert_eq!(updated.max_items, Some(9)); - assert_eq!(updated.selector.as_deref(), Some("main")); + assert_eq!(updated.max_tokens_per_sync, Some(1000)); + assert_eq!(updated.max_cost_per_sync_usd, Some(0.5)); + assert_eq!(updated.sync_depth_days, Some(30)); + assert_eq!(updated.max_commits, Some(5)); + assert_eq!(updated.max_issues, Some(6)); + assert_eq!(updated.max_prs, Some(7)); let memory = Arc::new( MemoryClient::from_workspace_dir(tmp.path().join("memory-sync-state")) diff --git a/tests/raw_coverage/memory_tree_sync_deep_raw_coverage_e2e.rs b/tests/raw_coverage/memory_tree_sync_deep_raw_coverage_e2e.rs index bee2ea04ea..2342c7f2a9 100644 --- a/tests/raw_coverage/memory_tree_sync_deep_raw_coverage_e2e.rs +++ b/tests/raw_coverage/memory_tree_sync_deep_raw_coverage_e2e.rs @@ -169,6 +169,7 @@ fn seed_topic_summary( id: format!("tree:{summary_id}"), kind: TreeKind::Topic, scope: entity_id.to_string(), + ask: None, root_id: Some(summary_id.to_string()), max_level: 2, status: TreeStatus::Active, diff --git a/vendor/tinycortex b/vendor/tinycortex index 671e78a014..9a0603afbe 160000 --- a/vendor/tinycortex +++ b/vendor/tinycortex @@ -1 +1 @@ -Subproject commit 671e78a01411de5bcda8f3d1816ac6c67485d694 +Subproject commit 9a0603afbebac608eac2ca0fa606caecd31ed1e7 From 011257251ae75dc374442b3f1f07fd1b72df4836 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Thu, 16 Jul 2026 05:32:55 +0530 Subject: [PATCH 26/86] fix(agent): provider-aware super-context + delegation guardrails for local LLMs (#4361) (#4935) Co-authored-by: Claude --- .../agent/harness/session/turn/core.rs | 590 +++++++++++++++++- .../agents/orchestrator/prompt.rs | 128 +++- 2 files changed, 708 insertions(+), 10 deletions(-) diff --git a/src/openhuman/agent/harness/session/turn/core.rs b/src/openhuman/agent/harness/session/turn/core.rs index ecb1dca6b1..d27184d621 100644 --- a/src/openhuman/agent/harness/session/turn/core.rs +++ b/src/openhuman/agent/harness/session/turn/core.rs @@ -44,10 +44,231 @@ use std::hash::{Hash, Hasher}; /// persisted `content`, so `seed_resume_from_messages` can't drop it) — that /// is still a brand-new conversation and should get super context; AND /// - `enabled` — the `context.super_context_enabled` config flag is on. +/// - `user_message` — the request is not an obvious context-free greeting or +/// simple local action. Super context is useful when prior memory, profile, +/// integrations, or web facts can change the answer; it is counterproductive +/// for "hi"/"ciao" and straightforward local filesystem commands, where an +/// auto-run scout only adds tool noise before the orchestrator sees intent. +/// - `native_tool_calling` — provider-aware guardrail for #4361. Local +/// providers (Ollama / LM Studio / MLX / llama.cpp) force +/// `native_tool_calling=false`, so the harness serializes the **entire tool + +/// integration catalog as prose** into the system prompt (the +/// `PFormatToolDispatcher` path, `should_send_tool_specs()==false`). A weak +/// local model then over-selects from that text menu and mis-routes a +/// greeting into `composio_list_connections` or a local folder op into the +/// calendar path (the reported v0.58 regression). For these providers we only +/// auto-run the scout when the user *explicitly* asks for prior context or a +/// connected integration; otherwise the extra scout just enlarges the surface +/// the model can trip over. Native-tool-calling providers keep the broader +/// behavior — structured tool specs make spurious tool-routing far rarer. /// /// Pulled out as a pure function so the gate (in particular the resume and /// orchestrator guards) is unit-testable without a full agent turn harness. -fn should_run_super_context( +fn super_context_skip_reason( + user_message: &str, + native_tool_calling: bool, +) -> Option<&'static str> { + let normalized = user_message + .split_whitespace() + .collect::>() + .join(" ") + .trim_matches(|c: char| c.is_ascii_punctuation()) + .to_ascii_lowercase(); + + if normalized.is_empty() { + return Some("empty_message"); + } + + if matches!( + normalized.as_str(), + "hi" | "hello" + | "hey" + | "yo" + | "ciao" + | "hola" + | "bonjour" + | "thanks" + | "thank you" + | "ok" + | "okay" + | "good morning" + | "good afternoon" + | "good evening" + | "good night" + ) { + return Some("context_free_greeting"); + } + + let local_action_candidate = strip_local_folder_action_lead_in(&normalized); + let starts_like_local_folder_action = [ + // English + "create a folder ", + "create folder ", + "make a folder ", + "make folder ", + "create a directory ", + "create directory ", + "make a directory ", + "make directory ", + "mkdir ", + // Italian (#4361 repro: "Crea una cartella sul Desktop e chiamala + // PROVA"). Gemma-class local models mis-route the Italian phrasing into + // Calendar/Connections exactly as they do the English one, so the same + // folder-op suppression must be language-aware for the reported locale. + "crea una cartella ", + "crea la cartella ", + "crea cartella ", + "creare una cartella ", + "creare cartella ", + "crea una directory ", + "crea directory ", + "fai una cartella ", + "fammi una cartella ", + "nuova cartella ", + ] + .iter() + .any(|prefix| local_action_candidate.starts_with(prefix)); + + if starts_like_local_folder_action { + let context_hints = [ + "discussed", + "mentioned", + "previous", + "earlier", + "last time", + "email", + "gmail", + "calendar", + "slack", + "notion", + "github", + "drive", + // Italian cues, mirroring the Italian folder-op detection above so a + // folder request that *does* reference prior context or an + // integration still earns a scout (#4361). + "discusso", + "parlato", + "calendario", + "precedente", + ]; + if !context_hints.iter().any(|hint| normalized.contains(hint)) { + return Some("simple_local_filesystem_action"); + } + } + + // Provider-aware guardrail (#4361). On providers without native tool calling + // the whole tool/integration catalog is injected as prose, so an unrequested + // first-turn scout is exactly what tips a small local model into spurious + // integration tool-calls. Suppress super context for these providers unless + // the prompt explicitly references prior context or a connected integration + // (e.g. "show my connections", "schedule a meeting tomorrow at 3"), where the + // scout genuinely helps. Native-tool-calling providers are unaffected. + if !native_tool_calling && !mentions_context_or_integration(&normalized) { + return Some("non_native_provider_no_explicit_intent"); + } + + None +} + +/// True when a first-turn prompt explicitly references prior conversation +/// context or a connected integration — the cases where a context scout earns +/// its cost. Used to keep super context ON for these prompts even on local +/// (non-native-tool-calling) providers, where it is otherwise suppressed to +/// avoid tipping weak models into spurious integration tool-calls (#4361). +/// +/// `normalized` must already be whitespace-collapsed, punctuation-trimmed, and +/// lowercased by `super_context_skip_reason`. +fn mentions_context_or_integration(normalized: &str) -> bool { + const INTENT_HINTS: [&str; 37] = [ + // prior-conversation / memory cues + "discussed", + "mentioned", + "previous", + "earlier", + "last time", + "remember", + "we talked", + "you said", + "my profile", + // connection / integration cues + "connection", + "connections", + "integration", + "integrations", + "connected", + // calendar / scheduling + "calendar", + "schedule", + "meeting", + "reminder", + "remind me", + "event", + // mail + "email", + "gmail", + "inbox", + // common connected apps + "slack", + "notion", + "github", + "drive", + "linkedin", + "whatsapp", + "telegram", + // Italian cues (#4361) — keep super context ON for an explicit + // context/integration ask in the reported locale. "calendario"/"email"/ + // "gmail" already match via substring, so only the non-overlapping stems + // are listed here. Additive only: these can never *cause* a skip. + "connessione", // connection + "connessioni", // connections + "integrazione", // integration + "riunione", // meeting + "promemoria", // reminder + "ricordami", // remind me + "agenda", // agenda / calendar + ]; + INTENT_HINTS.iter().any(|hint| normalized.contains(hint)) +} + +fn strip_local_folder_action_lead_in(message: &str) -> &str { + let mut candidate = message; + for _ in 0..3 { + let trimmed = candidate.trim_start(); + let next = [ + // English + "can you please ", + "could you please ", + "would you please ", + "can you ", + "could you ", + "would you ", + "please ", + "hey ", + "hello ", + "hi ", + // Italian (#4361) + "puoi per favore ", + "potresti per favore ", + "puoi per piacere ", + "puoi ", + "potresti ", + "per favore ", + "per piacere ", + "ciao ", + "ehi ", + ] + .iter() + .find_map(|lead_in| trimmed.strip_prefix(lead_in)); + + match next { + Some(rest) => candidate = rest, + None => return trimmed, + } + } + candidate.trim_start() +} + +fn super_context_base_gate( is_orchestrator: bool, first_turn: bool, has_prior_conversation: bool, @@ -56,6 +277,18 @@ fn should_run_super_context( is_orchestrator && first_turn && !has_prior_conversation && enabled } +fn should_run_super_context( + is_orchestrator: bool, + first_turn: bool, + has_prior_conversation: bool, + enabled: bool, + native_tool_calling: bool, + user_message: &str, +) -> bool { + super_context_base_gate(is_orchestrator, first_turn, has_prior_conversation, enabled) + && super_context_skip_reason(user_message, native_tool_calling).is_none() +} + // `parse_context_bundle_has_enough_context` moved to // `tinyagents::middleware` alongside the `SuperContextMiddleware` graph node // that now owns the first-turn context-collection pass (#4249). @@ -631,6 +864,25 @@ impl Agent { .cached_transcript_messages .as_ref() .is_some_and(|msgs| msgs.iter().any(|m| m.role == "assistant")); + // `should_send_tool_specs()` is true only when the provider receives a + // structured tool schema (native tool calling). For local providers it + // is false — the whole tool/integration catalog is prose in the prompt, + // which is the surface a weak model mis-routes on (#4361). + let native_tool_calling = self.tool_dispatcher.should_send_tool_specs(); + let skip_reason_for_logging = super_context_skip_reason(user_message, native_tool_calling); + let base_gate = super_context_base_gate( + self.agent_definition_id == "orchestrator", + first_turn, + has_prior_conversation, + self.context.super_context_enabled(), + ); + if base_gate { + if let Some(reason) = skip_reason_for_logging { + log::info!( + "[agent_loop] super_context skipped for context-free first turn reason={reason} native_tool_calling={native_tool_calling}" + ); + } + } // The scout no longer runs here imperatively: super context is now a // before_model **graph node** (`SuperContextMiddleware`, installed via // `context_mw.super_context` below). It runs the read-only `context_scout` @@ -643,6 +895,8 @@ impl Agent { first_turn, has_prior_conversation, self.context.super_context_enabled(), + native_tool_calling, + user_message, ); if run_super_context { log::info!( @@ -1414,24 +1668,55 @@ impl Agent { #[cfg(test)] mod super_context_gate_tests { - use super::{render_agent_context_status_note, should_run_super_context}; + use super::{ + mentions_context_or_integration, render_agent_context_status_note, + should_run_super_context, super_context_skip_reason, + }; use crate::openhuman::agent::harness::AgentContextPreparedSource; + // Provider-aware convenience: `native_tool_calling` (5th arg) is `true` for + // these tests unless a case is specifically exercising the local + // (non-native-tool-calling) provider path (#4361). Native providers keep the + // original #4361/#4403 gate semantics. + const NATIVE: bool = true; + const LOCAL: bool = false; + #[test] fn runs_only_on_first_turn_of_a_new_orchestrator_thread_when_enabled() { // Orchestrator, new thread, first turn, flag on → run. - assert!(should_run_super_context(true, true, false, true)); + assert!(should_run_super_context( + true, + true, + false, + true, + NATIVE, + "find the project we discussed yesterday" + )); } #[test] fn skips_when_flag_disabled() { - assert!(!should_run_super_context(true, true, false, false)); + assert!(!should_run_super_context( + true, + true, + false, + false, + NATIVE, + "find the project we discussed yesterday" + )); } #[test] fn skips_on_later_turns() { // history non-empty → not the first turn. - assert!(!should_run_super_context(true, false, false, true)); + assert!(!should_run_super_context( + true, + false, + false, + true, + NATIVE, + "find the project we discussed yesterday" + )); } #[test] @@ -1440,7 +1725,14 @@ mod super_context_gate_tests { // `first_turn` is true) but a seeded prefix that includes a prior // assistant reply. Super context must NOT re-fire on these existing // conversations. - assert!(!should_run_super_context(true, true, true, true)); + assert!(!should_run_super_context( + true, + true, + true, + true, + NATIVE, + "find the project we discussed yesterday" + )); } #[test] @@ -1449,7 +1741,14 @@ mod super_context_gate_tests { // persisted *user* row (no assistant reply), so `has_prior_conversation` // is false. That is still a brand-new conversation — super context // should run. - assert!(should_run_super_context(true, true, false, true)); + assert!(should_run_super_context( + true, + true, + false, + true, + NATIVE, + "[IMAGE: screenshot.png]\nwhat is this?" + )); } #[test] @@ -1458,7 +1757,282 @@ mod super_context_gate_tests { // `run_single()` flows (goals enrichment, cron/task agents, // specialist sub-agents). Even on a fresh first turn with the flag on, // super context must only run for the user-facing orchestrator. - assert!(!should_run_super_context(false, true, false, true)); + assert!(!should_run_super_context( + false, + true, + false, + true, + NATIVE, + "find the project we discussed yesterday" + )); + } + + #[test] + fn skips_context_free_greeting() { + // #4361 case: a bare greeting must never trigger a scout — on any + // provider. "Ciao" is the exact prompt from the issue report. + for native in [NATIVE, LOCAL] { + assert!(!should_run_super_context( + true, true, false, true, native, "Ciao" + )); + assert!(!should_run_super_context( + true, true, false, true, native, "hello!" + )); + assert_eq!( + super_context_skip_reason("Ciao", native), + Some("context_free_greeting") + ); + } + } + + #[test] + fn skips_simple_local_folder_creation() { + // #4361 case: "create a folder on Desktop called test" must not browse + // Calendar/Connections — on any provider. + for native in [NATIVE, LOCAL] { + assert!( + !should_run_super_context( + true, + true, + false, + true, + native, + "Create a folder on Desktop called test" + ), + "expected skip for local folder creation (native={native})" + ); + assert!(!should_run_super_context( + true, + true, + false, + true, + native, + "Create a folder on Desktop named PROVA" + )); + } + } + + #[test] + fn skips_simple_local_folder_creation_with_polite_lead_in() { + for message in [ + "Please create a folder on Desktop named PROVA", + "Can you make a directory named invoices", + "Hey please create folder screenshots", + ] { + assert!( + !should_run_super_context(true, true, false, true, NATIVE, message), + "expected super context to skip for {message:?}" + ); + } + } + + #[test] + fn keeps_super_context_for_local_action_with_context_hint() { + assert!(should_run_super_context( + true, + true, + false, + true, + NATIVE, + "Create a folder for the project we discussed yesterday" + )); + assert!(should_run_super_context( + true, + true, + false, + true, + NATIVE, + "Please create a folder for the project we discussed yesterday" + )); + } + + #[test] + fn skips_italian_local_folder_creation() { + // #4361 exact repro: the Italian folder op must be recognized as a + // simple local filesystem action and NOT trigger a Calendar/Connections + // scout — on any provider. This is the string from the issue report. + for native in [NATIVE, LOCAL] { + assert!( + !should_run_super_context( + true, + true, + false, + true, + native, + "Crea una cartella sul Desktop e chiamala PROVA" + ), + "expected skip for Italian folder creation (native={native})" + ); + // The reason must be the language-agnostic filesystem classification, + // not the provider fallback — so it holds even for native providers. + assert_eq!( + super_context_skip_reason("Crea una cartella sul Desktop e chiamala PROVA", NATIVE), + Some("simple_local_filesystem_action") + ); + } + } + + #[test] + fn skips_italian_local_folder_creation_variants_and_polite_lead_ins() { + for message in [ + "Crea una cartella chiamata PROVA", + "Crea cartella screenshots", + "Creare una cartella sul desktop", + "Fai una cartella per le fatture", + "Nuova cartella documenti", + "Puoi creare una cartella chiamata PROVA", + "Per favore crea una cartella sul Desktop", + "Ciao puoi creare una cartella chiamata test", + ] { + assert_eq!( + super_context_skip_reason(message, NATIVE), + Some("simple_local_filesystem_action"), + "expected filesystem skip for {message:?}" + ); + } + } + + #[test] + fn keeps_super_context_for_italian_local_action_with_context_hint() { + // An Italian folder op that references prior context / an integration + // should still earn a scout — the filesystem shortcut must not swallow + // it. Verified on a native provider (local providers add the separate + // provider gate, exercised elsewhere). + assert!(should_run_super_context( + true, + true, + false, + true, + NATIVE, + "Crea una cartella per il progetto di cui abbiamo parlato" + )); + assert_eq!( + super_context_skip_reason("Crea una cartella per la riunione in calendario", NATIVE), + None + ); + } + + // ── Provider-aware guardrail (#4361) ──────────────────────────────────── + // On providers without native tool calling (Ollama / LM Studio / MLX / + // llama.cpp) the whole tool + integration catalog is injected as prose, so + // an unrequested first-turn scout is what tips weak models into spurious + // integration tool-calls. For these providers super context runs ONLY when + // the prompt explicitly references prior context or a connected integration. + + #[test] + fn local_provider_skips_super_context_for_generic_prompt() { + // A generic first-turn ask with no context/integration cue: a native + // provider still scouts (broad behavior preserved), but a local provider + // suppresses it to keep the prose tool menu from mis-routing. + let msg = "write me a short poem about the sea"; + assert!(should_run_super_context( + true, true, false, true, NATIVE, msg + )); + assert!(!should_run_super_context( + true, true, false, true, LOCAL, msg + )); + assert_eq!( + super_context_skip_reason(msg, LOCAL), + Some("non_native_provider_no_explicit_intent") + ); + assert_eq!(super_context_skip_reason(msg, NATIVE), None); + } + + #[test] + fn keeps_super_context_for_explicit_connection_intent() { + // #4361 case: "show my connections" is an explicit integration ask → + // super context is allowed on BOTH provider kinds. + let msg = "show my connections"; + assert!(should_run_super_context( + true, true, false, true, NATIVE, msg + )); + assert!(should_run_super_context( + true, true, false, true, LOCAL, msg + )); + assert_eq!(super_context_skip_reason(msg, LOCAL), None); + } + + #[test] + fn keeps_super_context_for_explicit_scheduling_intent() { + // #4361 case: "schedule a meeting tomorrow at 3" is an explicit + // integration ask → super context is allowed when integrations are + // enabled (the `enabled` flag), on BOTH provider kinds. + let msg = "schedule a meeting tomorrow at 3"; + assert!(should_run_super_context( + true, true, false, true, NATIVE, msg + )); + assert!(should_run_super_context( + true, true, false, true, LOCAL, msg + )); + // Still gated by the config flag: disabled ⇒ no scout regardless. + assert!(!should_run_super_context( + true, true, false, false, LOCAL, msg + )); + } + + #[test] + fn local_provider_keeps_super_context_for_explicit_italian_integration_intent() { + // Fix B, locale-aware: an explicit Italian integration ask must keep the + // scout ON even on a local (non-native-tool-calling) provider — the + // provider gate only suppresses *context-free* first turns. A generic + // Italian prompt with no cue is still suppressed on local providers. + for msg in [ + "mostra le mie connessioni", // show my connections + "fissa una riunione domani", // schedule a meeting tomorrow + "aggiungilo al mio calendario", // add it to my calendar + "controlla la mia email", // check my email + ] { + assert!( + should_run_super_context(true, true, false, true, LOCAL, msg), + "expected local provider to keep super context for {msg:?}" + ); + assert_eq!(super_context_skip_reason(msg, LOCAL), None); + } + + // A generic Italian ask (a poem) carries no integration cue → the local + // provider still suppresses it, while a native provider scouts. + let generic = "scrivimi una breve poesia sul mare"; + assert!(should_run_super_context( + true, true, false, true, NATIVE, generic + )); + assert_eq!( + super_context_skip_reason(generic, LOCAL), + Some("non_native_provider_no_explicit_intent") + ); + } + + #[test] + fn mentions_context_or_integration_matches_expected_cues() { + for hit in [ + "show my connections", + "schedule a meeting tomorrow at 3", + "check my gmail inbox", + "what did we discuss earlier", + "add it to my calendar", + "post this to slack", + // Italian cues (#4361) + "mostra le mie connessioni", + "fissa una riunione domani", + "aggiungilo al mio calendario", + "impostami un promemoria", + "controlla la mia email", + ] { + assert!( + mentions_context_or_integration(hit), + "expected context/integration cue in {hit:?}" + ); + } + for miss in [ + "write me a short poem about the sea", + "what is 2 plus 2", + "translate hola to english", + "scrivimi una breve poesia sul mare", + ] { + assert!( + !mentions_context_or_integration(miss), + "did not expect a cue in {miss:?}" + ); + } } #[test] diff --git a/src/openhuman/agent_registry/agents/orchestrator/prompt.rs b/src/openhuman/agent_registry/agents/orchestrator/prompt.rs index 83248b1059..57ff5e8f87 100644 --- a/src/openhuman/agent_registry/agents/orchestrator/prompt.rs +++ b/src/openhuman/agent_registry/agents/orchestrator/prompt.rs @@ -13,6 +13,7 @@ use crate::openhuman::context::prompt::{ render_datetime, render_tools, render_user_files, ConnectedIntegration, PromptContext, + ToolCallFormat, }; use crate::openhuman::skills::ops_types::Workflow; use crate::openhuman::tools::orchestrator_tools::sanitise_slug; @@ -44,7 +45,7 @@ pub fn build(ctx: &PromptContext<'_>) -> Result { out.push_str("\n\n"); } - let integrations = render_delegation_guide(ctx.connected_integrations); + let integrations = render_delegation_guide(ctx.connected_integrations, ctx.tool_call_format); if !integrations.trim().is_empty() { out.push_str(integrations.trim_end()); out.push_str("\n\n"); @@ -230,7 +231,22 @@ fn format_connected_mcp_block( /// `sanitise_slug` function that `collect_orchestrator_tools` uses when /// synthesising the real tool objects, so the names in the prompt always /// match the names in the function-calling schema. -fn render_delegation_guide(integrations: &[ConnectedIntegration]) -> String { +/// +/// `tool_call_format` lets the guide adapt to the active provider. Providers +/// with native structured tool-calling (`ToolCallFormat::Native`) get the +/// historic guide unchanged. Text-protocol providers (`PFormat`/`Json`) — the +/// dispatcher chosen for models that force `native_tool_calling = false`, i.e. +/// local runtimes like Ollama / LM Studio / MLX / llama.cpp — additionally get +/// an explicit "when NOT to delegate" carve-out. Weak local models over-select +/// from the prose tool catalogue and the coercive "you MUST delegate" wording, +/// spuriously routing greetings and local-filesystem actions into +/// `delegate_to_integrations_agent` (issue #4361: "Ciao" → Connections, +/// "create a folder on Desktop" → Calendar). The carve-out is additive: the +/// always-delegate contract for genuine service requests is preserved. +fn render_delegation_guide( + integrations: &[ConnectedIntegration], + tool_call_format: ToolCallFormat, +) -> String { let connected: Vec<&ConnectedIntegration> = integrations.iter().filter(|ci| ci.connected).collect(); tracing::debug!( @@ -336,6 +352,36 @@ fn render_delegation_guide(integrations: &[ConnectedIntegration]) -> String { quoting any past capability claim. Never echo a stale \"I can't\" \ without re-checking.\n\n", ); + + // Provider-aware guardrail (#4361). Native-tool-calling providers keep the + // guide byte-identical. Text-protocol providers (PFormat/Json) — the + // dispatcher used when a model forces `native_tool_calling = false`, i.e. + // local runtimes (Ollama / LM Studio / MLX / llama.cpp) — see the whole + // tool catalogue rendered as prose and are steered by the coercive "you + // MUST delegate" wording above. Weaker local models then route obviously + // non-integration requests (greetings, local folder/file actions) into + // `delegate_to_integrations_agent`, which surfaces "Viewing your + // Connections" / calendar mis-maps. Carve those cases out explicitly so a + // small model does not have to infer them from the coercive block alone. + if tool_call_format != ToolCallFormat::Native { + out.push_str( + "### When NOT to delegate\n\n\ + Some requests are NOT integration work — handle them directly and do NOT call \ + `delegate_to_integrations_agent`:\n\ + - **Greetings and small talk** (\"hi\", \"hello\", \"ciao\", \"thanks\", \"how are \ + you?\") — just reply.\n\ + - **Local-machine actions**: creating, reading, writing, moving, or listing files \ + and folders on this computer (e.g. \"create a folder on the Desktop\", \"make a \ + directory\", \"save this to a file\") — use your local filesystem tools. A local \ + folder/file request is NOT a Calendar, Drive, or any connected-service request.\n\n\ + Delegate ONLY when the request clearly names or operates on one of the connected \ + services listed above (its email, calendar, messages, documents, etc.). When a \ + request mixes a local action with a connected service (\"save my latest email to a \ + file on the Desktop\"), do the local part directly and delegate only the \ + service part.\n\n", + ); + } + tracing::debug!( section_len = out.len(), "[delegation-guide] section emitted ({} bytes)", @@ -686,6 +732,84 @@ mod tests { assert!(!body.contains("spawn_subagent(agent_id=\"integrations_agent\"")); } + fn gmail_only() -> Vec { + vec![ConnectedIntegration { + toolkit: "gmail".into(), + description: "Email access.".into(), + tools: Vec::new(), + gated_tools: Vec::new(), + connected: true, + connections: Vec::new(), + non_active_status: None, + }] + } + + // Regression for #4361: on local providers (`native_tool_calling = false` + // → PFormat/Json dispatcher) the whole tool catalogue is prose and weak + // models mis-route trivial requests through the integrations delegate + // ("Ciao" → Connections, "create a folder on Desktop" → Calendar). The + // delegation guide must add an explicit non-delegation carve-out for those + // text-protocol providers. + #[test] + fn delegation_guide_adds_local_guardrail_for_text_protocol() { + let integrations = gmail_only(); + for format in [ToolCallFormat::PFormat, ToolCallFormat::Json] { + let guide = render_delegation_guide(&integrations, format); + assert!( + guide.contains("### When NOT to delegate"), + "text-protocol ({format:?}) guide must carve out non-integration work" + ); + // The two reported failure modes are named explicitly. + assert!( + guide.contains("create a folder on the Desktop"), + "guardrail must keep local folder/file actions off delegation ({format:?})" + ); + assert!( + guide.to_ascii_lowercase().contains("greetings"), + "guardrail must keep greetings off delegation ({format:?})" + ); + // Additive: the always-delegate contract for real service requests + // is preserved — the guardrail narrows, it does not remove it. + assert!( + guide.contains( + "Never claim you cannot access a connected service without first attempting delegation" + ), + "always-delegate contract must remain for genuine service asks ({format:?})" + ); + } + } + + // Native structured-tool-calling providers (cloud) keep the historic guide + // byte-for-byte: no over-delegation problem, so no carve-out. + #[test] + fn delegation_guide_omits_local_guardrail_for_native() { + let guide = render_delegation_guide(&gmail_only(), ToolCallFormat::Native); + assert!(guide.contains("## Connected Integrations")); + assert!( + !guide.contains("### When NOT to delegate"), + "native providers must keep the delegation guide unchanged" + ); + assert!(guide.contains( + "Never claim you cannot access a connected service without first attempting delegation" + )); + } + + // With no connected integrations the section is omitted for every format — + // the guardrail must never resurrect an otherwise-empty block. + #[test] + fn delegation_guide_empty_without_connections_for_all_formats() { + for format in [ + ToolCallFormat::PFormat, + ToolCallFormat::Json, + ToolCallFormat::Native, + ] { + assert!( + render_delegation_guide(&[], format).is_empty(), + "empty connections must omit the section ({format:?})" + ); + } + } + #[test] fn build_hides_unconnected_integrations() { // Only connected toolkits make it into the Delegation Guide From 91b92195a971c59246523cd70ecb0e3d7565955f Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Thu, 16 Jul 2026 05:33:09 +0530 Subject: [PATCH 27/86] feat(chat): stop-response button + ESC to interrupt and re-edit prompt (#4898) --- .../features/conversations/Conversations.tsx | 123 +++++++++- 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 + .../__tests__/Conversations.render.test.tsx | 222 +++++++++++++++++- 16 files changed, 354 insertions(+), 5 deletions(-) diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index 7d71112992..ee867f98dd 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -115,6 +115,7 @@ import { import { useAppDispatch, useAppSelector } from '../../store/hooks'; import { selectSocketStatus } from '../../store/socketSelectors'; import { + addInferenceResponse, addMessageLocal, clearThreadInferenceActive, createNewThread, @@ -525,6 +526,11 @@ const Conversations = ({ const textInputRef = useRef(null); const composerFooterRef = useRef(null); const isComposingTextRef = useRef(false); + // One-shot guard for the Stop/ESC partial-preservation path (#4862): request + // ids whose partial reply has already been persisted, so a repeated Stop/ESC + // fired before the `cancelled` event clears the live stream can't append the + // same partial twice. + const stoppedRequestIdsRef = useRef>(new Set()); // Threads with an in-flight send, guarding against double-submit to the SAME // thread. Per-thread (a Set) so a send to thread B isn't blocked by an // in-flight send to thread A. @@ -1408,11 +1414,63 @@ const Conversations = ({ selectedThreadActive ? handleSendFollowup(text) : handleSendMessage(text); // Cancel the in-flight turn for the selected thread. Shared by the in-composer - // Stop button (text mode) and the footer Cancel control (mic-cloud / voice - // modes) so the cancel path lives in one place. + // Stop button (text mode), the ESC-to-interrupt shortcut, and the footer + // Cancel control (mic-cloud / voice modes) so the cancel path lives in one + // place. + // + // Any assistant text already streamed for this turn is persisted as its own + // message flagged `stopped: true` so the partial output stays in the + // transcript (clearly marked) instead of vanishing when the `cancelled` + // chat_error clears the live streaming preview (#4862). The matching + // `onError` path deliberately appends no message for `cancelled`, so this can + // never double-render the partial reply. + // + // Persistence is gated on the cancel actually being accepted: `chatCancel` + // resolves `false` (no throw) when the socket is down or the RPC is rejected, + // and in that case the original turn may keep running and later append its + // own final response — so persisting a partial here would leave a + // misleading/duplicate bubble. On failure we release the one-shot claim so a + // retry can still preserve the partial once cancellation succeeds. const handleStopGeneration = useCallback(() => { - if (selectedThreadId) void chatCancel(selectedThreadId); - }, [selectedThreadId]); + if (!selectedThreadId) { + debug('[chat] stop generation: no selected thread — noop'); + return; + } + const threadId = selectedThreadId; + const streaming = streamingAssistantByThread[threadId]; + const partial = streaming?.content ?? ''; + const requestId = streaming?.requestId; + // Claim the turn synchronously so a second Stop/ESC in the same tick (before + // the cancel round-trips) can't queue a duplicate persist. + const shouldPersist = + partial.trim().length > 0 && (!requestId || !stoppedRequestIdsRef.current.has(requestId)); + if (shouldPersist && requestId) stoppedRequestIdsRef.current.add(requestId); + debug( + '[chat] stop generation: thread=%s request=%s partialLen=%d willPersist=%s', + threadId, + requestId ?? 'none', + partial.trim().length, + shouldPersist + ); + void chatCancel(threadId).then(cancelled => { + debug('[chat] stop generation: chatCancel thread=%s ok=%s', threadId, cancelled); + if (!cancelled) { + // Cancel not accepted: don't leave a misleading partial, and release the + // claim so a later Stop/ESC can persist once cancellation goes through. + if (shouldPersist && requestId) stoppedRequestIdsRef.current.delete(requestId); + return; + } + if (shouldPersist) { + void dispatch( + addInferenceResponse({ + content: partial, + threadId, + extraMetadata: { stopped: true, ...(requestId ? { requestId } : {}) }, + }) + ).then(() => debug('[chat] stop generation: persisted stopped reply thread=%s', threadId)); + } + }); + }, [selectedThreadId, streamingAssistantByThread, dispatch]); const transcribeAndSendAudio = async (mimeType: string) => { setIsRecording(false); @@ -1587,6 +1645,47 @@ const Conversations = ({ const handleInputKeyDown = (e: React.KeyboardEvent) => { if (isComposingTextRef.current || isImeCompositionKeyEvent(e)) return; + // ESC while the selected thread is streaming interrupts the turn AND + // restores the user's last prompt into the composer for re-editing in + // place (#4862). Interrupt always fires; the prompt is only re-hydrated + // when the composer is empty so a follow-up the user already started + // typing is never clobbered. When nothing is streaming, ESC is left to its + // default behaviour (blur / no-op). + if (e.key === 'Escape' && selectedThreadActive) { + e.preventDefault(); + const composerEmpty = inputValue.trim().length === 0; + debug( + '[chat] esc interrupt: thread=%s composerEmpty=%s', + selectedThreadId ?? 'none', + composerEmpty + ); + handleStopGeneration(); + if (composerEmpty) { + // Restore the last *visible* user prompt (hidden system/injected + // messages are excluded here to match how the transcript is rendered). + const lastUserMessage = [...messages] + .reverse() + .find(m => m.sender === 'user' && !m.extraMetadata?.hidden); + const restored = lastUserMessage + ? parseMessageImages(lastUserMessage.content ?? '').text + : ''; + if (restored.length > 0) { + debug('[chat] esc interrupt: restored prompt len=%d', restored.length); + setInputValue(restored); + // Drop any stale inline ghost-completion so it doesn't reappear over + // the freshly restored prompt. + setInlineSuggestionValue(''); + window.requestAnimationFrame(() => { + const ta = textInputRef.current; + if (!ta) return; + ta.focus(); + ta.setSelectionRange(restored.length, restored.length); + }); + } + } + return; + } + const inlineSuffix = getInlineCompletionSuffix(inputValue, inlineSuggestionValue); const textarea = e.currentTarget; const caretAtEnd = @@ -2254,6 +2353,22 @@ const Conversations = ({ ); })()}
+ {/* 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; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 9488af5f37..774d76d28c 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -920,6 +920,7 @@ const messages: TranslationMap = { 'chat.typeMessage': 'كيف يمكنني مساعدتك اليوم؟', 'chat.send': 'إرسال الرسالة', 'chat.stopGeneration': 'إيقاف التوليد', + 'chat.stoppedByUser': 'تم الإيقاف', 'chat.parallelBranchHint': 'فرع متوازٍ: ⌘/Ctrl+Enter للإرسال', 'chat.followupHint': 'أضِف متابعة إلى القائمة: تُرسَل بعد هذا الرد · ⌘/Ctrl+Enter لفرع متوازٍ', 'chat.queuedFollowups.label': 'متابعات في قائمة الانتظار', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 601efd3ced..4e4cae34bc 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -942,6 +942,7 @@ const messages: TranslationMap = { 'chat.typeMessage': 'আজ আমি আপনাকে কীভাবে সাহায্য করতে পারি?', 'chat.send': 'বার্তা পাঠান', 'chat.stopGeneration': 'জেনারেশন বন্ধ করুন', + 'chat.stoppedByUser': 'থামানো হয়েছে', 'chat.parallelBranchHint': 'সমান্তরাল শাখা টাইপ করুন: পাঠাতে ⌘/Ctrl+Enter', 'chat.followupHint': 'একটি ফলো-আপ সারিবদ্ধ করুন: এই উত্তরের পরে পাঠানো হবে · সমান্তরাল শাখার জন্য ⌘/Ctrl+Enter', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 09ea9cdfe7..ca9341ec85 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -981,6 +981,7 @@ const messages: TranslationMap = { 'chat.typeMessage': 'Wie kann ich dir heute helfen?', 'chat.send': 'Nachricht senden', 'chat.stopGeneration': 'Generierung stoppen', + 'chat.stoppedByUser': 'Gestoppt', 'chat.parallelBranchHint': 'Parallelen Zweig eingeben: ⌘/Strg+Enter zum Senden', 'chat.followupHint': 'Folgenachricht einreihen: wird nach dieser Antwort gesendet · ⌘/Strg+Enter für parallelen Zweig', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index d74883b67e..ab2fe44826 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -742,6 +742,7 @@ const en: TranslationMap = { 'chat.typeMessage': 'How can I help you today?', 'chat.send': 'Send message', 'chat.stopGeneration': 'Stop generating', + 'chat.stoppedByUser': 'Stopped', 'chat.parallelBranchHint': 'Type a parallel branch: ⌘/Ctrl+Enter to send', 'chat.followupHint': 'Queue a follow-up: sent after this reply · ⌘/Ctrl+Enter for a parallel branch', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 45921f527a..0531472b6c 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -966,6 +966,7 @@ const messages: TranslationMap = { 'chat.typeMessage': '¿En qué puedo ayudarte hoy?', 'chat.send': 'Enviar mensaje', 'chat.stopGeneration': 'Detener generación', + 'chat.stoppedByUser': 'Detenido', 'chat.parallelBranchHint': 'Escribe una rama paralela: ⌘/Ctrl+Enter para enviar', 'chat.followupHint': 'Pon en cola un seguimiento: se envía tras esta respuesta · ⌘/Ctrl+Enter para una rama paralela', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index e3a8e1d5d6..c74cc065c8 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -979,6 +979,7 @@ const messages: TranslationMap = { 'chat.typeMessage': "Comment puis-je t'aider aujourd'hui ?", 'chat.send': 'Envoyer le message', 'chat.stopGeneration': 'Arrêter la génération', + 'chat.stoppedByUser': 'Arrêté', 'chat.parallelBranchHint': 'Saisir une branche parallèle: ⌘/Ctrl+Entrée pour envoyer', 'chat.followupHint': 'Mettre un suivi en file: envoyé après cette réponse · ⌘/Ctrl+Entrée pour une branche parallèle', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 73799ce912..22373b5797 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -941,6 +941,7 @@ const messages: TranslationMap = { 'chat.typeMessage': 'आज मैं आपकी कैसे मदद कर सकता हूँ?', 'chat.send': 'मैसेज भेजें', 'chat.stopGeneration': 'जेनरेशन रोकें', + 'chat.stoppedByUser': 'रोक दिया गया', 'chat.parallelBranchHint': 'समानांतर शाखा टाइप करें: भेजने के लिए ⌘/Ctrl+Enter', 'chat.followupHint': 'फ़ॉलो-अप कतार में लगाएँ: इस उत्तर के बाद भेजा जाएगा · समानांतर शाखा के लिए ⌘/Ctrl+Enter', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index e18630330a..2b75d3d777 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -953,6 +953,7 @@ const messages: TranslationMap = { 'chat.typeMessage': 'Apa yang bisa saya bantu hari ini?', 'chat.send': 'Kirim pesan', 'chat.stopGeneration': 'Hentikan pembuatan', + 'chat.stoppedByUser': 'Dihentikan', 'chat.parallelBranchHint': 'Ketik cabang paralel: ⌘/Ctrl+Enter untuk mengirim', 'chat.followupHint': 'Antrekan tindak lanjut: dikirim setelah balasan ini · ⌘/Ctrl+Enter untuk cabang paralel', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 55ff7e2113..c78f81a6d7 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -968,6 +968,7 @@ const messages: TranslationMap = { 'chat.typeMessage': 'Come posso aiutarti oggi?', 'chat.send': 'Invia messaggio', 'chat.stopGeneration': 'Interrompi generazione', + 'chat.stoppedByUser': 'Interrotto', 'chat.parallelBranchHint': 'Digita un ramo parallelo: ⌘/Ctrl+Invio per inviare', 'chat.followupHint': 'Metti in coda un follow-up: inviato dopo questa risposta · ⌘/Ctrl+Invio per un ramo parallelo', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 3ad8643e37..655b19dbfa 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -934,6 +934,7 @@ const messages: TranslationMap = { 'chat.typeMessage': '오늘 무엇을 도와드릴까요?', 'chat.send': '메시지 보내기', 'chat.stopGeneration': '생성 중지', + 'chat.stoppedByUser': '중지됨', 'chat.parallelBranchHint': '병렬 분기 입력: 보내려면 ⌘/Ctrl+Enter', 'chat.followupHint': '후속 메시지를 대기열에 추가: 이 응답 후 전송 · 병렬 분기는 ⌘/Ctrl+Enter', 'chat.queuedFollowups.label': '대기 중인 후속 메시지', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index f67a8ffaa7..34f13041c8 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -959,6 +959,7 @@ const messages: TranslationMap = { 'chat.typeMessage': 'Jak mogę ci dziś pomóc?', 'chat.send': 'Wyślij wiadomość', 'chat.stopGeneration': 'Zatrzymaj generowanie', + 'chat.stoppedByUser': 'Zatrzymano', 'chat.parallelBranchHint': 'Wpisz równoległą gałąź: ⌘/Ctrl+Enter, aby wysłać', 'chat.followupHint': 'Dodaj wiadomość uzupełniającą do kolejki: wyślemy po tej odpowiedzi · ⌘/Ctrl+Enter dla równoległej gałęzi', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 76ee0c2b12..1703c2632c 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -962,6 +962,7 @@ const messages: TranslationMap = { 'chat.typeMessage': 'Como posso ajudá-lo hoje?', 'chat.send': 'Enviar mensagem', 'chat.stopGeneration': 'Parar geração', + 'chat.stoppedByUser': 'Interrompido', 'chat.parallelBranchHint': 'Digite uma ramificação paralela: ⌘/Ctrl+Enter para enviar', 'chat.followupHint': 'Enfileirar um acompanhamento: enviado após esta resposta · ⌘/Ctrl+Enter para uma ramificação paralela', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index e3c1319a08..82cca0d05e 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -953,6 +953,7 @@ const messages: TranslationMap = { 'chat.typeMessage': 'Чем я могу помочь тебе сегодня?', 'chat.send': 'Отправить сообщение', 'chat.stopGeneration': 'Остановить генерацию', + 'chat.stoppedByUser': 'Остановлено', 'chat.parallelBranchHint': 'Введите параллельную ветку: ⌘/Ctrl+Enter для отправки', 'chat.followupHint': 'Поставить продолжение в очередь: отправится после этого ответа · ⌘/Ctrl+Enter для параллельной ветки', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 2a411462b3..d8c5969d54 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -889,6 +889,7 @@ const messages: TranslationMap = { 'chat.typeMessage': '今天我能帮您做什么?', 'chat.send': '发送', 'chat.stopGeneration': '停止生成', + 'chat.stoppedByUser': '已停止', 'chat.parallelBranchHint': '输入并行分支:⌘/Ctrl+Enter 发送', 'chat.followupHint': '将后续消息加入队列:将在本次回复后发送 · ⌘/Ctrl+Enter 开启并行分支', 'chat.queuedFollowups.label': '排队的后续消息', diff --git a/app/src/pages/__tests__/Conversations.render.test.tsx b/app/src/pages/__tests__/Conversations.render.test.tsx index 2b77205c13..c1bf675ea5 100644 --- a/app/src/pages/__tests__/Conversations.render.test.tsx +++ b/app/src/pages/__tests__/Conversations.render.test.tsx @@ -24,8 +24,10 @@ import chatRuntimeReducer, { bumpInferenceHeartbeatForThread, clearFollowupsForThread, enqueueFollowup, + markInferenceTurnStreaming, setInferenceStatusForThread, setPendingPlanReviewForThread, + setStreamingAssistantForThread, setTaskBoardForThread, setToolTimelineForThread, setTurnTimelinesForThread, @@ -65,7 +67,7 @@ const mockUseOpenRouterFreeModels = vi.hoisted(() => vi.fn()); // ── Module mocks ─────────────────────────────────────────────────────────── vi.mock('../../services/chatService', () => ({ - chatCancel: vi.fn(), + chatCancel: vi.fn().mockResolvedValue(true), chatClearQueue: vi.fn().mockResolvedValue(0), chatSend: vi.fn().mockResolvedValue(undefined), subscribeChatEvents: vi.fn(() => () => {}), @@ -1219,6 +1221,224 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => { expect(chatCancel).toHaveBeenCalledWith(thread.id); }); + // ── #4862: Stop-response + ESC-to-interrupt & re-edit ──────────────────── + + // Render a selected thread with an in-flight streaming turn (active + a + // partial assistant reply already streamed), so the in-composer Stop button + // is visible and ESC has a turn to interrupt. + async function renderStreamingConversation( + opts: { userPrompt?: string; streamingContent?: string } = {} + ) { + const thread = makeThread({ id: 'stream-thread', title: 'Streaming' }); + const messages: ThreadMessage[] = opts.userPrompt + ? [ + { + id: 'u-1', + sender: 'user', + type: 'text', + content: opts.userPrompt, + extraMetadata: {}, + createdAt: '2026-01-01T00:00:00.000Z', + }, + ] + : []; + mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 }); + mockGetThreadMessages.mockResolvedValue({ messages, count: messages.length }); + + let store: ReturnType | undefined; + await act(async () => { + store = await renderConversations({ + thread: { + ...selectedThreadState(thread), + messagesByThreadId: { [thread.id]: messages }, + messages, + // Marks the thread's turn as in-flight so the composer stays editable + // (`allowParallelSend`) and `selectedThreadActive` is true. + activeThreadIds: { [thread.id]: true }, + }, + socket: socketState('connected'), + }); + }); + + await act(async () => { + store!.dispatch(beginInferenceTurn({ threadId: thread.id })); + store!.dispatch(markInferenceTurnStreaming({ threadId: thread.id })); + store!.dispatch( + setStreamingAssistantForThread({ + threadId: thread.id, + streaming: { + requestId: 'req-stream-1', + content: opts.streamingContent ?? 'partial answer so far', + thinking: '', + }, + }) + ); + }); + + return { store: store!, thread }; + } + + it('preserves the partial reply marked stopped when Stop is clicked mid-stream (#4862)', async () => { + const { thread } = await renderStreamingConversation({ streamingContent: 'half a thought' }); + + const stopButton = await screen.findByTestId('stop-generation-button'); + await act(async () => { + fireEvent.click(stopButton); + }); + + expect(chatCancel).toHaveBeenCalledWith(thread.id); + // The partial stream is persisted as its own agent message flagged stopped + // so it survives the cancel instead of vanishing with the live preview. + await waitFor(() => { + expect(threadApi.appendMessage).toHaveBeenCalledWith( + thread.id, + expect.objectContaining({ + content: 'half a thought', + sender: 'agent', + extraMetadata: expect.objectContaining({ stopped: true }), + }) + ); + }); + }); + + it('does not persist a stopped message when nothing has streamed yet (#4862)', async () => { + const { thread } = await renderStreamingConversation({ streamingContent: ' ' }); + + const stopButton = await screen.findByTestId('stop-generation-button'); + await act(async () => { + fireEvent.click(stopButton); + }); + + expect(chatCancel).toHaveBeenCalledWith(thread.id); + // Whitespace-only partial → nothing worth preserving, so no message append. + expect(threadApi.appendMessage).not.toHaveBeenCalled(); + }); + + it('does not persist a stopped reply when the cancel is rejected (#4862)', async () => { + // Socket down / RPC rejected → chatCancel resolves false. The original turn + // may keep running and append its own final response, so we must NOT leave a + // misleading partial bubble behind. + vi.mocked(chatCancel).mockResolvedValueOnce(false); + const { thread } = await renderStreamingConversation({ streamingContent: 'half a thought' }); + + const stopButton = await screen.findByTestId('stop-generation-button'); + await act(async () => { + fireEvent.click(stopButton); + }); + + expect(chatCancel).toHaveBeenCalledWith(thread.id); + // Give the cancel promise a tick to resolve; no stopped message should land. + await act(async () => { + await Promise.resolve(); + }); + expect(threadApi.appendMessage).not.toHaveBeenCalled(); + }); + + it('persists the stopped reply only once across repeated Stop clicks (#4862)', async () => { + const { thread } = await renderStreamingConversation({ streamingContent: 'half a thought' }); + + const stopButton = await screen.findByTestId('stop-generation-button'); + // Two rapid Stop clicks before the cancel event clears the live stream. + await act(async () => { + fireEvent.click(stopButton); + fireEvent.click(stopButton); + }); + + expect(chatCancel).toHaveBeenCalledWith(thread.id); + // The one-shot requestId guard keeps the partial from being appended twice. + await waitFor(() => { + expect(threadApi.appendMessage).toHaveBeenCalledTimes(1); + }); + expect(threadApi.appendMessage).toHaveBeenCalledWith( + thread.id, + expect.objectContaining({ extraMetadata: expect.objectContaining({ stopped: true }) }) + ); + }); + + it('interrupts the stream and restores the last prompt into the composer on ESC (#4862)', async () => { + const { thread } = await renderStreamingConversation({ + userPrompt: 'my original question', + streamingContent: 'streaming so far', + }); + + const textarea = await screen.findByPlaceholderText(/Queue a follow-up/); + expect(textarea).toHaveValue(''); + + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Escape' }); + }); + + // The turn is cancelled and the user's prompt is re-hydrated for editing. + expect(chatCancel).toHaveBeenCalledWith(thread.id); + await waitFor(() => { + expect(textarea).toHaveValue('my original question'); + }); + }); + + it('does not clobber a typed follow-up when ESC is pressed with a non-empty composer (#4862)', async () => { + const { thread } = await renderStreamingConversation({ + userPrompt: 'my original question', + streamingContent: 'streaming so far', + }); + + const textarea = await screen.findByPlaceholderText(/Queue a follow-up/); + await act(async () => { + fireEvent.change(textarea, { target: { value: 'a fresh follow-up' } }); + }); + + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Escape' }); + }); + + // Interrupt still fires, but the in-progress follow-up text is left intact. + expect(chatCancel).toHaveBeenCalledWith(thread.id); + expect(textarea).toHaveValue('a fresh follow-up'); + }); + + it('renders a Stopped marker on a stopped partial reply (#4862)', async () => { + const thread = makeThread({ id: 'stopped-marker-thread', title: 'Stopped' }); + const messages: ThreadMessage[] = [ + { + id: 'u', + sender: 'user', + type: 'text', + content: 'go', + extraMetadata: {}, + createdAt: '2026-01-01T00:00:00.000Z', + }, + { + id: 'a', + sender: 'agent', + type: 'text', + content: 'partial reply that got cut off', + extraMetadata: { stopped: true }, + createdAt: '2026-01-01T00:01:00.000Z', + }, + ]; + mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 }); + mockGetThreadMessages.mockResolvedValue({ messages, count: messages.length }); + + await act(async () => { + await renderConversations({ + thread: { + ...selectedThreadState(thread), + messagesByThreadId: { [thread.id]: messages }, + messages, + }, + socket: socketState('connected'), + }); + }); + + expect(screen.getByTestId('stopped-marker')).toHaveTextContent('Stopped'); + }); + + it('shows Send and no Stop button while the thread is idle (#4862)', async () => { + await renderSelectedConversation(); + + expect(screen.queryByTestId('stop-generation-button')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Send message' })).toBeInTheDocument(); + }); + it('releases the pending-send lock when appendMessage rejects with a generic error', async () => { vi.mocked(threadApi.appendMessage).mockRejectedValueOnce(new Error('disk full')); const { textarea } = await renderSelectedConversation(); From deddc7cc611fd3b55de7ab8666ebd9febcc1ba74 Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Thu, 16 Jul 2026 05:33:24 +0530 Subject: [PATCH 28/86] feat(tools): generate_document tool emitting real .docx (#4847 Problem 3) (#4899) Co-authored-by: Steven Enamakel --- Cargo.lock | 50 +- Cargo.toml | 7 + app/src-tauri/Cargo.lock | 66 ++- app/src/components/chat/ArtifactCard.tsx | 25 +- app/src/components/chat/ChatFilesPanel.tsx | 18 +- .../chat/__tests__/ArtifactCard.test.tsx | 2 +- .../chat/__tests__/ChatFilesPanel.test.tsx | 2 +- .../components/chat/artifactExtension.test.ts | 32 ++ app/src/components/chat/artifactExtension.ts | 36 ++ src/openhuman/tools/impl/document/engine.rs | 427 ++++++++++++++++ src/openhuman/tools/impl/document/mod.rs | 291 +++++++++++ src/openhuman/tools/impl/document/tests.rs | 225 +++++++++ src/openhuman/tools/impl/document/types.rs | 456 ++++++++++++++++++ src/openhuman/tools/impl/mod.rs | 2 + src/openhuman/tools/ops.rs | 9 + 15 files changed, 1591 insertions(+), 57 deletions(-) create mode 100644 app/src/components/chat/artifactExtension.test.ts create mode 100644 app/src/components/chat/artifactExtension.ts create mode 100644 src/openhuman/tools/impl/document/engine.rs create mode 100644 src/openhuman/tools/impl/document/mod.rs create mode 100644 src/openhuman/tools/impl/document/tests.rs create mode 100644 src/openhuman/tools/impl/document/types.rs diff --git a/Cargo.lock b/Cargo.lock index bdd73c1173..d426c29ebb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1022,6 +1022,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -1698,6 +1704,21 @@ dependencies = [ "litrs", ] +[[package]] +name = "docx-rs" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed73cbf5e1c37baa23f4132569ac1187829f03922c206bd68fe109e3001a343d" +dependencies = [ + "base64 0.22.1", + "image", + "quick-xml 0.36.2", + "serde", + "serde_json", + "thiserror 2.0.18", + "zip 0.6.6", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -2512,6 +2533,16 @@ dependencies = [ "polyval 0.7.1", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gimli" version = "0.32.3" @@ -2863,7 +2894,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.58.0", + "windows-core 0.62.2", ] [[package]] @@ -3066,6 +3097,8 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "gif", "moxcms", "num-traits", "png", @@ -4369,6 +4402,7 @@ dependencies = [ "dialoguer", "directories", "dirs 5.0.1", + "docx-rs", "dotenvy", "ed25519-dalek", "enigo", @@ -4866,7 +4900,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -5191,6 +5225,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +dependencies = [ + "encoding_rs", + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -8294,7 +8338,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8c436dd53b..3312907407 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -268,6 +268,13 @@ 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" +# 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 +# `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" [target.'cfg(windows)'.dependencies] # Windows: tokio-tungstenite uses native-tls (schannel) so wss:// diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 4b69f599ed..34e997c970 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -250,7 +250,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.60.2", "x11rb", ] @@ -1335,6 +1335,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -2093,7 +2099,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2169,6 +2175,21 @@ dependencies = [ "litrs", ] +[[package]] +name = "docx-rs" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed73cbf5e1c37baa23f4132569ac1187829f03922c206bd68fe109e3001a343d" +dependencies = [ + "base64 0.22.1", + "image", + "quick-xml 0.36.2", + "serde", + "serde_json", + "thiserror 2.0.18", + "zip 0.6.6", +] + [[package]] name = "dom_query" version = "0.27.0" @@ -2496,7 +2517,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3199,6 +3220,16 @@ dependencies = [ "polyval", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gimli" version = "0.32.3" @@ -3737,7 +3768,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.58.0", + "windows-core 0.62.2", ] [[package]] @@ -3882,6 +3913,8 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "gif", "moxcms", "num-traits", "png 0.18.1", @@ -4136,7 +4169,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5030,7 +5063,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5598,6 +5631,7 @@ dependencies = [ "dialoguer", "directories 6.0.0", "dirs 5.0.1", + "docx-rs", "dotenvy", "ed25519-dalek", "enigo", @@ -6647,6 +6681,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +dependencies = [ + "encoding_rs", + "memchr", +] + [[package]] name = "quick-xml" version = "0.37.5" @@ -6727,7 +6771,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -7314,7 +7358,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7373,7 +7417,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8999,7 +9043,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -10659,7 +10703,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/app/src/components/chat/ArtifactCard.tsx b/app/src/components/chat/ArtifactCard.tsx index 540420aa4e..d5ff4b0168 100644 --- a/app/src/components/chat/ArtifactCard.tsx +++ b/app/src/components/chat/ArtifactCard.tsx @@ -7,6 +7,7 @@ import { saveArtifactViaDialog, } from '../../services/artifactDownloadService'; import type { ArtifactSnapshot } from '../../store/chatRuntimeSlice'; +import { extensionFor } from './artifactExtension'; /** * Inline chat card surfacing a single agent-generated artifact (#2779). @@ -33,30 +34,6 @@ export interface ArtifactCardProps { onRetry?: (artifactId: string) => void; } -/** - * Extension hint for the Tauri download command. Falls back to a - * lowercased kind slug when the title doesn't carry an explicit - * extension (defensive — `create_artifact` sanitises the title + - * extension separately, but a malformed title shouldn't crash the - * card). - */ -function extensionFor(kind: ArtifactSnapshot['kind'], title: string): string { - const dot = title.lastIndexOf('.'); - if (dot > 0 && dot < title.length - 1) { - return title.slice(dot + 1).toLowerCase(); - } - switch (kind) { - case 'presentation': - return 'pptx'; - case 'document': - return 'pdf'; - case 'image': - return 'png'; - default: - return 'bin'; - } -} - function KindIcon({ kind }: { kind: ArtifactSnapshot['kind'] }) { const stroke = 'currentColor'; switch (kind) { diff --git a/app/src/components/chat/ChatFilesPanel.tsx b/app/src/components/chat/ChatFilesPanel.tsx index 034ef17365..0bed3469d9 100644 --- a/app/src/components/chat/ChatFilesPanel.tsx +++ b/app/src/components/chat/ChatFilesPanel.tsx @@ -15,6 +15,7 @@ import { } from '../../store/chatRuntimeSlice'; import { useAppDispatch } from '../../store/hooks'; import Button from '../ui/Button'; +import { extensionFor } from './artifactExtension'; /** * Popover panel listing every `ready` artifact for a thread (#3024). @@ -76,23 +77,6 @@ function localizeErrorCode( } } -function extensionFor(kind: ArtifactSnapshot['kind'], title: string): string { - const dot = title.lastIndexOf('.'); - if (dot > 0 && dot < title.length - 1) { - return title.slice(dot + 1).toLowerCase(); - } - switch (kind) { - case 'presentation': - return 'pptx'; - case 'document': - return 'pdf'; - case 'image': - return 'png'; - default: - return 'bin'; - } -} - function KindIcon({ kind }: { kind: ArtifactSnapshot['kind'] }) { const stroke = 'currentColor'; switch (kind) { diff --git a/app/src/components/chat/__tests__/ArtifactCard.test.tsx b/app/src/components/chat/__tests__/ArtifactCard.test.tsx index b4c550c3bf..096048a568 100644 --- a/app/src/components/chat/__tests__/ArtifactCard.test.tsx +++ b/app/src/components/chat/__tests__/ArtifactCard.test.tsx @@ -130,7 +130,7 @@ describe('ArtifactCard', () => { }); it.each([ - ['document' as const, 'pdf'], + ['document' as const, 'docx'], ['image' as const, 'png'], ['other' as const, 'bin'], ['presentation' as const, 'pptx'], diff --git a/app/src/components/chat/__tests__/ChatFilesPanel.test.tsx b/app/src/components/chat/__tests__/ChatFilesPanel.test.tsx index a43deec115..d9d59d5ca3 100644 --- a/app/src/components/chat/__tests__/ChatFilesPanel.test.tsx +++ b/app/src/components/chat/__tests__/ChatFilesPanel.test.tsx @@ -262,7 +262,7 @@ describe('ChatFilesPanel', () => { }); it.each([ - ['document' as const, 'pdf'], + ['document' as const, 'docx'], ['image' as const, 'png'], ['other' as const, 'bin'], ])( diff --git a/app/src/components/chat/artifactExtension.test.ts b/app/src/components/chat/artifactExtension.test.ts new file mode 100644 index 0000000000..1b65954b42 --- /dev/null +++ b/app/src/components/chat/artifactExtension.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { extensionFor } from './artifactExtension'; + +describe('extensionFor', () => { + it('maps document artifacts to docx (GH #4847, was pdf)', () => { + // The regression this guards: a `document` artifact carrying no + // explicit extension in its title must export as `.docx` — the + // format `generate_document` actually emits — not the stale `pdf`. + expect(extensionFor('document', 'Quarterly Report')).toBe('docx'); + }); + + it('maps the other known kinds to their default extensions', () => { + expect(extensionFor('presentation', 'Q3 Deck')).toBe('pptx'); + expect(extensionFor('image', 'Chart')).toBe('png'); + expect(extensionFor('other', 'Blob')).toBe('bin'); + }); + + it('prefers an explicit extension already present on the title', () => { + // The title-carried extension wins over the per-kind default, so a + // legacy pdf document still round-trips as pdf. + expect(extensionFor('document', 'legacy.pdf')).toBe('pdf'); + expect(extensionFor('document', 'notes.DOCX')).toBe('docx'); + expect(extensionFor('image', 'photo.jpeg')).toBe('jpeg'); + }); + + it('ignores a leading or trailing dot that is not a real extension', () => { + // No extension segment → fall back to the kind default. + expect(extensionFor('document', '.hidden')).toBe('docx'); + expect(extensionFor('document', 'trailing.')).toBe('docx'); + }); +}); diff --git a/app/src/components/chat/artifactExtension.ts b/app/src/components/chat/artifactExtension.ts new file mode 100644 index 0000000000..968161382a --- /dev/null +++ b/app/src/components/chat/artifactExtension.ts @@ -0,0 +1,36 @@ +import type { ArtifactSnapshot } from '../../store/chatRuntimeSlice'; + +/** + * Extension hint for the Tauri download / Save-As commands. + * + * Prefers an explicit extension already present on the artifact title + * (defensive — `create_artifact` sanitises the title + extension + * separately, but a malformed title shouldn't crash the card), otherwise + * falls back to a per-kind default. + * + * Shared by {@link ArtifactCard} and {@link ChatFilesPanel}, which both + * derive the download filename extension from `(kind, title)` and must + * stay in lockstep — hence a single source of truth rather than two + * hand-kept copies. + * + * `document` → `docx`: the document artifact producer (`generate_document`, + * GH #4847) emits a real Word `.docx`. The prior `pdf` default predated any + * document producer and would have handed the byte-agnostic export a wrong + * extension. + */ +export function extensionFor(kind: ArtifactSnapshot['kind'], title: string): string { + const dot = title.lastIndexOf('.'); + if (dot > 0 && dot < title.length - 1) { + return title.slice(dot + 1).toLowerCase(); + } + switch (kind) { + case 'presentation': + return 'pptx'; + case 'document': + return 'docx'; + case 'image': + return 'png'; + default: + return 'bin'; + } +} diff --git a/src/openhuman/tools/impl/document/engine.rs b/src/openhuman/tools/impl/document/engine.rs new file mode 100644 index 0000000000..7247fcb24a --- /dev/null +++ b/src/openhuman/tools/impl/document/engine.rs @@ -0,0 +1,427 @@ +//! Native Rust `.docx` generator, backed by the +//! [`docx-rs`](https://crates.io/crates/docx-rs) crate (MIT). Pure CPU, +//! no subprocess, no managed runtime — the document analogue of the +//! `ppt-rs`-backed presentation [`engine`](super::super::presentation). +//! Output is a byte buffer the caller writes to the artifact's +//! `output_path`. +//! +//! ## Mapping `GenerateDocumentInput` → OOXML +//! +//! The document is emitted as a linear paragraph stream: +//! +//! ```text +//! title → bold, large "Title"-styled paragraph +//! author (opt) → italic paragraph beneath the title +//! per section: +//! heading (opt) → bold "Heading1"-styled paragraph +//! paragraphs[] → one normal paragraph each +//! bullets[] → single-level `•` list (shared numbering id) +//! ``` +//! +//! Headings carry BOTH a style id (`Title` / `Heading1`, which Word maps +//! to its built-in outline styles) AND an explicit bold+size run, so the +//! visual hierarchy survives even if a reader ignores the style table. +//! Empty / whitespace-only paragraphs and bullets are filtered so a +//! trailing blank entry does not emit an empty run. +//! +//! ## Runtime +//! +//! `docx-rs` synthesis is synchronous and CPU-bound (well under 100 ms +//! for the section cap). We still drive it through `spawn_blocking` so +//! the async executor is not blocked, and wrap the call in a +//! `tokio::time::timeout` so a runaway generation cannot wedge the agent +//! loop — identical control-flow to the presentation engine. + +use std::time::Duration; + +use docx_rs::{ + AbstractNumbering, Docx, IndentLevel, Level, LevelJc, LevelText, NumberFormat, Numbering, + NumberingId, Paragraph, Run, Start, +}; +use tokio::task::JoinError; +use tokio::time::{error::Elapsed, timeout}; + +use super::types::{DocumentError, GenerateDocumentInput}; + +/// Shared numbering id for the single-level bullet list. One abstract +/// numbering + one concrete numbering is registered on the document and +/// every bullet paragraph references it at indent level 0. +const BULLET_NUMBERING_ID: usize = 1; + +/// Run font size for the document title, in OOXML half-points (28 pt). +const TITLE_SIZE_HALF_PT: usize = 56; +/// Run font size for a section heading, in half-points (16 pt). +const HEADING_SIZE_HALF_PT: usize = 32; +/// Run font size for the author byline, in half-points (12 pt). +const AUTHOR_SIZE_HALF_PT: usize = 24; + +/// Run the synthesis. Returns the serialised `.docx` bytes ready to be +/// written to the artifact path. +/// +/// The `deadline` covers the entire blocking call (including the +/// `spawn_blocking` thread acquisition). Hitting it surfaces as +/// [`DocumentError::GenerationTimeout`]. +pub(super) async fn generate( + input: &GenerateDocumentInput, + deadline: Duration, +) -> Result, DocumentError> { + // Clone the input across the blocking boundary — cheap relative to the + // synthesis, and keeps the blocking closure `'static`. + let owned = input.clone(); + let started = std::time::Instant::now(); + let section_count = owned.sections.len(); + let deadline_secs = deadline.as_secs(); + let title_chars = owned.title.chars().count(); + + tracing::debug!( + target: "document", + deadline_secs, + section_count, + title_chars, + "[document:engine] generate:start" + ); + + let join: Result, EngineFailure>, _>, Elapsed> = timeout( + deadline, + tokio::task::spawn_blocking(move || generate_blocking(&owned)), + ) + .await; + + let elapsed_ms = started.elapsed().as_millis() as u64; + match join { + Err(_elapsed) => { + tracing::warn!( + target: "document", + elapsed_ms, + deadline_secs, + section_count, + "[document:engine] generate:timeout" + ); + Err(DocumentError::GenerationTimeout { + timeout_secs: deadline_secs, + }) + } + Ok(Err(join_err)) => { + let err = map_join_error(join_err); + tracing::warn!( + target: "document", + elapsed_ms, + kind = "join_error", + err = %err, + "[document:engine] generate:failure" + ); + Err(err) + } + Ok(Ok(Err(engine_err))) => { + let err = map_engine_failure(engine_err); + tracing::warn!( + target: "document", + elapsed_ms, + kind = "engine_failure", + err = %err, + "[document:engine] generate:failure" + ); + Err(err) + } + Ok(Ok(Ok(bytes))) => { + tracing::debug!( + target: "document", + elapsed_ms, + bytes = bytes.len(), + section_count, + "[document:engine] generate:done" + ); + Ok(bytes) + } + } +} + +/// Blocking inner — runs on the `spawn_blocking` pool. Builds the whole +/// `docx-rs` document from the input then serialises it into an in-memory +/// zip buffer. Returns a dedicated [`EngineFailure`] so the async wrapper +/// can distinguish a library error from a panic / cancellation. +fn generate_blocking(input: &GenerateDocumentInput) -> Result, EngineFailure> { + let docx = build_document(input); + + // `XMLDocx::pack` takes a `Write + Seek` writer by value; a + // `&mut Cursor>` satisfies both traits, so we can pack into an + // in-memory buffer and recover the bytes via `into_inner`. + let mut cursor = std::io::Cursor::new(Vec::::new()); + docx.build() + .pack(&mut cursor) + .map_err(|err| EngineFailure::Library(format!("{err}")))?; + Ok(cursor.into_inner()) +} + +/// Pure transformation from our schema to a `docx-rs` [`Docx`]. Pulled +/// out for unit-testability — the paragraph ordering + empty-filtering +/// rules are load-bearing for the rendered document shape. +fn build_document(input: &GenerateDocumentInput) -> Docx { + // Register the shared single-level bullet list once. `NumberFormat` + // "bullet" + a `•` level text renders an unordered list; every bullet + // paragraph binds to this numbering id at indent level 0. + let mut docx = Docx::new() + .add_abstract_numbering( + AbstractNumbering::new(BULLET_NUMBERING_ID).add_level(Level::new( + 0, + Start::new(1), + NumberFormat::new("bullet"), + LevelText::new("•"), + LevelJc::new("left"), + )), + ) + .add_numbering(Numbering::new(BULLET_NUMBERING_ID, BULLET_NUMBERING_ID)); + + // Title — bold + large, styled as the built-in "Title" outline style. + docx = docx.add_paragraph( + Paragraph::new().style("Title").add_run( + Run::new() + .add_text(input.title.trim()) + .bold() + .size(TITLE_SIZE_HALF_PT), + ), + ); + + // Author byline — italic, if present and non-blank. + if let Some(author) = input.author.as_deref().filter(|a| !a.trim().is_empty()) { + docx = docx.add_paragraph( + Paragraph::new().add_run( + Run::new() + .add_text(author.trim()) + .italic() + .size(AUTHOR_SIZE_HALF_PT), + ), + ); + } + + for section in &input.sections { + if let Some(heading) = section.heading.as_deref().filter(|h| !h.trim().is_empty()) { + docx = docx.add_paragraph( + Paragraph::new().style("Heading1").add_run( + Run::new() + .add_text(heading.trim()) + .bold() + .size(HEADING_SIZE_HALF_PT), + ), + ); + } + for paragraph in §ion.paragraphs { + let text = paragraph.trim(); + if !text.is_empty() { + docx = docx.add_paragraph(Paragraph::new().add_run(Run::new().add_text(text))); + } + } + for bullet in §ion.bullets { + let text = bullet.trim(); + if !text.is_empty() { + docx = docx.add_paragraph( + Paragraph::new() + .add_run(Run::new().add_text(text)) + .numbering(NumberingId::new(BULLET_NUMBERING_ID), IndentLevel::new(0)), + ); + } + } + } + + docx +} + +/// Internal failure shape used to keep the blocking-thread surface +/// `Send`-clean (the underlying library error types are not guaranteed +/// to be `Send + Sync + 'static`). +#[derive(Debug)] +enum EngineFailure { + Library(String), +} + +fn map_engine_failure(failure: EngineFailure) -> DocumentError { + match failure { + EngineFailure::Library(msg) => DocumentError::GenerationFailed { + stderr_truncated: DocumentError::truncate_stderr(&msg), + }, + } +} + +fn map_join_error(err: JoinError) -> DocumentError { + // The outer `tokio::time::timeout` already routes the timeout case, so + // a `JoinError` here is a panic (docx-rs bug / OOM on the blocking + // pool) or a cancellation (runtime shutdown / explicit abort). Both + // surface as `GenerationFailed` with context preserved — mirrors the + // presentation engine so a "0s timeout" message is never fabricated. + if err.is_panic() { + DocumentError::GenerationFailed { + stderr_truncated: DocumentError::truncate_stderr("document engine panicked"), + } + } else { + DocumentError::GenerationFailed { + stderr_truncated: DocumentError::truncate_stderr(&format!( + "document engine task cancelled: {err}" + )), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::tools::implementations::document::types::DocumentSection; + + fn sample_input() -> GenerateDocumentInput { + GenerateDocumentInput { + title: "Project Charter".to_string(), + author: Some("Alice".to_string()), + sections: vec![ + DocumentSection { + heading: Some("Overview".to_string()), + paragraphs: vec!["This document describes the plan.".to_string()], + bullets: vec![], + }, + DocumentSection { + heading: Some("Goals".to_string()), + paragraphs: vec![], + bullets: vec!["Ship v1".to_string(), "Delight users".to_string()], + }, + ], + } + } + + /// Read the entry names of a produced `.docx` byte buffer. + fn docx_entry_names(bytes: &[u8]) -> Vec { + let cursor = std::io::Cursor::new(bytes.to_vec()); + let mut zip = zip::ZipArchive::new(cursor).expect("output is a valid zip archive"); + (0..zip.len()) + .map(|i| zip.by_index(i).unwrap().name().to_string()) + .collect() + } + + /// Read one entry's UTF-8 body out of a produced `.docx`. + fn docx_entry_body(bytes: &[u8], name: &str) -> String { + let cursor = std::io::Cursor::new(bytes.to_vec()); + let mut zip = zip::ZipArchive::new(cursor).expect("valid zip"); + let mut entry = zip.by_name(name).expect("entry present"); + let mut body = String::new(); + std::io::Read::read_to_string(&mut entry, &mut body).unwrap(); + body + } + + #[tokio::test] + async fn generate_round_trips_to_valid_docx() { + // End-to-end: build → docx-rs → byte buffer → re-open as zip → + // confirm the OOXML skeleton + that our text reached document.xml. + let bytes = generate(&sample_input(), Duration::from_secs(30)) + .await + .expect("generate should succeed"); + + // A `.docx` is a zip: the magic bytes are the local-file-header + // signature `PK\x03\x04`. This is the acceptance-criteria check + // that any OOXML reader can open the file. + assert!( + bytes.len() > 200, + "docx unexpectedly small ({} bytes)", + bytes.len() + ); + assert_eq!(&bytes[0..2], b"PK", "docx must start with the zip magic PK"); + + let names = docx_entry_names(&bytes); + for required in ["[Content_Types].xml", "_rels/.rels", "word/document.xml"] { + assert!( + names.iter().any(|n| n == required), + "missing OOXML entry: {required} (got: {names:?})" + ); + } + + // Numbering was used → the numbering part must materialise. + assert!( + names.iter().any(|n| n == "word/numbering.xml"), + "bullet list should emit word/numbering.xml (got: {names:?})" + ); + + // Our title, heading, paragraph, and bullet text all reach the + // rendered document body — none dropped on the floor. + let doc = docx_entry_body(&bytes, "word/document.xml"); + for needle in [ + "Project Charter", + "Overview", + "This document describes the plan.", + "Goals", + "Ship v1", + "Delight users", + ] { + assert!( + doc.contains(needle), + "document.xml missing text: {needle:?}" + ); + } + } + + #[tokio::test] + async fn generate_drops_blank_paragraphs_and_bullets() { + // Whitespace-only entries must not blow up generation and must not + // emit empty runs — the engine trims + drops them. + let input = GenerateDocumentInput { + title: "Trimmed".to_string(), + author: Some(" ".to_string()), + sections: vec![DocumentSection { + heading: Some("Kept".to_string()), + paragraphs: vec!["real".to_string(), " ".to_string(), String::new()], + bullets: vec!["item".to_string(), "\t\n".to_string()], + }], + }; + let bytes = generate(&input, Duration::from_secs(30)) + .await + .expect("generate should succeed on whitespace-only entries"); + let doc = docx_entry_body(&bytes, "word/document.xml"); + assert!(doc.contains("real")); + assert!(doc.contains("item")); + } + + #[tokio::test] + async fn generate_yields_clean_structured_result_under_zero_deadline() { + // Contract under an impossibly-short deadline: `generate` must surface a + // clean, structured outcome — never a panic or a half-written buffer. + // + // Which outcome we get is inherently racy and must NOT be pinned: a + // near-zero `timeout` wrapping `spawn_blocking` usually elapses first + // (GenerationTimeout), but the runtime can instead cancel the blocking + // task, which `map_join_error` maps to GenerationFailed, and a trivial + // input can even finish before the timer fires (Ok). Asserting one exact + // variant made this flake under coverage instrumentation. We assert the + // real invariant: any Ok is a non-empty buffer, any Err is one of the + // two documented structured variants, and nothing panics. + match generate(&sample_input(), Duration::ZERO).await { + Ok(bytes) => assert!(!bytes.is_empty(), "a completed docx must be non-empty"), + Err(DocumentError::GenerationTimeout { timeout_secs }) => { + assert_eq!(timeout_secs, 0, "zero deadline reports 0 seconds"); + } + Err(DocumentError::GenerationFailed { .. }) => { + // Blocking task cancelled before the timer fired — still clean. + } + Err(other) => panic!("unexpected error variant under a zero deadline: {other:?}"), + } + } + + #[tokio::test] + async fn map_join_error_cancellation_becomes_generation_failed() { + // A non-panic JoinError (cancellation via abort) surfaces as + // GenerationFailed with the cancellation context preserved — never + // a fabricated "0s timeout". + let handle = tokio::spawn(async { + tokio::time::sleep(Duration::from_secs(3600)).await; + }); + handle.abort(); + let join_err = handle.await.expect_err("aborted task yields JoinError"); + assert!( + !join_err.is_panic(), + "abort() yields a cancellation, not a panic" + ); + match map_join_error(join_err) { + DocumentError::GenerationFailed { stderr_truncated } => { + assert!( + stderr_truncated.contains("document engine task cancelled"), + "cancellation context missing: {stderr_truncated:?}" + ); + } + other => panic!("expected GenerationFailed, got {other:?}"), + } + } +} diff --git a/src/openhuman/tools/impl/document/mod.rs b/src/openhuman/tools/impl/document/mod.rs new file mode 100644 index 0000000000..b4ad4751ee --- /dev/null +++ b/src/openhuman/tools/impl/document/mod.rs @@ -0,0 +1,291 @@ +//! Tool: `generate_document` — build a `.docx` file from a structured +//! section spec via the native-Rust [`engine`] module. +//! +//! Flow (mirrors `generate_presentation` exactly so the two artifact +//! producers stay parallel): +//! 1. Validate the JSON-Schema input early (`types::validate_input`) so +//! the agent gets a structured `InvalidInput` it can self-correct on. +//! 2. Allocate an artifact dir via `artifacts::create_artifact` (kind = +//! [`ArtifactKind::Document`], extension `"docx"`). The returned +//! `meta` starts at `ArtifactStatus::Pending` so an interrupted run +//! never surfaces as Ready. +//! 3. Persist the verbatim args via `save_artifact_args` for regeneration +//! parity (the failed-card Retry path, #3162). +//! 4. Generate the `.docx` bytes via [`engine::generate`] — pure Rust, +//! `docx-rs`-backed, no subprocess. Wrapped in `spawn_blocking` + +//! `tokio::time::timeout`. +//! 5. Write the bytes, stat for size, flip the artifact to `Ready` via +//! `finalize_artifact`, return the artifact id + path. +//! 6. On any failure: flip the artifact to `Failed` via `fail_artifact` +//! so the UI surfaces the reason instead of an indefinite spinner. +//! +//! Added in GH #4847 (Problem 3: `.docx` export). The +//! [`ArtifactKind::Document`] enum variant already existed but had no +//! producer; the byte-agnostic artifact pipeline (Save-As dialog + +//! Downloads copy) and the frontend `document → docx` extension map need +//! no format-specific changes. + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::openhuman::artifacts::{ + create_artifact, fail_artifact, finalize_artifact, ArtifactKind, +}; +use crate::openhuman::security::SecurityPolicy; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; + +mod engine; +mod types; + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; + +use self::types::{validate_input, GenerateDocumentInput, GenerateDocumentOutput}; + +/// Generation timeout. `docx-rs` typically completes the full section cap +/// in well under a second; the 30 s ceiling is a defensive bound against +/// pathological inputs slipping past `validate_input` and worst-case +/// `spawn_blocking` thread-acquisition latency on a saturated runtime. +const GENERATION_TIMEOUT: Duration = Duration::from_secs(30); + +/// Tool name surfaced to the agent. Stable; do not rename without +/// coordinating with the orchestrator agent definition list. +pub const TOOL_NAME: &str = "generate_document"; + +/// One-shot `.docx` generator. See module docs for the request flow. +pub struct DocumentTool { + workspace_dir: PathBuf, + /// Retained for constructor parity with [`PresentationTool`] (both are + /// registered identically in `tools::ops`) and for future features + /// (e.g. embedding a `File`-source image) that will need the same + /// path-validation surface. Not read by the current text-only engine. + #[allow(dead_code)] + security: Arc, +} + +impl DocumentTool { + /// Production constructor. The engine is stateless. Pass the workspace + /// directory the artifact pipeline writes into, plus the active + /// [`SecurityPolicy`] (same signature as [`PresentationTool::new`] so + /// both tools register with an identical call). + pub fn new(workspace_dir: PathBuf, security: Arc) -> Self { + Self { + workspace_dir, + security, + } + } +} + +#[async_trait] +impl Tool for DocumentTool { + fn name(&self) -> &str { + TOOL_NAME + } + + fn description(&self) -> &str { + // Router-rule format per the existing tool conventions: tell the + // orchestrator when to use this tool and when NOT to. + "Generate a Word (.docx) document from a structured section spec. \ + USE THIS when the user asks for a document, a report, a letter, an \ + essay, meeting notes, or any prose deliverable they want as an \ + editable Word file. Provide `title` plus a `sections` array of \ + `{heading?, paragraphs?, bullets?}` objects; headings render bold, \ + bullets render as a list. NOT for: slide decks or presentations \ + (use generate_presentation), spreadsheets, or non-Word formats \ + (PDF, Google Docs exports). The generated file is persisted as an \ + artifact in the workspace and the tool returns the artifact id + \ + absolute path so the agent can reference it in the reply." + } + + fn parameters_schema(&self) -> Value { + // Built as separate `json!` bindings to keep macro-expansion depth + // low and to mirror the presentation tool's schema style. + let section_item_schema = json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "heading": { + "type": "string", + "maxLength": types::MAX_TEXT_CHARS, + "description": "Optional section heading, rendered bold." + }, + "paragraphs": { + "type": "array", + "maxItems": types::MAX_PARAGRAPHS_PER_SECTION, + "description": "Body paragraphs, one rendered paragraph each.", + "items": { "type": "string", "maxLength": types::MAX_PARAGRAPH_CHARS } + }, + "bullets": { + "type": "array", + "maxItems": types::MAX_BULLETS_PER_SECTION, + "description": "Bullet-list items rendered after the paragraphs.", + "items": { "type": "string", "maxLength": types::MAX_PARAGRAPH_CHARS } + } + } + }); + + json!({ + "type": "object", + "additionalProperties": false, + "required": ["title", "sections"], + "properties": { + "title": { + "type": "string", + "description": "Document title. Rendered as the leading title line and used as the artifact's human-readable name. Required, non-empty.", + "maxLength": types::MAX_TEXT_CHARS, + }, + "author": { + "type": "string", + "description": "Optional author byline, rendered italic beneath the title.", + "maxLength": types::MAX_TEXT_CHARS, + }, + "sections": { + "type": "array", + "minItems": 1, + "maxItems": types::MAX_SECTIONS, + "description": "Sections in display order. At least one entry required; each must have at least one of heading / paragraphs / bullets. Hard cap to bound generation time + output size.", + "items": section_item_schema, + } + } + }) + } + + fn permission_level(&self) -> PermissionLevel { + // Writes a file to the workspace artifacts dir. No subprocess / + // network reach. + PermissionLevel::Write + } + + fn supports_markdown(&self) -> bool { + true + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let input: GenerateDocumentInput = match serde_json::from_value(args.clone()) { + Ok(v) => v, + Err(err) => { + let msg = format!("invalid generate_document arguments: {err}"); + tracing::warn!(target: "document", err = %err, "[document] deserialisation failed"); + return Ok(ToolResult::error(msg)); + } + }; + + if let Err(err) = validate_input(&input) { + tracing::debug!(target: "document", err = %err, "[document] validation rejected input"); + return Ok(ToolResult::error(err.to_string())); + } + + tracing::info!( + target: "document", + title_chars = input.title.chars().count(), + has_author = input.author.is_some(), + section_count = input.sections.len(), + "[document] generation request accepted" + ); + + let (meta, output_path) = create_artifact( + &self.workspace_dir, + ArtifactKind::Document, + &input.title, + "docx", + ) + .await + .map_err(anyhow::Error::msg)?; + + // Persist the verbatim args next to meta.json so a failed card's + // Retry can re-dispatch this exact spec (#3162). Best-effort: a + // write failure only forfeits regeneration, never aborts an + // otherwise-successful generation. + if let Err(err) = crate::openhuman::artifacts::store::save_artifact_args( + &self.workspace_dir, + &meta.id, + &args, + ) + .await + { + tracing::warn!( + target: "document", + err = %err, + artifact_id = %meta.id, + "[document] failed to persist args.json; artifact will not be regenerable" + ); + } + + let bytes = match engine::generate(&input, GENERATION_TIMEOUT).await { + Ok(bytes) => bytes, + Err(err) => { + let _ = fail_artifact(&self.workspace_dir, &meta.id, &err.to_string()).await; + tracing::warn!( + target: "document", + err = %err, + "[document] engine generation failed" + ); + return Ok(ToolResult::error(err.to_string())); + } + }; + + if let Err(err) = tokio::fs::write(&output_path, &bytes).await { + let filename = output_path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let reason = format!("failed to write generated document ({filename}): {err}"); + let _ = fail_artifact(&self.workspace_dir, &meta.id, &reason).await; + tracing::warn!( + target: "document", + err = %err, + artifact_id = %meta.id, + filename = %filename, + "[document] artifact file write failed" + ); + return Ok(ToolResult::error(reason)); + } + + let size_bytes = bytes.len() as u64; + let updated = match finalize_artifact(&self.workspace_dir, &meta.id, size_bytes).await { + Ok(updated) => updated, + Err(err) => { + let reason = format!("failed to finalize artifact: {err}"); + // File is already on disk but the ledger transition failed. + // Flip to Failed so the UI surfaces the error instead of a + // stuck `Pending` spinner. Fail-artifact errors are + // swallowed — they can only recur if the same ledger backend + // is unavailable. + let _ = fail_artifact(&self.workspace_dir, &meta.id, &reason).await; + tracing::warn!( + target: "document", + err = %err, + artifact_id = %meta.id, + "[document] finalize_artifact failed; flipped to Failed" + ); + return Ok(ToolResult::error(reason)); + } + }; + + tracing::info!( + target: "document", + artifact_id = %updated.id, + size_bytes, + section_count = input.sections.len(), + "[document] generation complete" + ); + + let out = GenerateDocumentOutput { + artifact_id: updated.id.clone(), + artifact_path: output_path.display().to_string(), + section_count: input.sections.len(), + size_bytes, + }; + let payload = serde_json::to_value(&out)?; + let markdown = format!( + "Generated a {}-section document at `{}` (artifact `{}`, {} bytes).", + out.section_count, out.artifact_path, out.artifact_id, out.size_bytes + ); + Ok(ToolResult::success_with_markdown(payload, markdown)) + } +} diff --git a/src/openhuman/tools/impl/document/tests.rs b/src/openhuman/tools/impl/document/tests.rs new file mode 100644 index 0000000000..074b0c2556 --- /dev/null +++ b/src/openhuman/tools/impl/document/tests.rs @@ -0,0 +1,225 @@ +//! Unit tests for the `generate_document` tool. +//! +//! The engine layer (`engine.rs`) ships its own focused tests covering +//! the schema → OOXML mapping, the zip round-trip, empty-filtering, and +//! timeout handling. The tests here cover the tool-level concerns: input +//! validation rejection branches, the parameters schema contract, the +//! `description` router rules, and the happy-path output shape + artifact +//! finalisation (id + path + section count + size, file on disk). +//! +//! No mocks — the real `docx-rs` engine runs every test, so the happy +//! path doubles as a contract check that generation produces a valid, +//! openable `.docx` from the tool's perspective. + +use super::types::{DocumentError, MAX_SECTIONS, MAX_TEXT_CHARS}; +use super::*; + +use std::path::Path; + +fn workspace() -> tempfile::TempDir { + tempfile::tempdir().expect("create temp workspace") +} + +/// A permissive policy rooted at `workspace`. The current engine never +/// reads it, but the constructor takes it for parity with the +/// presentation tool. +fn test_security(workspace: &Path) -> Arc { + use crate::openhuman::security::AutonomyLevel; + Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Full, + workspace_dir: workspace.to_path_buf(), + action_dir: workspace.to_path_buf(), + workspace_only: false, + forbidden_paths: vec![], + ..SecurityPolicy::default() + }) +} + +fn make_tool(workspace: &Path) -> DocumentTool { + DocumentTool::new(workspace.to_path_buf(), test_security(workspace)) +} + +fn minimal_input_json() -> serde_json::Value { + json!({ + "title": "Project Charter", + "sections": [ + { "heading": "Overview", "paragraphs": ["The plan in brief."], "bullets": ["Ship v1"] } + ] + }) +} + +/// Pull the JSON payload out of a tool result. +fn payload_of(result: &ToolResult) -> serde_json::Value { + match result.content.first().expect("a content block") { + crate::openhuman::skills::types::ToolContent::Json { data } => data.clone(), + other => panic!("expected Json content block, got {other:?}"), + } +} + +#[test] +fn parameters_schema_shape_matches_contract() { + let tool = make_tool(Path::new("/tmp/never-read")); + let schema = tool.parameters_schema(); + assert_eq!(schema["type"], "object"); + let required = schema["required"].as_array().expect("required is array"); + assert!(required.iter().any(|v| v.as_str() == Some("title"))); + assert!(required.iter().any(|v| v.as_str() == Some("sections"))); + assert_eq!(schema["additionalProperties"], false); + let title_props = &schema["properties"]["title"]; + assert_eq!(title_props["type"], "string"); + assert_eq!(title_props["maxLength"], MAX_TEXT_CHARS); + let sections = &schema["properties"]["sections"]; + assert_eq!(sections["minItems"], 1); + assert_eq!(sections["maxItems"], MAX_SECTIONS); + let section_item = §ions["items"]; + assert_eq!(section_item["additionalProperties"], false); +} + +#[test] +fn permission_level_is_write() { + let tool = make_tool(Path::new("/tmp/never-read")); + assert_eq!(tool.permission_level(), PermissionLevel::Write); +} + +#[test] +fn description_includes_router_rules() { + let tool = make_tool(Path::new("/tmp/never-read")); + let desc = tool.description(); + assert!(desc.contains("USE THIS")); + assert!(desc.contains("NOT for")); + assert!(desc.contains("document") || desc.contains("docx")); +} + +#[tokio::test] +async fn execute_rejects_empty_title() { + let ws = workspace(); + let tool = make_tool(ws.path()); + let args = json!({ "title": "", "sections": [{ "heading": "x", "paragraphs": ["y"] }] }); + let result = tool.execute(args).await.expect("execute returns Ok"); + assert!(result.is_error); + assert!(result.text().contains("title")); +} + +#[tokio::test] +async fn execute_rejects_empty_sections_array() { + let ws = workspace(); + let tool = make_tool(ws.path()); + let args = json!({ "title": "Doc", "sections": [] }); + let result = tool.execute(args).await.expect("execute returns Ok"); + assert!(result.is_error); + assert!(result.text().contains("section")); +} + +#[tokio::test] +async fn execute_rejects_section_with_no_content() { + let ws = workspace(); + let tool = make_tool(ws.path()); + let args = json!({ + "title": "Doc", + "sections": [{ "heading": "", "paragraphs": [" "], "bullets": [] }] + }); + let result = tool.execute(args).await.expect("execute returns Ok"); + assert!(result.is_error); +} + +#[tokio::test] +async fn execute_rejects_oversize_heading() { + let ws = workspace(); + let tool = make_tool(ws.path()); + let big = "x".repeat(MAX_TEXT_CHARS + 1); + let args = json!({ + "title": "Doc", + "sections": [{ "heading": big, "paragraphs": ["ok"] }] + }); + let result = tool.execute(args).await.expect("execute returns Ok"); + assert!(result.is_error); +} + +#[tokio::test] +async fn execute_rejects_too_many_sections() { + let ws = workspace(); + let tool = make_tool(ws.path()); + let sections: Vec<_> = (0..(MAX_SECTIONS + 1)) + .map(|i| json!({ "heading": format!("S{i}"), "paragraphs": ["x"] })) + .collect(); + let args = json!({ "title": "Big doc", "sections": sections }); + let result = tool.execute(args).await.expect("execute returns Ok"); + assert!(result.is_error); + assert!(result.text().contains(&MAX_SECTIONS.to_string())); +} + +#[tokio::test] +async fn execute_rejects_unknown_field() { + // `deny_unknown_fields` on the input structs means a stray key is a + // deserialisation error surfaced as a tool error, not a silent drop. + let ws = workspace(); + let tool = make_tool(ws.path()); + let args = json!({ "title": "Doc", "sections": [{ "paragraphs": ["x"] }], "bogus": 1 }); + let result = tool.execute(args).await.expect("execute returns Ok"); + assert!(result.is_error); +} + +#[tokio::test] +async fn execute_happy_path_returns_artifact_metadata() { + // End-to-end: drives the real docx-rs engine + artifact pipeline. + // Asserts the success contract — the artifact is finalised on disk and + // the markdown reply quotes the path + id. + let ws = workspace(); + let tool = make_tool(ws.path()); + let result = tool + .execute(minimal_input_json()) + .await + .expect("execute returns Ok"); + + assert!( + !result.is_error, + "happy path should not be flagged as error" + ); + + let payload = payload_of(&result); + assert_eq!(payload["section_count"].as_u64(), Some(1)); + let artifact_path = payload["artifact_path"] + .as_str() + .expect("artifact_path is a string"); + let artifact_id = payload["artifact_id"] + .as_str() + .expect("artifact_id is a string"); + let size_bytes = payload["size_bytes"] + .as_u64() + .expect("size_bytes is an integer"); + + assert!( + std::path::Path::new(artifact_path).exists(), + "artifact file must exist at {artifact_path}" + ); + assert!( + artifact_path.ends_with(".docx"), + "artifact should be a .docx: {artifact_path}" + ); + assert!( + size_bytes > 200, + "document unexpectedly small ({size_bytes} bytes)" + ); + + // The written file is a real, openable OOXML zip. + let bytes = std::fs::read(artifact_path).expect("artifact file readable"); + assert_eq!(&bytes[0..2], b"PK", "artifact must be a zip (PK magic)"); + + let md = result + .markdown_formatted + .as_deref() + .expect("success_with_markdown sets markdown_formatted"); + assert!(md.contains(artifact_id)); + assert!(md.contains(artifact_path)); + assert!(md.contains("1-section")); +} + +#[test] +fn truncate_stderr_caps_payload_with_suffix() { + let raw = "y".repeat(2000); + let out = DocumentError::truncate_stderr(&raw); + assert!(out.chars().count() <= 500); + assert!(out.ends_with("[…truncated]")); + let short = "tiny error"; + assert_eq!(DocumentError::truncate_stderr(short), short); +} diff --git a/src/openhuman/tools/impl/document/types.rs b/src/openhuman/tools/impl/document/types.rs new file mode 100644 index 0000000000..4917ce6374 --- /dev/null +++ b/src/openhuman/tools/impl/document/types.rs @@ -0,0 +1,456 @@ +//! Typed input / output / error contracts for the `generate_document` tool. +//! +//! Deliberately mirrors the `generate_presentation` contracts +//! (`presentation::types`) so the two artifact producers stay +//! structurally parallel: same validate-early ethos, same size caps +//! pattern, same structured `InvalidInput` the agent can self-correct +//! on. Where the presentation tool models a deck of `slides`, the +//! document tool models a linear body of ordered `sections`. + +use serde::{Deserialize, Serialize}; + +/// Maximum number of sections a single `generate_document` call may +/// produce. Hard cap to bound generation time + output size; the LLM is +/// asked to break larger documents into multiple calls. +pub(super) const MAX_SECTIONS: usize = 128; + +/// Maximum length of a single short text field (title, author, section +/// heading). Bounds the payload handed to the `docx-rs` engine. +pub(super) const MAX_TEXT_CHARS: usize = 2_000; + +/// Maximum length of a single body paragraph. Prose paragraphs run +/// longer than a slide's bullet, so this cap is more generous than +/// [`MAX_TEXT_CHARS`] while still bounding the worst-case payload. +pub(super) const MAX_PARAGRAPH_CHARS: usize = 20_000; + +/// Maximum number of body paragraphs in a single section. Beyond this a +/// section should be split; keeps one `execute` call bounded. +pub(super) const MAX_PARAGRAPHS_PER_SECTION: usize = 200; + +/// Maximum number of bullet-list items in a single section. +pub(super) const MAX_BULLETS_PER_SECTION: usize = 200; + +/// Aggregate cap on the total body text (title + author + every heading, +/// paragraph, and bullet) across the whole document, in Unicode scalar +/// values. The per-field/per-section limits above bound each piece, but +/// their product (`MAX_SECTIONS × MAX_PARAGRAPHS_PER_SECTION × +/// MAX_PARAGRAPH_CHARS` alone is ~512M chars) would still let a single +/// valid request build a multi-hundred-megabyte DOCX in memory. This +/// checked total keeps the worst-case in-memory payload bounded to a few +/// megabytes of text while remaining generous for any real document. +pub(super) const MAX_TOTAL_CHARS: usize = 2_000_000; + +/// One section of the document, rendered in input order. A section is a +/// heading (optional) followed by any number of body paragraphs and/or a +/// bullet list. At least one of the three must be populated — a wholly +/// empty section is rejected by [`validate_input`] rather than rendering +/// a blank run. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DocumentSection { + /// Section heading, rendered as a bold heading paragraph. Optional — + /// a section may be pure body/bullets under the document title. + #[serde(default)] + pub heading: Option, + /// Body paragraphs, each rendered as its own normal paragraph in + /// order. Empty / whitespace-only entries are dropped by the engine. + #[serde(default)] + pub paragraphs: Vec, + /// Bullet-list items, rendered as a single-level `•` bulleted list + /// after the section's body paragraphs. Empty / whitespace-only + /// entries are dropped by the engine. + #[serde(default)] + pub bullets: Vec, +} + +impl DocumentSection { + /// `true` when the section carries no renderable content at all + /// (heading blank/absent, and every paragraph/bullet blank). Used by + /// [`validate_input`] to reject empty sections the same way the + /// presentation tool rejects an empty slide. + pub(super) fn is_empty(&self) -> bool { + let has_heading = self + .heading + .as_deref() + .map(|h| !h.trim().is_empty()) + .unwrap_or(false); + let has_paragraph = self.paragraphs.iter().any(|p| !p.trim().is_empty()); + let has_bullet = self.bullets.iter().any(|b| !b.trim().is_empty()); + !(has_heading || has_paragraph || has_bullet) + } +} + +/// Top-level input for the `generate_document` tool. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GenerateDocumentInput { + /// Document title. Surfaces as the leading title paragraph and as the + /// artifact's human-readable name. Required, non-empty. + pub title: String, + /// Optional author byline, rendered as an italic line beneath the + /// title. + #[serde(default)] + pub author: Option, + /// Section specs, in display order. Must contain at least one entry. + #[serde(default)] + pub sections: Vec, +} + +/// Tool output returned via [`crate::openhuman::tools::traits::ToolResult`] +/// as the JSON `data` field. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerateDocumentOutput { + /// UUID of the persisted artifact record. Use with the + /// `ai_get_artifact` / `ai_delete_artifact` RPCs. + pub artifact_id: String, + /// Absolute filesystem path to the generated `.docx`. + pub artifact_path: String, + /// Number of sections rendered into the document body. + pub section_count: usize, + /// On-disk size of the produced `.docx` in bytes. + pub size_bytes: u64, +} + +/// Structured error variants surfaced to the agent. Mirrors +/// [`presentation::types::PresentationError`](super::super::presentation) +/// so downstream error handling stays uniform across artifact producers. +#[derive(Debug, thiserror::Error)] +pub enum DocumentError { + #[error("invalid input for field '{field}': {reason}")] + InvalidInput { field: String, reason: String }, + + #[error("document generation failed: {stderr_truncated}")] + GenerationFailed { stderr_truncated: String }, + + #[error("document generation exceeded {timeout_secs}s timeout")] + GenerationTimeout { timeout_secs: u64 }, +} + +impl DocumentError { + /// Truncate a library-error string to a 500-char cap (UTF-8-safe) so + /// the variant never carries an unbounded payload back to the agent. + /// Same cap/suffix as the presentation tool's `truncate_stderr`. + pub(super) fn truncate_stderr(raw: &str) -> String { + const MAX: usize = 500; + const SUFFIX: &str = " […truncated]"; + let total = raw.chars().count(); + if total <= MAX { + return raw.to_string(); + } + let keep = MAX.saturating_sub(SUFFIX.chars().count()); + let mut out: String = raw.chars().take(keep).collect(); + out.push_str(SUFFIX); + out + } +} + +/// Validate the input early — before invoking the `docx-rs` engine — so +/// the agent gets a structured `InvalidInput` it can self-correct on +/// instead of a generic engine error. +pub(super) fn validate_input(input: &GenerateDocumentInput) -> Result<(), DocumentError> { + if input.title.trim().is_empty() { + return Err(DocumentError::InvalidInput { + field: "title".to_string(), + reason: "must not be empty".to_string(), + }); + } + if input.title.chars().count() > MAX_TEXT_CHARS { + return Err(DocumentError::InvalidInput { + field: "title".to_string(), + reason: format!("must be ≤ {MAX_TEXT_CHARS} chars"), + }); + } + if let Some(author) = input.author.as_deref() { + if author.chars().count() > MAX_TEXT_CHARS { + return Err(DocumentError::InvalidInput { + field: "author".to_string(), + reason: format!("must be ≤ {MAX_TEXT_CHARS} chars"), + }); + } + } + if input.sections.is_empty() { + return Err(DocumentError::InvalidInput { + field: "sections".to_string(), + reason: "must contain at least one section".to_string(), + }); + } + if input.sections.len() > MAX_SECTIONS { + return Err(DocumentError::InvalidInput { + field: "sections".to_string(), + reason: format!("must contain ≤ {MAX_SECTIONS} sections"), + }); + } + for (i, section) in input.sections.iter().enumerate() { + // Reject wholly-empty sections: the engine trims + drops empty + // paragraph/bullet entries, so a section with only [" "] would + // render blank despite carrying entries. + if section.is_empty() { + return Err(DocumentError::InvalidInput { + field: format!("sections[{i}]"), + reason: "must have at least one of heading / paragraphs / bullets".to_string(), + }); + } + if let Some(heading) = section.heading.as_deref() { + if heading.chars().count() > MAX_TEXT_CHARS { + return Err(DocumentError::InvalidInput { + field: format!("sections[{i}].heading"), + reason: format!("must be ≤ {MAX_TEXT_CHARS} chars"), + }); + } + } + if section.paragraphs.len() > MAX_PARAGRAPHS_PER_SECTION { + return Err(DocumentError::InvalidInput { + field: format!("sections[{i}].paragraphs"), + reason: format!("must contain ≤ {MAX_PARAGRAPHS_PER_SECTION} paragraphs"), + }); + } + for (p, paragraph) in section.paragraphs.iter().enumerate() { + if paragraph.chars().count() > MAX_PARAGRAPH_CHARS { + return Err(DocumentError::InvalidInput { + field: format!("sections[{i}].paragraphs[{p}]"), + reason: format!("must be ≤ {MAX_PARAGRAPH_CHARS} chars"), + }); + } + } + if section.bullets.len() > MAX_BULLETS_PER_SECTION { + return Err(DocumentError::InvalidInput { + field: format!("sections[{i}].bullets"), + reason: format!("must contain ≤ {MAX_BULLETS_PER_SECTION} bullets"), + }); + } + for (b, bullet) in section.bullets.iter().enumerate() { + if bullet.chars().count() > MAX_PARAGRAPH_CHARS { + return Err(DocumentError::InvalidInput { + field: format!("sections[{i}].bullets[{b}]"), + reason: format!("must be ≤ {MAX_PARAGRAPH_CHARS} chars"), + }); + } + } + } + // Aggregate budget: the per-field caps above bound each piece, but not + // their sum, so a valid request could still assemble a huge in-memory DOCX. + // Sum every text field with saturating arithmetic (can't overflow) and + // reject once the whole document exceeds MAX_TOTAL_CHARS. + let mut total_chars = input.title.chars().count(); + if let Some(author) = input.author.as_deref() { + total_chars = total_chars.saturating_add(author.chars().count()); + } + for section in &input.sections { + if let Some(heading) = section.heading.as_deref() { + total_chars = total_chars.saturating_add(heading.chars().count()); + } + for paragraph in §ion.paragraphs { + total_chars = total_chars.saturating_add(paragraph.chars().count()); + } + for bullet in §ion.bullets { + total_chars = total_chars.saturating_add(bullet.chars().count()); + } + } + if total_chars > MAX_TOTAL_CHARS { + return Err(DocumentError::InvalidInput { + field: "sections".to_string(), + reason: format!("total document text must be ≤ {MAX_TOTAL_CHARS} chars"), + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// One valid section carrying a heading + a paragraph + a bullet. + fn section() -> DocumentSection { + DocumentSection { + heading: Some("Overview".to_string()), + paragraphs: vec!["A body paragraph.".to_string()], + bullets: vec!["A bullet".to_string()], + } + } + + /// A minimal valid input; individual tests mutate one field to drive a + /// single rejection branch. + fn base() -> GenerateDocumentInput { + GenerateDocumentInput { + title: "Charter".to_string(), + author: Some("Alice".to_string()), + sections: vec![section()], + } + } + + /// Assert `validate_input` rejects `input` naming `field` in the error. + fn assert_rejects(input: &GenerateDocumentInput, field: &str) { + match validate_input(input) { + Err(DocumentError::InvalidInput { field: f, .. }) => { + assert!( + f.contains(field), + "expected error field to contain {field:?}, got {f:?}" + ); + } + other => panic!("expected InvalidInput({field}), got {other:?}"), + } + } + + #[test] + fn accepts_a_well_formed_input() { + assert!(validate_input(&base()).is_ok()); + } + + #[test] + fn is_empty_reflects_content_presence() { + assert!(!section().is_empty()); + assert!(DocumentSection { + heading: Some(" ".to_string()), + paragraphs: vec![" ".to_string(), String::new()], + bullets: vec!["\t".to_string()], + } + .is_empty()); + // Any one populated field is enough. + assert!(!DocumentSection { + heading: None, + paragraphs: vec![], + bullets: vec!["x".to_string()], + } + .is_empty()); + } + + #[test] + fn rejects_empty_title() { + let mut i = base(); + i.title = " ".to_string(); + assert_rejects(&i, "title"); + } + + #[test] + fn rejects_oversize_title() { + let mut i = base(); + i.title = "t".repeat(MAX_TEXT_CHARS + 1); + assert_rejects(&i, "title"); + } + + #[test] + fn rejects_oversize_author() { + let mut i = base(); + i.author = Some("a".repeat(MAX_TEXT_CHARS + 1)); + assert_rejects(&i, "author"); + } + + #[test] + fn rejects_no_sections() { + let mut i = base(); + i.sections = vec![]; + assert_rejects(&i, "sections"); + } + + #[test] + fn rejects_too_many_sections() { + let mut i = base(); + i.sections = (0..(MAX_SECTIONS + 1)).map(|_| section()).collect(); + assert_rejects(&i, "sections"); + } + + #[test] + fn rejects_empty_section() { + let mut i = base(); + i.sections = vec![DocumentSection { + heading: Some(" ".to_string()), + paragraphs: vec![" ".to_string()], + bullets: vec![], + }]; + assert_rejects(&i, "sections[0]"); + } + + #[test] + fn rejects_oversize_heading() { + let mut i = base(); + i.sections[0].heading = Some("h".repeat(MAX_TEXT_CHARS + 1)); + assert_rejects(&i, "sections[0].heading"); + } + + #[test] + fn rejects_too_many_paragraphs() { + let mut i = base(); + i.sections[0].paragraphs = (0..(MAX_PARAGRAPHS_PER_SECTION + 1)) + .map(|n| format!("p{n}")) + .collect(); + assert_rejects(&i, "sections[0].paragraphs"); + } + + #[test] + fn rejects_oversize_paragraph() { + let mut i = base(); + i.sections[0].paragraphs = vec!["p".repeat(MAX_PARAGRAPH_CHARS + 1)]; + assert_rejects(&i, "sections[0].paragraphs[0]"); + } + + #[test] + fn rejects_too_many_bullets() { + let mut i = base(); + i.sections[0].bullets = (0..(MAX_BULLETS_PER_SECTION + 1)) + .map(|n| format!("b{n}")) + .collect(); + assert_rejects(&i, "sections[0].bullets"); + } + + #[test] + fn rejects_oversize_bullet() { + let mut i = base(); + i.sections[0].bullets = vec!["b".repeat(MAX_PARAGRAPH_CHARS + 1)]; + assert_rejects(&i, "sections[0].bullets[0]"); + } + + #[test] + fn accepts_document_exactly_at_total_char_budget() { + // 99 full paragraphs + one one-short paragraph + a 1-char title sum to + // exactly MAX_TOTAL_CHARS, while every field stays within its own cap. + let mut paragraphs: Vec = + (0..99).map(|_| "p".repeat(MAX_PARAGRAPH_CHARS)).collect(); + paragraphs.push("p".repeat(MAX_PARAGRAPH_CHARS - 1)); + assert_eq!( + 1 + 99 * MAX_PARAGRAPH_CHARS + (MAX_PARAGRAPH_CHARS - 1), + MAX_TOTAL_CHARS, + "test fixture must total exactly the budget" + ); + let input = GenerateDocumentInput { + title: "T".to_string(), + author: None, + sections: vec![DocumentSection { + heading: None, + paragraphs, + bullets: vec![], + }], + }; + assert!( + validate_input(&input).is_ok(), + "at-limit input must be accepted" + ); + } + + #[test] + fn rejects_document_over_total_char_budget() { + // 101 max-size paragraphs = 2_020_000 chars > MAX_TOTAL_CHARS, even + // though each paragraph and the section/paragraph counts stay within + // their own limits — only the aggregate budget catches it. + let mut i = base(); + i.sections[0].paragraphs = (0..101).map(|_| "p".repeat(MAX_PARAGRAPH_CHARS)).collect(); + match validate_input(&i) { + Err(DocumentError::InvalidInput { field, reason }) => { + assert_eq!(field, "sections"); + assert!( + reason.contains("total document text"), + "expected aggregate-budget error, got: {reason}" + ); + } + other => panic!("expected InvalidInput(sections), got {other:?}"), + } + } + + #[test] + fn truncate_stderr_caps_and_passes_short_through() { + let long = "z".repeat(4000); + let out = DocumentError::truncate_stderr(&long); + assert!(out.chars().count() <= 500); + assert!(out.ends_with("[…truncated]")); + assert_eq!(DocumentError::truncate_stderr("short"), "short"); + } +} diff --git a/src/openhuman/tools/impl/mod.rs b/src/openhuman/tools/impl/mod.rs index 2ede3cc3ae..0dbd542fc1 100644 --- a/src/openhuman/tools/impl/mod.rs +++ b/src/openhuman/tools/impl/mod.rs @@ -1,5 +1,6 @@ pub mod browser; pub mod computer; +pub mod document; pub mod filesystem; pub mod network; pub mod presentation; @@ -7,6 +8,7 @@ pub mod system; pub use browser::*; pub use computer::*; +pub use document::DocumentTool; pub use filesystem::*; pub use network::*; pub use presentation::PresentationTool; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index d9203d79b5..7c12a9ae46 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -696,6 +696,15 @@ pub fn all_tools_with_runtime( security.clone(), ))); + // Document generation (#4847, Problem 3). Native-Rust engine + // (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. + tools.push(Box::new(DocumentTool::new( + root_config.workspace_dir.clone(), + security.clone(), + ))); + // Long-term goals list tools. Used primarily by the background // `goals_agent` (which filters to these via its `[tools] named` // allowlist); also available to the main agent for explicit edits. From 3794b471cad11bf7ae7de2fa51aa87d300aac3af Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Thu, 16 Jul 2026 05:33:34 +0530 Subject: [PATCH 29/86] fix(agent-harness): enforce required structured-output block every turn (#4117) (#4900) Co-authored-by: Steven Enamakel --- src/openhuman/agent/harness/mod.rs | 1 + .../agent/harness/required_output.rs | 226 ++++++++ .../agent/harness/session/turn/core.rs | 53 ++ .../agent/harness/session/turn/session_io.rs | 237 ++++++++ .../agent/harness/session/turn_tests.rs | 522 ++++++++++++++++++ src/openhuman/config/mod.rs | 3 + src/openhuman/config/schema/agent.rs | 74 +++ src/openhuman/config/schema/mod.rs | 2 +- 8 files changed, 1117 insertions(+), 1 deletion(-) create mode 100644 src/openhuman/agent/harness/required_output.rs diff --git a/src/openhuman/agent/harness/mod.rs b/src/openhuman/agent/harness/mod.rs index da98fdd5ea..393eda4b40 100644 --- a/src/openhuman/agent/harness/mod.rs +++ b/src/openhuman/agent/harness/mod.rs @@ -34,6 +34,7 @@ pub(crate) mod memory_context; pub(crate) mod memory_context_safety; pub(crate) mod memory_protocol; pub(crate) mod parse; +pub(crate) mod required_output; pub mod run_queue; pub mod sandbox_context; pub mod session; diff --git a/src/openhuman/agent/harness/required_output.rs b/src/openhuman/agent/harness/required_output.rs new file mode 100644 index 0000000000..8621c647d1 --- /dev/null +++ b/src/openhuman/agent/harness/required_output.rs @@ -0,0 +1,226 @@ +//! Required structured-output validation & repair (issue #4117). +//! +//! Some agent contracts mandate a JSON block — e.g. a `thoughts` block like +//! `{"thoughts": "…", "next_action": "…"}` — on **every** turn, because +//! downstream parsing/routing depends on it. Models frequently omit the block +//! entirely, leaving those consumers with nothing. +//! +//! This module supplies the pure primitives the turn engine uses to guarantee a +//! well-formed block on every accepted turn: +//! +//! * [`output_satisfies_contract`] — validate presence + shape of the block, +//! * [`repair_instruction`] — the corrective re-prompt that asks the model to +//! re-emit its reply with the block, and +//! * [`synthesize_block`] — a minimal, schema-valid block used as a deterministic +//! fallback when the re-prompt also omits it. +//! +//! The orchestration that ties these together (validate → re-prompt → synthesize) +//! lives on the session in `session::turn::session_io` so it can drive the extra +//! provider call and reconcile with streaming; keeping the logic here pure keeps +//! it unit-testable without a provider. + +use crate::openhuman::config::RequiredOutputContract; + +/// Whether `text` satisfies `contract`: it contains a JSON object carrying every +/// required key with a non-null value, in the expected leading position. An +/// inert contract (no non-blank keys) is treated as always satisfied so +/// enforcement is a no-op. +pub(crate) fn output_satisfies_contract(text: &str, contract: &RequiredOutputContract) -> bool { + if !contract.is_active() { + return true; + } + find_required_block(text, contract).is_some() +} + +/// The required block when it appears in the expected **leading position** — +/// the *first* JSON value in `text` must be an object carrying every required +/// key with a non-null value — else `None`. +/// +/// Issue #4117 mandates the block in a predictable position so downstream +/// parsing/routing can rely on it. Prose before the block is fine (prose is not +/// JSON, so the block is still the first extracted value), but a block buried +/// after *another* JSON object is rejected and gets repaired rather than +/// silently accepted. Reuses the harness JSON extractor so fenced, inline, and +/// whole-object replies are all recognised. +pub(crate) fn find_required_block( + text: &str, + contract: &RequiredOutputContract, +) -> Option { + let keys = contract.all_keys(); + if keys.is_empty() { + return None; + } + let first = super::parse::extract_json_values(text).into_iter().next()?; + let obj = first.as_object()?; + let has_all = keys + .iter() + .all(|key| obj.get(key).is_some_and(|v| !v.is_null())); + if has_all { + Some(first) + } else { + None + } +} + +/// A minimal, schema-valid block synthesised when the model omits the block and +/// a corrective re-prompt fails to recover it. Every required key maps to an +/// empty string so downstream parsing always has a well-formed object to +/// consume. Returns `"{}"` only for an inert contract (which enforcement never +/// reaches). +pub(crate) fn synthesize_block(contract: &RequiredOutputContract) -> String { + let mut obj = serde_json::Map::new(); + for key in contract.all_keys() { + obj.insert(key, serde_json::Value::String(String::new())); + } + serde_json::to_string(&serde_json::Value::Object(obj)).unwrap_or_else(|_| "{}".to_string()) +} + +/// The corrective instruction that re-prompts the model to re-emit its reply +/// with the required block. Mirrors the iteration-cap checkpoint re-prompt: a +/// self-contained user turn appended after the omitting reply. +/// +/// The model is asked to lead with the JSON object so the repaired reply +/// satisfies the leading-position rule directly, whether the caller keeps the +/// re-prompt as the whole reply (the non-streamed *replace* path) or appends it +/// after prose that was already streamed (the *append* path); see +/// `Agent::enforce_required_output`. +pub(crate) fn repair_instruction(contract: &RequiredOutputContract) -> String { + let keys = contract.all_keys().join("\", \""); + format!( + "Your previous reply omitted the required JSON `{}` block that every turn must include. \ +Reply again, leading with a single valid JSON object containing the keys \"{}\" — all present \ +and non-null — then continue with your answer. Do not call any tools.", + contract.block_key, keys + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn thoughts_contract() -> RequiredOutputContract { + RequiredOutputContract { + block_key: "thoughts".into(), + required_keys: vec!["next_action".into()], + } + } + + #[test] + fn present_well_formed_block_satisfies_contract() { + let contract = thoughts_contract(); + let text = "Sure! {\"thoughts\": \"planning\", \"next_action\": \"call tool\"}"; + assert!(output_satisfies_contract(text, &contract)); + assert!(find_required_block(text, &contract).is_some()); + } + + #[test] + fn prose_only_reply_fails_validation() { + let contract = thoughts_contract(); + assert!(!output_satisfies_contract( + "Sure, I'll handle that.", + &contract + )); + } + + #[test] + fn block_missing_a_required_sibling_key_fails() { + let contract = thoughts_contract(); + // Has `thoughts` but not `next_action`. + let text = "{\"thoughts\": \"planning\"}"; + assert!(!output_satisfies_contract(text, &contract)); + } + + #[test] + fn null_valued_required_key_fails() { + let contract = RequiredOutputContract::new("thoughts"); + assert!(!output_satisfies_contract( + "{\"thoughts\": null}", + &contract + )); + } + + #[test] + fn synthesized_block_satisfies_its_own_contract() { + let contract = thoughts_contract(); + let synthesized = synthesize_block(&contract); + assert!( + output_satisfies_contract(&synthesized, &contract), + "synthesized fallback must satisfy the contract it was built from: {synthesized}" + ); + } + + #[test] + fn leading_block_after_prose_is_accepted() { + let contract = thoughts_contract(); + // Prose before the block is fine — prose is not JSON, so the block is + // still the first extracted value. + let text = "Here is my plan.\n{\"thoughts\": \"x\", \"next_action\": \"y\"}"; + assert!(output_satisfies_contract(text, &contract)); + } + + #[test] + fn synthesized_block_prepended_to_prose_leads_correctly() { + // The non-streamed *replace* fallback prepends a synthesized block to the + // original prose; the block must be the first JSON value so the reply + // validates. + let contract = thoughts_contract(); + let repaired = format!("{}\n\n{}", synthesize_block(&contract), "Working on it."); + assert!(output_satisfies_contract(&repaired, &contract)); + } + + #[test] + fn block_buried_after_another_json_object_is_rejected() { + let contract = thoughts_contract(); + // A different JSON object leads; the required block is second → rejected + // so it gets repaired rather than silently accepted (issue #4117). + let text = "{\"foo\": 1}\n{\"thoughts\": \"x\", \"next_action\": \"y\"}"; + assert!(!output_satisfies_contract(text, &contract)); + } + + #[test] + fn blank_block_key_makes_contract_inert() { + // A blank block key is inert even when sibling keys are listed — the + // contract's defining key can never be enforced, so enforcement is + // skipped instead of accepting a block missing that key. + let contract = RequiredOutputContract { + block_key: " ".into(), + required_keys: vec!["next_action".into()], + }; + assert!(!contract.is_active()); + assert!(output_satisfies_contract( + "{\"next_action\": \"y\"}", + &contract + )); + } + + #[test] + fn inert_contract_is_always_satisfied() { + let contract = RequiredOutputContract::default(); + assert!(!contract.is_active()); + assert!(output_satisfies_contract("no block here", &contract)); + assert!(find_required_block("no block here", &contract).is_none()); + } + + #[test] + fn all_keys_trims_and_dedupes() { + let contract = RequiredOutputContract { + block_key: " thoughts ".into(), + required_keys: vec![ + "thoughts".into(), + " next_action ".into(), + "next_action".into(), + ], + }; + // block_key trimmed; duplicate `thoughts` and repeated `next_action` + // collapse to a single occurrence each, order-preserving. + assert_eq!(contract.all_keys(), vec!["thoughts", "next_action"]); + } + + #[test] + fn repair_instruction_names_every_required_key() { + let contract = thoughts_contract(); + let instruction = repair_instruction(&contract); + assert!(instruction.contains("thoughts")); + assert!(instruction.contains("next_action")); + } +} diff --git a/src/openhuman/agent/harness/session/turn/core.rs b/src/openhuman/agent/harness/session/turn/core.rs index d27184d621..3e21abd82f 100644 --- a/src/openhuman/agent/harness/session/turn/core.rs +++ b/src/openhuman/agent/harness/session/turn/core.rs @@ -332,6 +332,23 @@ fn tool_records_from_conversation( records } +/// 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 +/// row is touched — when the tail is not an assistant `Chat` (defensive; a clean +/// finish, a cap checkpoint, and the #4093 close all end on one) a fresh +/// assistant message is appended rather than mutating an older entry. +fn replace_last_assistant_reply(history: &mut Vec, text: &str) { + match history.last_mut() { + Some(ConversationMessage::Chat(chat)) if chat.role == "assistant" => { + chat.content = text.to_string(); + } + _ => history.push(ConversationMessage::Chat(ChatMessage::assistant( + text.to_string(), + ))), + } +} + fn render_agent_context_status_note(sources: &[harness::AgentContextPreparedSource]) -> String { let sources = if sources.is_empty() { "the OpenHuman harness".to_string() @@ -1373,6 +1390,42 @@ impl Agent { } else { outcome.text.clone() }; + + // Enforce the required structured-output contract (issue #4117) on the + // accepted reply — for ALL of the branches above (normal finish, cap + // checkpoint, #4093 synthesized close), since each delivers a reply + // downstream parsing depends on. When this agent must emit a JSON block + // every turn and the reply omitted it, validate-and-repair before the + // turn is accepted, reconciling with streaming (append-only when a live + // stream is attached, replace otherwise — see `enforce_required_output`). + // The trailing assistant message is rewritten to match, and the repair + // call's usage is folded into the turn accounting. `required_output` + // defaults to `None`, so existing agents are entirely unaffected. + let reply = if let Some(contract) = self.config.required_output.clone() { + match self + .enforce_required_output( + &reply, + &contract, + effective_model, + outcome.model_calls as u32 + 1, + ) + .await + { + Some((repaired, repair_usage)) => { + if let Some(u) = repair_usage { + input_tokens += u.input_tokens; + output_tokens += u.output_tokens; + cached_input_tokens += u.cached_input_tokens; + charged_amount_usd += u.charged_amount_usd; + } + replace_last_assistant_reply(&mut self.history, &repaired); + repaired + } + None => reply, + } + } else { + reply + }; self.trim_history(); // Fold this turn's sub-agent spend into the cumulative meters and capture diff --git a/src/openhuman/agent/harness/session/turn/session_io.rs b/src/openhuman/agent/harness/session/turn/session_io.rs index 195d7ccc86..9549be90f5 100644 --- a/src/openhuman/agent/harness/session/turn/session_io.rs +++ b/src/openhuman/agent/harness/session/turn/session_io.rs @@ -236,6 +236,243 @@ impl Agent { (checkpoint, usage) } + /// Enforce this agent's required structured-output contract on the turn's + /// final `reply` (issue #4117), reconciling with streaming. + /// + /// When the contract is active and `reply` already carries a well-formed + /// leading block, returns `None` (the caller keeps `reply` unchanged). When + /// the block is omitted/invalid, the turn is **repaired** so downstream + /// parsing/routing always receives one, and the repaired text is returned as + /// `Some((repaired_text, usage))` so the caller can fold the extra call's + /// usage into the turn accounting and rewrite the trailing assistant message. + /// + /// ## Streaming reconciliation (the #4387 / sanil-23 blocker) + /// + /// The original reply is streamed to the client as `TextDelta`s *before* this + /// runs (via the harness event bridge, keyed on `on_progress`). #4387 + /// repaired with a `stream: None` re-prompt whose result then *replaced* the + /// already-streamed reply — so the client watched one answer stream in and + /// silently got a different one back. This implementation makes the repair + /// **append-only**, so the returned/persisted reply is always exactly the + /// concatenation of deltas the client saw — the live preview is a *prefix* of + /// the final message, never contradicted: + /// + /// * **Streamed case** (`on_progress` attached — interactive/user-visible): + /// the corrective re-prompt runs *silently* (its raw output is never + /// streamed, so a malformed attempt is never shown), then the chosen + /// correction — the model's block if the concatenation validates, else a + /// deterministic [`synthesize_block`] — is streamed as a continuation and + /// appended after the original prose. Visible text == returned text, and + /// the appended block is the first JSON value (prose isn't JSON), so the + /// leading-position rule holds for the dominant omitted-block case. + /// * **Non-streamed case** (`on_progress` absent — background/cron/routing, + /// the "non-user-visible" scope sanil-23 offered as the alternative): no + /// client saw anything, so there is nothing to stay consistent with. The + /// strict #4387 **replace** design applies — recover via re-prompt, else + /// prepend a synthesized block to the prose — guaranteeing strict leading + /// position. + /// + /// `iteration_for_stream` labels the streamed continuation so the UI groups + /// it with the turn's other deltas. + /// + /// [`synthesize_block`]: harness::required_output::synthesize_block + pub(in super::super) async fn enforce_required_output( + &self, + reply: &str, + contract: &crate::openhuman::config::RequiredOutputContract, + effective_model: &str, + iteration_for_stream: u32, + ) -> Option<(String, Option)> { + use harness::required_output as ro; + + if ro::output_satisfies_contract(reply, contract) { + return None; + } + log::warn!( + "[agent_loop] required output block `{}` omitted from turn reply — repairing (streamed={})", + contract.block_key, + self.on_progress.is_some(), + ); + + // Corrective re-prompt (native tools disabled), seeded with the current + // history — which already carries the omitting assistant reply, so the + // model sees exactly what it left out. Run silently: we validate the + // result before deciding whether/what to show, so a malformed attempt is + // never streamed to the client. + let mut base = self.tool_dispatcher.to_provider_messages(&self.history); + base.push(ChatMessage::user(ro::repair_instruction(contract))); + let (repair_text, usage) = self + .reprompt_for_required_block(&base, effective_model) + .await; + let repair_text = repair_text.trim().to_string(); + + // Non-streamed (replace) path: nothing was shown, so the repaired reply + // can stand alone with a strictly-leading block. + if self.on_progress.is_none() { + if !repair_text.is_empty() && ro::output_satisfies_contract(&repair_text, contract) { + log::info!( + "[agent_loop] required output block `{}` recovered via re-prompt (replace)", + contract.block_key + ); + return Some((repair_text, usage)); + } + log::warn!( + "[agent_loop] required output block `{}` still missing after re-prompt — prepending a synthesized block", + contract.block_key + ); + let synthesized = format!("{}\n\n{}", ro::synthesize_block(contract), reply); + return Some((synthesized, usage)); + } + + // Streamed (append) path: the original prose is already on the client, so + // never replace it — append a streamed correction and return the exact + // concatenation the client sees. Append ONLY the required block, not the + // whole re-prompt reply: `repair_instruction` asks the model to re-emit the + // block *and continue with its answer*, so appending the full reply would + // duplicate the already-streamed answer after the block (#4900). Prefer the + // model's own recovered block; fall back to a synthesized one otherwise. + let correction = match ro::find_required_block(&repair_text, contract) { + Some(block) => { + log::info!( + "[agent_loop] required output block `{}` recovered via re-prompt (append)", + contract.block_key + ); + serde_json::to_string(&block).unwrap_or_else(|_| ro::synthesize_block(contract)) + } + None => { + log::warn!( + "[agent_loop] required output block `{}` still missing after re-prompt — appending a synthesized block", + contract.block_key + ); + ro::synthesize_block(contract) + } + }; + // Stream only the correction as a continuation so the live preview stays + // a prefix of the final message (visible == returned). + let continuation = format!("\n\n{correction}"); + self.stream_text_continuation(&continuation, iteration_for_stream) + .await; + let repaired = format!("{reply}{continuation}"); + if !ro::output_satisfies_contract(&repaired, contract) { + // The only way to reach here is a reply that streamed a *non-conforming + // JSON object first*: append-only can't restore strict leading + // position without contradicting what the user already saw, so we + // accept the trailing valid block and keep stream consistency (the + // higher-priority invariant). Downstream still finds a conforming + // block via `extract_json_values`; only strict ordering is relaxed, + // and only for this pathological already-streamed case. + log::warn!( + "[agent_loop] required output block `{}` appended but not in leading position (streamed reply led with JSON) — accepting for stream consistency", + contract.block_key + ); + } + Some((repaired, usage)) + } + + /// Ask the provider once for a reply that includes the required + /// structured-output block, with native tools **disabled** and **without** + /// forwarding any delta to the progress sink. Returns the parsed prose paired + /// with the call's usage (empty text + `None` usage when the call fails or + /// yields only tool-call markup). + /// + /// Unlike [`summarize_turn_wrapup`](Self::summarize_turn_wrapup) this is + /// deliberately silent: `enforce_required_output` validates the result before + /// deciding what (if anything) to stream, so a malformed repair attempt is + /// never shown to the client. + async fn reprompt_for_required_block( + &self, + base_messages: &[ChatMessage], + effective_model: &str, + ) -> (String, Option) { + let chat_model = match self + .turn_model_source + .build_summarizer(effective_model, self.temperature) + { + Ok(model) => model, + Err(error) => { + tracing::error!( + error = %error, + model = effective_model, + "[agent::session] failed to build required-output re-prompt model" + ); + return (String::new(), None); + } + }; + let request = ModelRequest::new( + base_messages + .iter() + .map(crate::openhuman::tinyagents::chat_message_to_message) + .collect(), + ) + .with_model(effective_model) + .with_temperature(self.temperature) + .with_max_tokens(AGENT_TURN_MAX_OUTPUT_TOKENS); + let mut stream = match chat_model.stream(&(), request).await { + Ok(stream) => stream, + Err(error) => { + tracing::warn!( + error = %error, + model = effective_model, + "[agent::session] required-output re-prompt stream failed to start" + ); + return (String::new(), None); + } + }; + + let mut streamed_text = String::new(); + let mut completed = None; + while let Some(item) = stream.next().await { + match item { + // Buffer only — deliberately NOT forwarded to `on_progress`. + ModelStreamItem::MessageDelta(delta) if !delta.text.is_empty() => { + streamed_text.push_str(&delta.text); + } + ModelStreamItem::Completed(response) => completed = Some(response), + ModelStreamItem::Failed(error) => { + tracing::warn!(%error, "[agent::session] required-output re-prompt stream failed"); + return (String::new(), None); + } + ModelStreamItem::ProviderFailed(error) => { + tracing::warn!(error = %error.message, "[agent::session] required-output re-prompt provider failed"); + return (String::new(), None); + } + _ => {} + } + } + let Some(response) = completed else { + tracing::warn!("[agent::session] required-output re-prompt ended without completion"); + return (String::new(), None); + }; + let usage = crate::openhuman::tinyagents::model::usage_info_from_response(&response); + let text = response.text(); + let out = if !text.trim().is_empty() { + text + } else if response.tool_calls().is_empty() { + streamed_text + } else { + // Only tool-call markup was present — no usable prose. + String::new() + }; + (out, usage) + } + + /// Emit `text` to the progress sink as a `TextDelta` continuation so a + /// repaired required-output block appears in the UI appended after the + /// already-streamed reply (issue #4117). No-op when no sink is attached. + async fn stream_text_continuation(&self, text: &str, iteration: u32) { + if text.is_empty() { + return; + } + if let Some(sink) = &self.on_progress { + let _ = sink + .send(AgentProgress::TextDelta { + delta: text.to_string(), + iteration, + }) + .await; + } + } + /// Persist the exact provider messages as a session transcript. /// /// Writes JSONL as source of truth and re-renders the companion `.md` diff --git a/src/openhuman/agent/harness/session/turn_tests.rs b/src/openhuman/agent/harness/session/turn_tests.rs index 5323af2cb9..5569f14476 100644 --- a/src/openhuman/agent/harness/session/turn_tests.rs +++ b/src/openhuman/agent/harness/session/turn_tests.rs @@ -903,6 +903,528 @@ async fn turn_uses_cached_transcript_prefix_on_first_iteration() { ); } +/// Issue #4117 — a reply that already carries a well-formed leading block is +/// accepted verbatim: no corrective re-prompt, so exactly one provider call. +#[tokio::test] +async fn turn_accepts_valid_required_output_without_repair() { + let provider_impl = Arc::new(SequenceProvider { + responses: AsyncMutex::new(vec![Ok(ChatResponse { + text: Some("{\"thoughts\": \"planning\", \"next_action\": \"answer\"}".into()), + tool_calls: vec![], + usage: None, + reasoning_content: None, + })]), + requests: AsyncMutex::new(Vec::new()), + }); + let provider: Arc = provider_impl.clone(); + + let config = crate::openhuman::config::AgentConfig { + max_tool_iterations: 3, + max_history_messages: 10, + required_output: Some(crate::openhuman::config::RequiredOutputContract { + block_key: "thoughts".into(), + required_keys: vec!["next_action".into()], + }), + ..crate::openhuman::config::AgentConfig::default() + }; + + let mut agent = make_agent_with_builder( + provider, + vec![], + Box::new(FixedMemoryLoader { + context: String::new(), + }), + vec![], + config, + crate::openhuman::config::ContextConfig::default(), + ); + + let response = agent.turn("hello").await.expect("turn should succeed"); + + assert!( + response.contains("thoughts") && response.contains("next_action"), + "a valid reply must be accepted verbatim, got: {response}" + ); + // No corrective re-prompt fired — a single provider call. + assert_eq!(provider_impl.requests.lock().await.len(), 1); +} + +/// Issue #4117 — with no live progress sink (background/routing agent), when the +/// model emits prose without the mandated JSON block the turn engine re-prompts +/// and the recovered block-bearing reply *replaces* the omitting one. +#[tokio::test] +async fn turn_repairs_missing_required_output_via_reprompt() { + let provider_impl = Arc::new(SequenceProvider { + responses: AsyncMutex::new(vec![ + // Turn 1 final reply: prose only, no `thoughts` block. + Ok(ChatResponse { + text: Some("Sure, I'll handle that.".into()), + tool_calls: vec![], + usage: None, + reasoning_content: None, + }), + // Corrective re-prompt: the model now emits a valid block. + Ok(ChatResponse { + text: Some( + "{\"thoughts\": \"planning the work\", \"next_action\": \"answer\"}".into(), + ), + tool_calls: vec![], + usage: None, + reasoning_content: None, + }), + ]), + requests: AsyncMutex::new(Vec::new()), + }); + let provider: Arc = provider_impl.clone(); + + let config = crate::openhuman::config::AgentConfig { + max_tool_iterations: 3, + max_history_messages: 10, + required_output: Some(crate::openhuman::config::RequiredOutputContract { + block_key: "thoughts".into(), + required_keys: vec!["next_action".into()], + }), + ..crate::openhuman::config::AgentConfig::default() + }; + + let mut agent = make_agent_with_builder( + provider, + vec![], + Box::new(FixedMemoryLoader { + context: String::new(), + }), + vec![], + config, + crate::openhuman::config::ContextConfig::default(), + ); + + let response = agent.turn("hello").await.expect("turn should succeed"); + + // The returned reply carries the recovered block. + assert!( + response.contains("thoughts") && response.contains("next_action"), + "repaired reply must contain the required block, got: {response}" + ); + // The omitting prose reply was re-prompted (2 provider calls total). + assert_eq!(provider_impl.requests.lock().await.len(), 2); + // History's trailing assistant message was rewritten to match. + assert!(agent.history.iter().rev().any(|message| matches!( + message, + ConversationMessage::Chat(chat) + if chat.role == "assistant" && chat.content.contains("next_action") + ))); +} + +/// Issue #4117 — when the corrective re-prompt *also* omits the block (and no +/// live sink is attached), the turn engine synthesizes a minimal valid block and +/// prepends it to the original prose so the accepted turn leads with a +/// well-formed block and never loses the model's answer. +#[tokio::test] +async fn turn_synthesizes_required_output_when_reprompt_also_omits() { + let provider_impl = Arc::new(SequenceProvider { + responses: AsyncMutex::new(vec![ + // Turn 1 final reply: prose only. + Ok(ChatResponse { + text: Some("Working on it.".into()), + tool_calls: vec![], + usage: None, + reasoning_content: None, + }), + // Re-prompt: still no block. + Ok(ChatResponse { + text: Some("Still just prose, sorry.".into()), + tool_calls: vec![], + usage: None, + reasoning_content: None, + }), + ]), + requests: AsyncMutex::new(Vec::new()), + }); + let provider: Arc = provider_impl.clone(); + + let config = crate::openhuman::config::AgentConfig { + max_tool_iterations: 3, + max_history_messages: 10, + required_output: Some(crate::openhuman::config::RequiredOutputContract::new( + "thoughts", + )), + ..crate::openhuman::config::AgentConfig::default() + }; + + let mut agent = make_agent_with_builder( + provider, + vec![], + Box::new(FixedMemoryLoader { + context: String::new(), + }), + vec![], + config, + crate::openhuman::config::ContextConfig::default(), + ); + + let response = agent.turn("hello").await.expect("turn should succeed"); + + // The synthesized block is prepended to the ORIGINAL turn reply + // ("Working on it."), not the failed corrective re-prompt — so the leading + // JSON object carries the block and the original prose is preserved. + let first_block = crate::openhuman::agent::harness::parse::extract_json_values(&response) + .into_iter() + .next(); + assert!( + first_block + .as_ref() + .is_some_and(|v| v.get("thoughts").is_some()), + "synthesized reply must lead with a `thoughts` block, got: {response}" + ); + assert!(response.contains("Working on it.")); + assert_eq!(provider_impl.requests.lock().await.len(), 2); +} + +/// Issue #4117 (the #4387 / sanil-23 streaming blocker) — when a live progress +/// sink is attached (the reply was streamed to the client) and the block is +/// omitted, the repair is **append-only**: the original prose is preserved, the +/// recovered block is *appended* after it (never replacing what the user saw), +/// and the appended block is streamed as a `TextDelta` continuation so the +/// returned reply is exactly the concatenation the client watched. +#[tokio::test] +async fn turn_appends_required_output_block_when_streamed_to_preserve_consistency() { + let provider_impl = Arc::new(SequenceProvider { + responses: AsyncMutex::new(vec![ + // Turn 1 final reply: prose only, streamed to the client. + Ok(ChatResponse { + text: Some("Sure, I'll handle that.".into()), + tool_calls: vec![], + usage: None, + reasoning_content: None, + }), + // Corrective re-prompt: the model now emits a valid block. + Ok(ChatResponse { + text: Some( + "{\"thoughts\": \"planning the work\", \"next_action\": \"answer\"}".into(), + ), + tool_calls: vec![], + usage: None, + reasoning_content: None, + }), + ]), + requests: AsyncMutex::new(Vec::new()), + }); + let provider: Arc = provider_impl.clone(); + + let config = crate::openhuman::config::AgentConfig { + max_tool_iterations: 3, + max_history_messages: 10, + required_output: Some(crate::openhuman::config::RequiredOutputContract { + block_key: "thoughts".into(), + required_keys: vec!["next_action".into()], + }), + ..crate::openhuman::config::AgentConfig::default() + }; + + let mut agent = make_agent_with_builder( + provider, + vec![], + Box::new(FixedMemoryLoader { + context: String::new(), + }), + vec![], + config, + crate::openhuman::config::ContextConfig::default(), + ); + + // Attach a live progress sink and drain it concurrently so the streamed + // deltas are captured without the bounded channel ever back-pressuring the + // turn. + let (tx, mut rx) = tokio::sync::mpsc::channel(64); + let text_deltas = Arc::new(AsyncMutex::new(Vec::::new())); + let text_deltas_drain = text_deltas.clone(); + let drain = tokio::spawn(async move { + while let Some(progress) = rx.recv().await { + if let crate::openhuman::agent::progress::AgentProgress::TextDelta { delta, .. } = + progress + { + text_deltas_drain.lock().await.push(delta); + } + } + }); + agent.set_on_progress(Some(tx)); + + let response = agent.turn("hello").await.expect("turn should succeed"); + + // Drop the sink so the drain task observes channel close and finishes. + agent.set_on_progress(None); + // The continuation delta is sent (awaited) during the turn, so it is already + // drained; guard the join with a timeout so a leaked sender clone can never + // hang the test. + let _ = timeout(Duration::from_secs(5), drain).await; + + // Append-only: the original prose is preserved AND precedes the block — the + // streamed preview is a prefix of the final reply, never contradicted. + assert!( + response.contains("Sure, I'll handle that."), + "original streamed prose must be preserved, not replaced: {response}" + ); + assert!( + response.contains("next_action"), + "repaired reply must contain the required block: {response}" + ); + let prose_at = response + .find("Sure, I'll handle that.") + .expect("prose present"); + let block_at = response.find("next_action").expect("block present"); + assert!( + prose_at < block_at, + "the block must be appended AFTER the already-streamed prose: {response}" + ); + + // The appended correction was streamed as a `TextDelta` continuation. + let deltas = text_deltas.lock().await; + assert!( + deltas.iter().any(|d| d.contains("next_action")), + "the appended block must be streamed as a TextDelta continuation, got: {deltas:?}" + ); + assert_eq!(provider_impl.requests.lock().await.len(), 2); +} + +/// Issue #4117 / #4900 — streamed path where the corrective re-prompt obeys +/// `repair_instruction` literally: it leads with the block **and continues with +/// the answer** (as the prompt asks). Because the original prose is already on +/// the client, only the block may be appended — appending the whole re-prompt +/// reply would duplicate the answer. Guards against that regression. +#[tokio::test] +async fn turn_appends_only_block_not_restated_answer_when_streamed() { + let provider_impl = Arc::new(SequenceProvider { + responses: AsyncMutex::new(vec![ + // Turn 1 final reply: prose only, streamed to the client. + Ok(ChatResponse { + text: Some("Paris is the capital of France.".into()), + tool_calls: vec![], + usage: None, + reasoning_content: None, + }), + // Corrective re-prompt: block first, THEN the restated answer — exactly + // what `repair_instruction` ("…then continue with your answer") elicits. + Ok(ChatResponse { + text: Some( + "{\"thoughts\": \"restating\", \"next_action\": \"answer\"}\n\n\ + Paris is the capital of France." + .into(), + ), + tool_calls: vec![], + usage: None, + reasoning_content: None, + }), + ]), + requests: AsyncMutex::new(Vec::new()), + }); + let provider: Arc = provider_impl.clone(); + + let config = crate::openhuman::config::AgentConfig { + max_tool_iterations: 3, + max_history_messages: 10, + required_output: Some(crate::openhuman::config::RequiredOutputContract { + block_key: "thoughts".into(), + required_keys: vec!["next_action".into()], + }), + ..crate::openhuman::config::AgentConfig::default() + }; + + let mut agent = make_agent_with_builder( + provider, + vec![], + Box::new(FixedMemoryLoader { + context: String::new(), + }), + vec![], + config, + crate::openhuman::config::ContextConfig::default(), + ); + + let (tx, mut rx) = tokio::sync::mpsc::channel(64); + let text_deltas = Arc::new(AsyncMutex::new(Vec::::new())); + let text_deltas_drain = text_deltas.clone(); + let drain = tokio::spawn(async move { + while let Some(progress) = rx.recv().await { + if let crate::openhuman::agent::progress::AgentProgress::TextDelta { delta, .. } = + progress + { + text_deltas_drain.lock().await.push(delta); + } + } + }); + agent.set_on_progress(Some(tx)); + + let response = agent + .turn("what is the capital of France?") + .await + .expect("turn should succeed"); + + agent.set_on_progress(None); + let _ = timeout(Duration::from_secs(5), drain).await; + + // The block is appended and the original prose preserved … + assert!( + response.contains("next_action"), + "repaired reply must contain the required block: {response}" + ); + // … but the answer must appear exactly ONCE — the restated answer from the + // re-prompt reply must not be appended after the block. + assert_eq!( + response.matches("Paris is the capital of France.").count(), + 1, + "the answer must not be duplicated by appending the re-prompt's restated prose: {response}" + ); + // Streamed continuation carried the block, not a second copy of the answer. + let deltas = text_deltas.lock().await; + let streamed_continuation: String = deltas.concat(); + assert!( + streamed_continuation.contains("next_action"), + "the appended block must be streamed as a continuation, got: {deltas:?}" + ); + assert!( + !streamed_continuation.contains("Paris is the capital of France."), + "the streamed continuation must not restream the answer, got: {deltas:?}" + ); +} + +/// Issue #4117 — streamed path, but the corrective re-prompt *also* omits the +/// block. A synthesized block is appended (never replacing the streamed prose) +/// and streamed as a continuation, so the accepted turn still leads with the +/// original prose and carries a well-formed block. +#[tokio::test] +async fn turn_appends_synthesized_block_when_streamed_reprompt_also_omits() { + let provider_impl = Arc::new(SequenceProvider { + responses: AsyncMutex::new(vec![ + Ok(ChatResponse { + text: Some("Working on it.".into()), + tool_calls: vec![], + usage: None, + reasoning_content: None, + }), + // Re-prompt: still no block. + Ok(ChatResponse { + text: Some("Still just prose, sorry.".into()), + tool_calls: vec![], + usage: None, + reasoning_content: None, + }), + ]), + requests: AsyncMutex::new(Vec::new()), + }); + let provider: Arc = provider_impl.clone(); + + let config = crate::openhuman::config::AgentConfig { + max_tool_iterations: 3, + max_history_messages: 10, + required_output: Some(crate::openhuman::config::RequiredOutputContract::new( + "thoughts", + )), + ..crate::openhuman::config::AgentConfig::default() + }; + + let mut agent = make_agent_with_builder( + provider, + vec![], + Box::new(FixedMemoryLoader { + context: String::new(), + }), + vec![], + config, + crate::openhuman::config::ContextConfig::default(), + ); + + let (tx, mut rx) = tokio::sync::mpsc::channel(64); + let text_deltas = Arc::new(AsyncMutex::new(Vec::::new())); + let text_deltas_drain = text_deltas.clone(); + let drain = tokio::spawn(async move { + while let Some(progress) = rx.recv().await { + if let crate::openhuman::agent::progress::AgentProgress::TextDelta { delta, .. } = + progress + { + text_deltas_drain.lock().await.push(delta); + } + } + }); + agent.set_on_progress(Some(tx)); + + let response = agent.turn("hello").await.expect("turn should succeed"); + agent.set_on_progress(None); + // The continuation delta is sent (awaited) during the turn, so it is already + // drained; guard the join with a timeout so a leaked sender clone can never + // hang the test. + let _ = timeout(Duration::from_secs(5), drain).await; + + // Original prose preserved and leads; a synthesized `thoughts` block follows. + assert!(response.contains("Working on it.")); + let prose_at = response.find("Working on it.").expect("prose present"); + let block_at = response.find("thoughts").expect("synth block present"); + assert!( + prose_at < block_at, + "synthesized block must be appended after the streamed prose: {response}" + ); + let deltas = text_deltas.lock().await; + assert!( + deltas.iter().any(|d| d.contains("thoughts")), + "the synthesized block must be streamed as a continuation, got: {deltas:?}" + ); + assert_eq!(provider_impl.requests.lock().await.len(), 2); +} + +/// Issue #4117 — when the corrective re-prompt call itself fails, enforcement +/// still guarantees a well-formed block: the deterministic synthesized fallback +/// is used so the turn is never left without one (no live sink → replace path). +#[tokio::test] +async fn turn_synthesizes_required_output_when_reprompt_call_fails() { + let provider_impl = Arc::new(SequenceProvider { + responses: AsyncMutex::new(vec![ + Ok(ChatResponse { + text: Some("Working on it.".into()), + tool_calls: vec![], + usage: None, + reasoning_content: None, + }), + // Re-prompt call errors out. + Err(anyhow::anyhow!("provider boom")), + ]), + requests: AsyncMutex::new(Vec::new()), + }); + let provider: Arc = provider_impl.clone(); + + let config = crate::openhuman::config::AgentConfig { + max_tool_iterations: 3, + max_history_messages: 10, + required_output: Some(crate::openhuman::config::RequiredOutputContract::new( + "thoughts", + )), + ..crate::openhuman::config::AgentConfig::default() + }; + + let mut agent = make_agent_with_builder( + provider, + vec![], + Box::new(FixedMemoryLoader { + context: String::new(), + }), + vec![], + config, + crate::openhuman::config::ContextConfig::default(), + ); + + let response = agent.turn("hello").await.expect("turn should succeed"); + + // Deterministic fallback: a synthesized block leads, original prose kept. + let first_block = crate::openhuman::agent::harness::parse::extract_json_values(&response) + .into_iter() + .next(); + assert!( + first_block + .as_ref() + .is_some_and(|v| v.get("thoughts").is_some()), + "failed re-prompt must still yield a synthesized leading block, got: {response}" + ); + assert!(response.contains("Working on it.")); +} + #[tokio::test] async fn turn_emits_checkpoint_when_max_tool_iterations_are_exceeded() { // First response forces a tool call (consuming the single allowed diff --git a/src/openhuman/config/mod.rs b/src/openhuman/config/mod.rs index 2964e508e7..b40f1ec703 100644 --- a/src/openhuman/config/mod.rs +++ b/src/openhuman/config/mod.rs @@ -55,6 +55,9 @@ pub use schema::{ MODEL_SUMMARIZATION_V1, MODEL_VISION_V1, SEARCH_ENGINE_BRAVE, SEARCH_ENGINE_DISABLED, SEARCH_ENGINE_MANAGED, SEARCH_ENGINE_PARALLEL, SEARCH_ENGINE_QUERIT, }; +// Kept as a separate re-export (issue #4117) so the large alphabetized group +// above stays byte-identical and rustfmt-stable. +pub use schema::RequiredOutputContract; pub use schemas::{ all_controller_schemas as all_config_controller_schemas, all_registered_controllers as all_config_registered_controllers, diff --git a/src/openhuman/config/schema/agent.rs b/src/openhuman/config/schema/agent.rs index 4bf032a25f..fc6417d988 100644 --- a/src/openhuman/config/schema/agent.rs +++ b/src/openhuman/config/schema/agent.rs @@ -159,6 +159,71 @@ fn default_max_depth() -> u32 { 3 } +/// A per-agent contract requiring a structured JSON block in every turn's final +/// reply (issue #4117). +/// +/// Some agents are consumed by downstream parsing/routing that expects a +/// mandated JSON block — e.g. a `thoughts` block like +/// `{"thoughts": "…", "next_action": "…"}` — on **every** turn. Models +/// frequently omit it, leaving those consumers with nothing. When this contract +/// is set on [`AgentConfig::required_output`], the turn engine validates the +/// reply and repairs an omitted block before the turn is accepted (see +/// `crate::openhuman::agent::harness::required_output`), so consumers always get +/// a well-formed block. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct RequiredOutputContract { + /// The JSON key that identifies the required block (e.g. `"thoughts"`). The + /// contract is satisfied when the reply contains a JSON object carrying this + /// key — plus every key in [`required_keys`](Self::required_keys) — with a + /// non-null value. A blank `block_key` makes the contract inert (enforcement + /// is skipped), so it is a safe no-op default. + pub block_key: String, + /// Additional sibling keys that must also be present and non-null in the + /// same block (e.g. `["next_action"]`). The `block_key` is always required + /// and need not be repeated here. + pub required_keys: Vec, +} + +impl RequiredOutputContract { + /// Construct a contract from a block key with no extra required siblings. + pub fn new(block_key: impl Into) -> Self { + Self { + block_key: block_key.into(), + required_keys: Vec::new(), + } + } + + /// Every key that must be present — the block key followed by any declared + /// siblings — order-preserving, trimmed, and de-duplicated. Empty when the + /// contract carries no non-blank keys, in which case it is inert and the + /// turn engine skips enforcement. + pub fn all_keys(&self) -> Vec { + // The block key is the contract's defining key — a blank one makes the + // whole contract inert, even if `required_keys` lists siblings, so the + // feature never accepts or synthesizes a block missing that key. + let block_key = self.block_key.trim(); + if block_key.is_empty() { + return Vec::new(); + } + + let mut keys: Vec = vec![block_key.to_string()]; + for key in &self.required_keys { + let trimmed = key.trim(); + if !trimmed.is_empty() && !keys.iter().any(|k| k == trimmed) { + keys.push(trimmed.to_string()); + } + } + keys + } + + /// Whether this contract actually constrains output. A contract with no + /// non-blank keys is inert and enforcement is skipped. + pub fn is_active(&self) -> bool { + !self.all_keys().is_empty() + } +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(default)] pub struct AgentConfig { @@ -286,6 +351,14 @@ pub struct AgentConfig { /// [`crate::openhuman::session_import::live::shadow_reads_enabled`]. #[serde(default = "default_session_shadow_reads")] pub session_shadow_reads: bool, + + /// Optional required structured-output contract. When set to an active + /// contract, every turn's final reply must contain the mandated JSON block; + /// the turn engine validates and repairs an omitted block before the turn is + /// accepted (issue #4117). `None` (the default) disables enforcement so + /// existing agents are unaffected. + #[serde(default)] + pub required_output: Option, } fn default_session_dual_write() -> bool { @@ -427,6 +500,7 @@ impl Default for AgentConfig { agent_timeout_secs: default_agent_timeout_secs(), session_dual_write: default_session_dual_write(), session_shadow_reads: default_session_shadow_reads(), + required_output: None, } } } diff --git a/src/openhuman/config/schema/mod.rs b/src/openhuman/config/schema/mod.rs index 2bf48e97c7..f603397dd7 100644 --- a/src/openhuman/config/schema/mod.rs +++ b/src/openhuman/config/schema/mod.rs @@ -50,7 +50,7 @@ mod update; pub use accessibility::ScreenIntelligenceConfig; pub use agent::{ AgentConfig, DelegateAgentConfig, MemoryContextWindow, MemoryWindowLimits, - OrchestratorModelConfig, TeamModelConfig, + OrchestratorModelConfig, RequiredOutputContract, TeamModelConfig, }; pub use autocomplete::AutocompleteConfig; pub use autonomy::AutonomyConfig; From 8e8c5af75398074dc296669d89b3650704f0d947 Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Thu, 16 Jul 2026 05:33:52 +0530 Subject: [PATCH 30/86] perf(ui): pause mesh-gradient background when window is unfocused/hidden (#3524) (#4902) Co-authored-by: Steven Enamakel --- app/src/components/MeshGradient.test.tsx | 125 ++++++++++++++++++++--- app/src/components/MeshGradient.tsx | 60 +++++++++++ app/src/lib/meshGradient.d.ts | 7 ++ 3 files changed, 178 insertions(+), 14 deletions(-) diff --git a/app/src/components/MeshGradient.test.tsx b/app/src/components/MeshGradient.test.tsx index 4d48408dc3..0956f4ab77 100644 --- a/app/src/components/MeshGradient.test.tsx +++ b/app/src/components/MeshGradient.test.tsx @@ -1,24 +1,45 @@ import { act } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import ThemeProvider from '../providers/ThemeProvider'; import { setThemeToken, upsertCustomTheme } from '../store/themeSlice'; import { renderWithProviders } from '../test/test-utils'; import MeshGradient from './MeshGradient'; -const gradientMock = vi.hoisted(() => ({ - disconnect: vi.fn(), - // eslint-disable-next-line prefer-arrow-callback -- constructor mock must be new-able; arrows are not constructible. - Gradient: vi.fn(function MockGradient() { - return { - disconnect: gradientMock.disconnect, - initGradient: gradientMock.initGradient, - pause: gradientMock.pause, - }; - }), - initGradient: vi.fn(), - pause: vi.fn(), -})); +const gradientMock = vi.hoisted(() => { + // Shared play flag, mirroring the real `Gradient.conf.playing`, so the + // component's double-schedule guard (`shouldAnimate === playing`) is exercised. + const conf = { playing: false }; + // `state.mesh` mirrors `Gradient.mesh`: truthy once a WebGL context is + // acquired, `undefined` on no-GPU/headless. The component only calls play() + // when it is set (#3524); a getter lets a test flip it before events fire. + const state: { mesh: unknown } = { mesh: {} }; + return { + conf, + state, + disconnect: vi.fn(), + initGradient: vi.fn(), + pause: vi.fn(() => { + conf.playing = false; + }), + play: vi.fn(() => { + conf.playing = true; + }), + // eslint-disable-next-line prefer-arrow-callback -- constructor mock must be new-able; arrows are not constructible. + Gradient: vi.fn(function MockGradient() { + return { + conf, + get mesh() { + return state.mesh; + }, + disconnect: gradientMock.disconnect, + initGradient: gradientMock.initGradient, + pause: gradientMock.pause, + play: gradientMock.play, + }; + }), + }; +}); vi.mock('../lib/meshGradient', () => ({ Gradient: gradientMock.Gradient })); @@ -30,6 +51,12 @@ describe('', () => { gradientMock.Gradient.mockClear(); gradientMock.initGradient.mockClear(); gradientMock.pause.mockClear(); + gradientMock.play.mockClear(); + gradientMock.conf.playing = false; + gradientMock.state.mesh = {}; // default: WebGL mesh initialized OK + // Default to a visible, focused window so the gradient animates unless a + // test says otherwise. + vi.spyOn(document, 'hasFocus').mockReturnValue(true); rafQueue = []; vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { rafQueue.push(callback); @@ -38,6 +65,10 @@ describe('', () => { vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + function flushAnimationFrames() { const pending = [...rafQueue]; rafQueue = []; @@ -90,4 +121,70 @@ describe('', () => { expect(gradientMock.pause).toHaveBeenCalledTimes(1); expect(gradientMock.initGradient).toHaveBeenCalledTimes(2); }); + + it('pauses the animation when the window loses focus and resumes when it returns (#3524)', () => { + const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(true); + + renderWithProviders( + + + + ); + act(() => { + flushAnimationFrames(); + }); + + // Focused + visible on mount → animating. + expect(gradientMock.play).toHaveBeenCalledTimes(1); + expect(gradientMock.conf.playing).toBe(true); + gradientMock.play.mockClear(); + gradientMock.pause.mockClear(); + + // Window backgrounded (occluded/blurred) → the shader must stop rendering. + hasFocus.mockReturnValue(false); + act(() => { + window.dispatchEvent(new Event('blur')); + }); + expect(gradientMock.pause).toHaveBeenCalledTimes(1); + expect(gradientMock.conf.playing).toBe(false); + + // Window refocused → resume. + hasFocus.mockReturnValue(true); + act(() => { + window.dispatchEvent(new Event('focus')); + }); + expect(gradientMock.play).toHaveBeenCalledTimes(1); + expect(gradientMock.conf.playing).toBe(true); + }); + + it('never resumes when the WebGL mesh failed to initialize (no-GPU/Tauri, #3524)', () => { + // Simulate a gradient whose connect() couldn't get a GL context: no `mesh` + // was ever built, yet the real lib leaves `conf.playing` truthy. play() must + // stay suppressed so the animation loop never dereferences the missing mesh. + gradientMock.state.mesh = undefined; + gradientMock.conf.playing = true; + + const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(true); + renderWithProviders( + + + + ); + act(() => { + flushAnimationFrames(); + }); + + // Blur then refocus — the resume path must NOT call play() without a mesh + // (previously this crashed on the next animation frame). + hasFocus.mockReturnValue(false); + act(() => { + window.dispatchEvent(new Event('blur')); + }); + hasFocus.mockReturnValue(true); + act(() => { + window.dispatchEvent(new Event('focus')); + }); + + expect(gradientMock.play).not.toHaveBeenCalled(); + }); }); diff --git a/app/src/components/MeshGradient.tsx b/app/src/components/MeshGradient.tsx index d52358f5c9..cac150ec13 100644 --- a/app/src/components/MeshGradient.tsx +++ b/app/src/components/MeshGradient.tsx @@ -47,6 +47,40 @@ export default function MeshGradient() { } }; + // Only animate when the app window is actually visible AND focused, and the + // user hasn't asked for reduced motion. A full-screen WebGL shader that keeps + // rendering every frame while the app merely sits open in the background + // (occluded/blurred) or minimized is pure wasted GPU/CPU — the sustained + // load users see as the machine running hot with nothing happening (#3524). + const prefersReducedMotion = () => + typeof window !== 'undefined' && typeof window.matchMedia === 'function' + ? window.matchMedia('(prefers-reduced-motion: reduce)').matches + : false; + + const applyPlayState = () => { + if (!gradient) return; + const shouldAnimate = !document.hidden && document.hasFocus() && !prefersReducedMotion(); + // Read the gradient's own play flag so we never double-schedule its rAF + // loop (calling play() while already playing starts a second loop). + const playing = gradient.conf?.playing ?? false; + if (shouldAnimate === playing) return; + try { + // Only (re)start once the WebGL mesh actually initialized. On a no-GPU / + // headless environment `connect()` leaves `conf.playing` truthy without + // ever creating `mesh`, so resuming here would schedule `animate`, which + // dereferences `mesh.material` on the next frame and throws — exactly the + // environment this component is meant to tolerate (#3524). `pause()` is + // always safe (it just clears the flag / cancels any rAF). + if (shouldAnimate) { + if (gradient.mesh) gradient.play(); + } else { + gradient.pause(); + } + } catch { + // Play-state control is best-effort. + } + }; + const start = () => { if (disposed) return; const root = document.documentElement; @@ -67,6 +101,9 @@ export default function MeshGradient() { try { gradient = new Gradient(); gradient.initGradient('#mesh-gradient'); + // Honor the current visibility/focus/reduced-motion state right away, so + // a gradient created while the window is backgrounded never animates. + applyPlayState(); } catch (err) { console.warn('[MeshGradient] WebGL init failed, gradient disabled:', err); gradient = null; @@ -82,6 +119,25 @@ export default function MeshGradient() { scheduleStart(); + // Pause/resume the animation as the window gains or loses visibility/focus + // (and when the reduced-motion preference flips), so it only ever burns + // cycles while the user is actually looking at a focused OpenHuman window. + const onPlayStateChange = () => applyPlayState(); + document.addEventListener('visibilitychange', onPlayStateChange); + window.addEventListener('focus', onPlayStateChange); + window.addEventListener('blur', onPlayStateChange); + let removeMotionListener: (() => void) | undefined; + if (typeof window !== 'undefined' && window.matchMedia) { + const motionMq = window.matchMedia('(prefers-reduced-motion: reduce)'); + if (motionMq.addEventListener) { + motionMq.addEventListener('change', onPlayStateChange); + removeMotionListener = () => motionMq.removeEventListener('change', onPlayStateChange); + } else { + motionMq.addListener(onPlayStateChange); + removeMotionListener = () => motionMq.removeListener(onPlayStateChange); + } + } + let removeSystemListener: (() => void) | undefined; if (variant === 'system' && typeof window !== 'undefined' && window.matchMedia) { const mq = window.matchMedia('(prefers-color-scheme: dark)'); @@ -98,6 +154,10 @@ export default function MeshGradient() { return () => { disposed = true; removeSystemListener?.(); + removeMotionListener?.(); + document.removeEventListener('visibilitychange', onPlayStateChange); + window.removeEventListener('focus', onPlayStateChange); + window.removeEventListener('blur', onPlayStateChange); window.cancelAnimationFrame(raf); disconnectGradient(); }; diff --git a/app/src/lib/meshGradient.d.ts b/app/src/lib/meshGradient.d.ts index c53c0b70f8..6b6621fb18 100644 --- a/app/src/lib/meshGradient.d.ts +++ b/app/src/lib/meshGradient.d.ts @@ -5,6 +5,13 @@ export interface GradientConfig { export class Gradient { el?: HTMLCanvasElement; conf?: GradientConfig; + /** + * The WebGL mesh. Only set once `connect()` acquires a GL context and builds + * the geometry; stays `undefined` on no-GPU / headless environments. Callers + * must check it before `play()`, since the animation loop dereferences + * `mesh.material` and would throw when it is absent (#3524). + */ + mesh?: unknown; play(): void; pause(): void; disconnect(): void; From b278f8eb59285bb75fdf175b2ca8354c2d0ec69a Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Thu, 16 Jul 2026 05:34:14 +0530 Subject: [PATCH 31/86] feat(accessibility): ranked UI-element matcher for reliable selection (#3202 slice 1) (#4905) Co-authored-by: Steven Enamakel --- src/openhuman/accessibility/ax_interact.rs | 43 +++- src/openhuman/accessibility/element_match.rs | 189 ++++++++++++++++ .../accessibility/element_match_tests.rs | 205 ++++++++++++++++++ src/openhuman/accessibility/mod.rs | 5 + 4 files changed, 434 insertions(+), 8 deletions(-) create mode 100644 src/openhuman/accessibility/element_match.rs create mode 100644 src/openhuman/accessibility/element_match_tests.rs diff --git a/src/openhuman/accessibility/ax_interact.rs b/src/openhuman/accessibility/ax_interact.rs index 8699911d23..03e3c37a57 100644 --- a/src/openhuman/accessibility/ax_interact.rs +++ b/src/openhuman/accessibility/ax_interact.rs @@ -107,15 +107,42 @@ pub fn ax_list_elements_filtered(app_name: &str, filter: &str) -> Result = resp - .get("elements") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or_default(); - let needle = filter.trim().to_lowercase(); - if !needle.is_empty() { - elements.retain(|e| e.label.to_lowercase().contains(&needle)); + // Parse the helper's element array. A decode failure means malformed + // helper output, not "no UI" — surface it in the log instead of silently + // collapsing to an empty list (which reads downstream as an app with no + // accessible elements). Behavior is unchanged (still degrades to empty). + let elements: Vec = match resp.get("elements") { + Some(raw) => serde_json::from_value(raw.clone()).unwrap_or_else(|e| { + log::warn!( + "[ax_interact] list: failed to decode elements for '{app_name}': {e} — treating as empty" + ); + Vec::new() + }), + None => { + log::debug!("[ax_interact] list: helper returned no 'elements' field for '{app_name}'"); + Vec::new() + } + }; + let needle = filter.trim(); + if needle.is_empty() { + log::debug!( + "[ax_interact] list: '{app_name}' unfiltered → {} elements", + elements.len() + ); + Ok(elements) + } else { + // Rank the matches best-first (exact → prefix → substring) instead of + // returning raw tree order. Same membership a `contains` filter kept, + // but the tool's fixed top-N render cap now keeps the *best* N — the + // reliable-selection half of #3202. + let total = elements.len(); + let ranked = super::element_match::filter_and_rank(elements, needle); + log::debug!( + "[ax_interact] list: '{app_name}' filter={needle:?} → {} of {total} elements matched (ranked best-first)", + ranked.len() + ); + Ok(ranked) } - Ok(elements) } #[cfg(target_os = "windows")] { diff --git a/src/openhuman/accessibility/element_match.rs b/src/openhuman/accessibility/element_match.rs new file mode 100644 index 0000000000..45af611d71 --- /dev/null +++ b/src/openhuman/accessibility/element_match.rs @@ -0,0 +1,189 @@ +//! Ranked, normalized matching of accessibility elements against a target label. +//! +//! Element selection today is a naive case-insensitive substring `contains` that +//! takes the *first* hit in tree order: `ax_list_elements_filtered` filters that +//! way, and the Swift helper's press path matches "exact-first, else the first +//! `contains`". That is unreliable for the "reliable UI element clicking" #3202 +//! calls out, in two ways: +//! +//! 1. **Ambiguity** — several controls share a substring. Filtering for `Save` +//! also surfaces `Save As…` and `Autosave`, and "first in tree order" is not +//! the same as "the control the user meant". +//! 2. **Near-misses** — the model asks for a label that is *almost* right: +//! different case, surrounding whitespace, a trailing `…` on a menu item, or +//! an extra space the AX label collapses. A literal `contains` misses these +//! even though a human reads them as the same target. +//! +//! This module ranks candidates by match quality ([`MatchTier`]) so the best, +//! least-ambiguous element sorts first. `ax_list_elements_filtered` uses +//! [`filter_and_rank`] to order (and, with the tool's top-N cap, *keep*) the best +//! matches; [`best_match`] additionally reports whether the top match is +//! ambiguous, the reusable primitive a later press-disambiguation slice can use +//! to ask the user which control they meant instead of clicking the wrong one. +//! +//! Everything here is pure — no AX/FFI, no clock — so it is exhaustively +//! unit-tested and shared verbatim by every backend. + +use super::ax_interact::AXElement; + +/// How well a candidate label matches a requested label, best (`Exact`) to worst +/// (`Substring`). The derived `Ord` orders variants by declaration, so an +/// ascending sort places the strongest match first. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum MatchTier { + /// Byte-for-byte identical. + Exact, + /// Equal ignoring ASCII case (`Save` vs `save`). + CaseInsensitiveExact, + /// Equal after normalization — case-folded, whitespace-collapsed, trailing + /// ellipsis stripped (`Save As…` vs `save as`). + NormalizedExact, + /// Normalized candidate starts with the normalized query (`Save As…` for + /// query `save`). + Prefix, + /// Normalized query appears at a word boundary inside the candidate + /// (`Discard changes` for query `changes`). + WordBoundary, + /// Normalized query appears somewhere inside the candidate (weakest signal). + Substring, +} + +/// The chosen element plus why it was chosen and whether the choice was +/// contested. `ambiguous` is `true` when another candidate tied at the same +/// [`MatchTier`] with a *different* normalized label — i.e. the query alone +/// cannot say which control the user meant. +#[derive(Debug, Clone, Copy)] +pub struct ElementMatch<'a> { + pub element: &'a AXElement, + pub tier: MatchTier, + pub ambiguous: bool, +} + +/// Fold a label into its comparable form: trimmed, lowercased, trailing ellipsis +/// (`…` or `...`) removed, and internal whitespace runs collapsed to one space. +/// +/// This is deliberately conservative — it never strips role words like `button` +/// (which would create false matches), only the incidental differences a human +/// reads through. +pub(crate) fn normalize(s: &str) -> String { + let mut t = s.trim().to_lowercase(); + // Strip any run of trailing ellipsis markers (menu items commonly end in one), + // re-trimming trailing space between passes so "Save …" reduces cleanly. + loop { + let trimmed = t.trim_end(); + if let Some(stripped) = trimmed + .strip_suffix('…') + .or_else(|| trimmed.strip_suffix("...")) + { + t = stripped.to_string(); + } else { + t = trimmed.to_string(); + break; + } + } + // Collapse internal whitespace and drop leading/trailing space in one pass. + t.split_whitespace().collect::>().join(" ") +} + +/// `true` when `needle` occurs in `haystack` as a whole word run — delimited by +/// the string edge or a non-alphanumeric char on **both** sides. Both inputs are +/// expected already normalized. +/// +/// Both boundaries matter: a leading-only check would let `changes` match inside +/// `changeset` (partial word). Requiring a trailing boundary too keeps +/// [`MatchTier::WordBoundary`] to genuine word matches; partial-word hits fall +/// through to the weaker [`MatchTier::Substring`]. +fn is_word_boundary_match(haystack: &str, needle: &str) -> bool { + if needle.is_empty() { + return false; + } + haystack.match_indices(needle).any(|(i, m)| { + let before_ok = haystack[..i] + .chars() + .next_back() + .is_none_or(|c| !c.is_alphanumeric()); + let after_ok = haystack[i + m.len()..] + .chars() + .next() + .is_none_or(|c| !c.is_alphanumeric()); + before_ok && after_ok + }) +} + +/// Classify how `candidate` matches `query`, or `None` if it does not match at +/// all. A blank query matches nothing (an empty needle would otherwise match +/// every control). +pub(crate) fn classify(candidate: &str, query: &str) -> Option { + if query.trim().is_empty() { + return None; + } + if candidate == query { + return Some(MatchTier::Exact); + } + if candidate.eq_ignore_ascii_case(query) { + return Some(MatchTier::CaseInsensitiveExact); + } + let nc = normalize(candidate); + let nq = normalize(query); + if nq.is_empty() { + return None; + } + if nc == nq { + return Some(MatchTier::NormalizedExact); + } + if nc.starts_with(&nq) { + return Some(MatchTier::Prefix); + } + if is_word_boundary_match(&nc, &nq) { + return Some(MatchTier::WordBoundary); + } + if nc.contains(&nq) { + return Some(MatchTier::Substring); + } + None +} + +/// Keep only the elements that match `query` and return them best-match-first. +/// +/// Membership is the same set a case-insensitive `contains` would keep (plus the +/// few near-misses normalization reconciles); the win is ordering — with the +/// tool's fixed top-N render cap, "best first" means the cap keeps the *best* N +/// rather than an arbitrary N. Ordering is stable: candidates in the same tier +/// keep their original tree order, so the result is deterministic. +pub(crate) fn filter_and_rank(elements: Vec, query: &str) -> Vec { + let mut ranked: Vec<(MatchTier, usize, AXElement)> = elements + .into_iter() + .enumerate() + .filter_map(|(idx, el)| classify(&el.label, query).map(|tier| (tier, idx, el))) + .collect(); + ranked.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1))); + ranked.into_iter().map(|(_, _, el)| el).collect() +} + +/// Pick the single best element for `query`, reporting its [`MatchTier`] and +/// whether the choice was ambiguous. `None` when nothing matches. +pub fn best_match<'a>(elements: &'a [AXElement], query: &str) -> Option> { + let mut ranked: Vec<(MatchTier, usize, &'a AXElement)> = elements + .iter() + .enumerate() + .filter_map(|(idx, el)| classify(&el.label, query).map(|tier| (tier, idx, el))) + .collect(); + ranked.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1))); + let &(best_tier, _, best_el) = ranked.first()?; + let best_norm = normalize(&best_el.label); + // Contested only when a same-tier rival names a *different* control; two + // candidates whose labels normalize identically are the same target. + let ambiguous = ranked + .iter() + .filter(|(tier, _, _)| *tier == best_tier) + .any(|(_, _, el)| normalize(&el.label) != best_norm); + Some(ElementMatch { + element: best_el, + tier: best_tier, + ambiguous, + }) +} + +#[cfg(test)] +#[path = "element_match_tests.rs"] +mod tests; diff --git a/src/openhuman/accessibility/element_match_tests.rs b/src/openhuman/accessibility/element_match_tests.rs new file mode 100644 index 0000000000..c0f0118995 --- /dev/null +++ b/src/openhuman/accessibility/element_match_tests.rs @@ -0,0 +1,205 @@ +//! Unit tests for the pure element matcher. No AX/FFI — runs on every platform. + +use super::{best_match, classify, filter_and_rank, normalize, MatchTier}; +use crate::openhuman::accessibility::ax_interact::AXElement; + +fn el(role: &str, label: &str) -> AXElement { + AXElement::new(role, label) +} + +// --- normalize ----------------------------------------------------------- + +#[test] +fn normalize_lowercases_and_trims() { + assert_eq!(normalize(" Save As "), "save as"); +} + +#[test] +fn normalize_strips_trailing_ellipsis_unicode_and_ascii() { + assert_eq!(normalize("Save As…"), "save as"); + assert_eq!(normalize("Save As..."), "save as"); + assert_eq!(normalize("Save As … "), "save as"); + // Repeated / stacked ellipsis runs collapse fully. + assert_eq!(normalize("Export……"), "export"); +} + +#[test] +fn normalize_collapses_internal_whitespace() { + assert_eq!(normalize("New\t Folder"), "new folder"); +} + +#[test] +fn normalize_of_blank_is_empty() { + assert_eq!(normalize(" "), ""); + assert_eq!(normalize("…"), ""); +} + +// --- classify: tiers ----------------------------------------------------- + +#[test] +fn classify_exact_beats_everything() { + assert_eq!(classify("Save", "Save"), Some(MatchTier::Exact)); +} + +#[test] +fn classify_case_insensitive_exact() { + assert_eq!( + classify("Save", "save"), + Some(MatchTier::CaseInsensitiveExact) + ); + assert_eq!( + classify("SAVE", "save"), + Some(MatchTier::CaseInsensitiveExact) + ); +} + +#[test] +fn classify_normalized_exact_handles_ellipsis_and_spacing() { + assert_eq!( + classify("Save As…", "save as"), + Some(MatchTier::NormalizedExact) + ); + assert_eq!( + classify(" Save ", "save"), + Some(MatchTier::NormalizedExact) + ); +} + +#[test] +fn classify_prefix() { + assert_eq!(classify("Save As…", "save"), Some(MatchTier::Prefix)); +} + +#[test] +fn classify_word_boundary_not_prefix() { + // "changes" starts a word inside the label but is not a prefix of it. + assert_eq!( + classify("Discard changes", "changes"), + Some(MatchTier::WordBoundary) + ); +} + +#[test] +fn classify_substring_is_weakest() { + // "ave" sits mid-word — only a raw substring, not a boundary match. + assert_eq!(classify("Save", "ave"), Some(MatchTier::Substring)); +} + +#[test] +fn classify_partial_word_is_substring_not_word_boundary() { + // "changes" starts a word in "changeset" but does not end at a boundary, so + // it must NOT rank as a word-boundary match — only the weaker substring. + assert_eq!( + classify("Discard changeset", "changes"), + Some(MatchTier::Substring) + ); +} + +#[test] +fn classify_non_match_is_none() { + assert_eq!(classify("Cancel", "submit"), None); +} + +#[test] +fn classify_blank_query_matches_nothing() { + assert_eq!(classify("Anything", ""), None); + assert_eq!(classify("Anything", " "), None); + // A query that is *only* an ellipsis normalizes to empty → no match. + assert_eq!(classify("Anything", "…"), None); +} + +// --- filter_and_rank ----------------------------------------------------- + +#[test] +fn filter_and_rank_orders_best_first() { + let elements = vec![ + el("AXMenuItem", "Autosave"), // substring + el("AXMenuItem", "Save As…"), // prefix + el("AXButton", "Save"), // case-insensitive exact + ]; + let ranked = filter_and_rank(elements, "save"); + let labels: Vec<&str> = ranked.iter().map(|e| e.label.as_str()).collect(); + assert_eq!(labels, vec!["Save", "Save As…", "Autosave"]); +} + +#[test] +fn filter_and_rank_drops_non_matches() { + let elements = vec![ + el("AXButton", "Save"), + el("AXButton", "Cancel"), + el("AXButton", "Delete"), + ]; + let ranked = filter_and_rank(elements, "save"); + assert_eq!(ranked.len(), 1); + assert_eq!(ranked[0].label, "Save"); +} + +#[test] +fn filter_and_rank_is_stable_within_a_tier() { + // Two equally-good substring matches keep their original tree order. + let elements = vec![ + el("AXButton", "first tab option"), + el("AXButton", "second tab option"), + ]; + let ranked = filter_and_rank(elements, "option"); + let labels: Vec<&str> = ranked.iter().map(|e| e.label.as_str()).collect(); + assert_eq!(labels, vec!["first tab option", "second tab option"]); +} + +#[test] +fn filter_and_rank_empty_query_keeps_nothing() { + let elements = vec![el("AXButton", "Save")]; + assert!(filter_and_rank(elements, "").is_empty()); +} + +// --- best_match ---------------------------------------------------------- + +#[test] +fn best_match_none_when_no_candidates() { + let elements = vec![el("AXButton", "Cancel")]; + assert!(best_match(&elements, "submit").is_none()); +} + +#[test] +fn best_match_picks_top_tier_and_is_unambiguous_when_alone() { + let elements = vec![el("AXButton", "Save"), el("AXMenuItem", "Save As…")]; + let m = best_match(&elements, "save").expect("should match"); + assert_eq!(m.element.label, "Save"); + assert_eq!(m.tier, MatchTier::CaseInsensitiveExact); + // The other candidate is a weaker (Prefix) tier, so the top tier is a clean win. + assert!(!m.ambiguous); +} + +#[test] +fn best_match_flags_ambiguity_on_a_same_tier_tie() { + // Both are prefix matches for "save" but name different controls. + let elements = vec![el("AXMenuItem", "Save As…"), el("AXMenuItem", "Save All")]; + let m = best_match(&elements, "save").expect("should match"); + assert_eq!(m.tier, MatchTier::Prefix); + assert!(m.ambiguous); +} + +#[test] +fn best_match_same_tier_same_label_is_not_ambiguous() { + // Both land in the SAME non-trivial tier (NormalizedExact — neither is a + // literal Exact/CaseInsensitiveExact hit) yet normalize identically, so the + // same-tier rival comparison in `best_match` runs and finds no real rival. + let elements = vec![el("AXButton", "Save As…"), el("AXMenuItem", "Save As")]; + let m = best_match(&elements, "Save As").expect("should match"); + assert_eq!(m.tier, MatchTier::NormalizedExact); + assert!(!m.ambiguous); +} + +#[test] +fn best_match_prefers_exact_over_ambiguous_weaker_matches() { + let elements = vec![ + el("AXMenuItem", "Save As…"), + el("AXButton", "Save"), + el("AXMenuItem", "Save All"), + ]; + let m = best_match(&elements, "Save").expect("should match"); + assert_eq!(m.element.label, "Save"); + assert_eq!(m.tier, MatchTier::Exact); + // Exact tier has exactly one member even though weaker tiers are contested. + assert!(!m.ambiguous); +} diff --git a/src/openhuman/accessibility/mod.rs b/src/openhuman/accessibility/mod.rs index a88ba08607..f690fe577b 100644 --- a/src/openhuman/accessibility/mod.rs +++ b/src/openhuman/accessibility/mod.rs @@ -10,6 +10,10 @@ pub mod automate; mod automation_state; pub mod ax_interact; 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. +mod element_match; mod focus; mod globe; mod helper; @@ -32,6 +36,7 @@ pub use automation_state::{ clear as clear_automation_denial, mark_system_events_denied, system_events_denied, }; pub use capture::{capture_screen_image_ref_for_context, CaptureMode, MAX_SCREENSHOT_BYTES}; +pub use element_match::{best_match, ElementMatch, MatchTier}; pub use focus::{ focused_text_context, focused_text_context_verbose, foreground_context, parse_foreground_output, validate_focused_target, From 01bb34d23afff75aa4f0ded721ea8bcd3fce5bd8 Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Thu, 16 Jul 2026 05:34:23 +0530 Subject: [PATCH 32/86] perf(services): start login-gated services concurrently to unblock command registration (#3490) (#4906) Co-authored-by: Steven Enamakel --- src/openhuman/credentials/ops.rs | 182 ++++++++++++++++++++++--- src/openhuman/credentials/ops_tests.rs | 53 +++++++ src/openhuman/voice/always_on.rs | 31 ++++- src/openhuman/voice/server.rs | 8 +- 4 files changed, 248 insertions(+), 26 deletions(-) diff --git a/src/openhuman/credentials/ops.rs b/src/openhuman/credentials/ops.rs index d7fe044e53..98203bbeb6 100644 --- a/src/openhuman/credentials/ops.rs +++ b/src/openhuman/credentials/ops.rs @@ -29,39 +29,181 @@ const AUTH_ME_STORE_TRANSIENT_STATUSES: &[u16] = &[408, 429, 500, 502, 503, 504, /// (when an existing session is detected) and from `store_session()` on /// fresh login. pub async fn start_login_gated_services(config: &Config) { - // 1. Local AI (Ollama, whisper, embeddings) - if config.local_ai.runtime_enabled { - let service = crate::openhuman::inference::local::global(config); - service.bootstrap(config).await; - log::info!("[services] local AI bootstrapped after login"); + // These login-gated services are mutually independent — the ONLY ordering + // constraint is voice-server → standalone-dictation-listener (they contend + // for the single rdev global listener on macOS). Previously each was + // `.await`ed in series, so their cold-start costs SUMMED: the local-AI + // bootstrap (Ollama/whisper/embeddings) + the Windows WASAPI microphone init + // (a synchronous readiness handshake in `always_on::spawn_capture_thread`) + + // the screen-capture server + the hosted-client network sync stacked into + // the ~10s stall users hit before hotkeys/commands were usable — worst on + // Windows (#3490). Worse, the hotkey/command registration (steps 2–3) sat + // *after* the local-AI bootstrap in the series, so commands could not + // register until Ollama/whisper finished warming. + // + // Run them concurrently on independent tasks instead: readiness is bounded + // by the slowest single service rather than their sum, and command + // registration no longer waits behind local-AI warm-up. Each task logs its + // own elapsed time so a future regression can be attributed to one stage; a + // panic in one service is logged on join and never aborts the others. + + // Unit tests must not launch the real login-gated background services: they + // are detached, long-lived loops (hosted-client read-sync + world-diff + // uploader + one-shot history migration, continuous audio capture, screen + // capture) that outlive the test that spawned them and interleave with the + // shared process state (HOME / active_user.toml) of the parallel `cargo + // test` run. Once startup became concurrent (#3490) that interleaving made + // the session-isolation tests order-dependent. `cfg!(test)` is compiled out + // of every production/release build, so this gate never affects shipped + // behavior; the one test that verifies this function's concurrency opts back + // in via `OPENHUMAN_RUN_LOGIN_GATED_SERVICES_IN_TEST`. + if cfg!(test) && std::env::var_os("OPENHUMAN_RUN_LOGIN_GATED_SERVICES_IN_TEST").is_none() { + log::debug!("[services] login-gated services skipped under unit test"); + return; } - // 2. Voice server (records + transcribes via hotkey) - crate::openhuman::voice::server::start_if_enabled(config).await; + let started = std::time::Instant::now(); + // (service label, task) pairs so a panic surfaced on join is attributed to + // the specific stage rather than an anonymous "a service failed". + let mut tasks: Vec<(&'static str, tokio::task::JoinHandle<()>)> = Vec::new(); - // 3. Dictation hotkey listener (only when voice server is NOT auto-started, - // since the voice server owns the single rdev listener on macOS) - if !config.voice_server.auto_start { - crate::openhuman::voice::dictation_listener::start_if_enabled(config).await; + // 1. Local AI (Ollama, whisper, embeddings) — the heaviest single warm-up, + // so keeping it off the critical path for the others is the biggest win. + { + let config = config.clone(); + tasks.push(( + "local_ai", + tokio::spawn(async move { + if config.local_ai.runtime_enabled { + let step = std::time::Instant::now(); + log::debug!("[services] local AI bootstrap starting"); + crate::openhuman::inference::local::global(&config) + .bootstrap(&config) + .await; + log::debug!( + "[services] local AI bootstrapped after login ({} ms)", + step.elapsed().as_millis() + ); + } else { + log::debug!("[services] local AI disabled — skipping bootstrap"); + } + }), + )); } - // 3b. Always-on listening (Phase 2): continuous mic + VAD → STT → agent, - // no hotkey. Opt-in via config.voice_server.always_on_enabled. - crate::openhuman::voice::always_on::start_if_enabled(config).await; + // 2+3. Voice hotkey services — the user-facing command registration. The + // embedded voice server owns the single rdev listener; the standalone + // dictation listener only starts when the server is NOT auto-starting, + // so keep these two ordered *relative to each other* (but concurrent + // with everything else). + { + let config = config.clone(); + tasks.push(( + "voice_hotkey", + tokio::spawn(async move { + let step = std::time::Instant::now(); + crate::openhuman::voice::server::start_if_enabled(&config).await; + if !config.voice_server.auto_start { + crate::openhuman::voice::dictation_listener::start_if_enabled(&config).await; + } + log::debug!( + "[services] voice hotkey services registered ({} ms)", + step.elapsed().as_millis() + ); + }), + )); + } + + // 3b. Always-on listening (Phase 2): continuous mic + VAD → STT → agent. + // Its cold WASAPI init (`always_on::spawn_capture_thread`) is the + // Windows-specific blocker; it now runs the blocking capture-readiness + // handshake on the blocking pool (see `always_on::start_if_enabled`), so + // on its own task it neither stalls an async worker nor the hotkey / + // command registration above. + { + let config = config.clone(); + tasks.push(( + "always_on", + tokio::spawn(async move { + let step = std::time::Instant::now(); + crate::openhuman::voice::always_on::start_if_enabled(&config).await; + log::debug!( + "[services] always-on listening started ({} ms)", + step.elapsed().as_millis() + ); + }), + )); + } - // 4. Screen intelligence (capture + vision analysis) - crate::openhuman::screen_intelligence::server::start_if_enabled(config).await; + // 4. Screen intelligence (capture + vision analysis). + { + let config = config.clone(); + tasks.push(( + "screen_intelligence", + tokio::spawn(async move { + let step = std::time::Instant::now(); + crate::openhuman::screen_intelligence::server::start_if_enabled(&config).await; + log::debug!( + "[services] screen intelligence started ({} ms)", + step.elapsed().as_millis() + ); + }), + )); + } - // 5. Autocomplete (text suggestions + Swift overlay helper) - crate::openhuman::autocomplete::start_if_enabled(config).await; + // 5. Autocomplete (text suggestions + Swift overlay helper). + { + let config = config.clone(); + tasks.push(( + "autocomplete", + tokio::spawn(async move { + let step = std::time::Instant::now(); + crate::openhuman::autocomplete::start_if_enabled(&config).await; + log::debug!( + "[services] autocomplete started ({} ms)", + step.elapsed().as_millis() + ); + }), + )); + } // 6. Orchestration hosted-client: read-sync loop + world-diff uploader + // one-shot history migration. Idempotent (aborts a prior session's loops // first); no-op when orchestration is disabled. Runs here so both startup // (already logged in) and a fresh login start the hosted-client tail. - crate::openhuman::orchestration::start_hosted_client_services(config).await; + { + let config = config.clone(); + tasks.push(( + "orchestration", + tokio::spawn(async move { + let step = std::time::Instant::now(); + crate::openhuman::orchestration::start_hosted_client_services(&config).await; + log::debug!( + "[services] orchestration hosted-client started ({} ms)", + step.elapsed().as_millis() + ); + }), + )); + } - log::info!("[services] all login-gated services started"); + let total = tasks.len(); + let mut failed = 0usize; + for (name, task) in tasks { + if let Err(err) = task.await { + failed += 1; + log::warn!("[services] login-gated service '{name}' panicked during startup: {err}"); + } + } + let elapsed_ms = started.elapsed().as_millis(); + if failed == 0 { + log::info!( + "[services] all {total} login-gated services started concurrently ({elapsed_ms} ms)" + ); + } else { + log::warn!( + "[services] {failed}/{total} login-gated services failed to start ({elapsed_ms} ms)" + ); + } } /// Stop all login-gated background services. Called from `clear_session()` diff --git a/src/openhuman/credentials/ops_tests.rs b/src/openhuman/credentials/ops_tests.rs index 78c2d2a313..aa85b013bb 100644 --- a/src/openhuman/credentials/ops_tests.rs +++ b/src/openhuman/credentials/ops_tests.rs @@ -1090,3 +1090,56 @@ async fn each_account_workspace_holds_its_own_credential_data() { assert_eq!(result_a.value[0].provider, "anthropic"); assert_eq!(result_b.value[0].provider, "anthropic"); } + +/// #3490 regression: `start_login_gated_services` must launch its services +/// concurrently (independent `tokio::spawn` tasks) and return, rather than +/// awaiting them serially. With an all-disabled config every `start_if_enabled` +/// is a no-op, so this drives the spawn/await/join orchestration (the changed +/// code path) deterministically — no microphone, model, or network is touched — +/// and asserts the function completes promptly instead of blocking. +/// +/// A serial regression here would still pass functionally, but the concurrent +/// structure is what collapses the readiness latency from the sum of the +/// per-service cold-starts to the slowest single one; this test guards that the +/// orchestration keeps returning (and never deadlocks/panics on join). +#[tokio::test] +async fn start_login_gated_services_completes_with_all_services_disabled() { + // Serialize with the other env-mutating tests: this test sets a process-wide + // opt-in env var below, and the lock keeps it from leaking into a + // concurrently-running `store_session` test that would then start real + // background services. (These are the same semantics `TEST_ENV_LOCK` gives + // the HOME-mutating tests.) + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let tmp = TempDir::new().unwrap(); + // Under `#[cfg(test)]` `start_login_gated_services` skips the real services + // by default (they leak across the parallel test run); opt this one test + // back in so it actually drives the concurrent spawn/await path it guards. + // Only presence is checked, so the value (a temp path) is irrelevant. + let _run_services = + EnvVarGuard::set_to_path("OPENHUMAN_RUN_LOGIN_GATED_SERVICES_IN_TEST", tmp.path()); + + let mut config = Config::default(); + // Every service is disabled so each `start_if_enabled` is a no-op: the test + // exercises the concurrent spawn/await machinery (the changed code) without + // touching the mic, a model, the screen, or the network. orchestration + // defaults ENABLED, so it must be turned off explicitly; the rest are set + // too, independent of future default changes. + config.local_ai.runtime_enabled = false; + config.voice_server.auto_start = false; + config.voice_server.always_on_enabled = false; + config.screen_intelligence.enabled = false; + config.autocomplete.enabled = false; + config.orchestration.enabled = false; + + // Bound the wait so a serial-blocking regression (or a hung join) fails the + // test instead of hanging CI. Every service no-ops, so this resolves almost + // immediately; the generous ceiling only guards against a deadlock. + tokio::time::timeout( + std::time::Duration::from_secs(30), + start_login_gated_services(&config), + ) + .await + .expect("start_login_gated_services must return, not block"); +} diff --git a/src/openhuman/voice/always_on.rs b/src/openhuman/voice/always_on.rs index 6c22b79582..a36d015822 100644 --- a/src/openhuman/voice/always_on.rs +++ b/src/openhuman/voice/always_on.rs @@ -238,11 +238,34 @@ pub async fn start_if_enabled(app_config: &Config) { // The cpal stream is `!Send`, so it lives on a dedicated thread that pushes // 16 kHz mono frames over a channel to the async processor below. + // `spawn_capture_thread` blocks on a synchronous readiness handshake while + // the OS builds the input stream — cold WASAPI init on Windows can take a + // while — so run it on the blocking pool. This function is polled + // concurrently with the other login-gated services (#3490), and blocking an + // async worker here would stall them. let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::>(); - if let Err(e) = spawn_capture_thread(tx) { - log::error!("{LOG_PREFIX} could not start microphone capture: {e}"); - RUNNING.store(false, Ordering::SeqCst); - return; + log::debug!( + "{LOG_PREFIX} starting microphone capture (blocking readiness handshake on the blocking pool)" + ); + // Distinguish a Tokio join failure (the blocking task itself panicked) from a + // `spawn_capture_thread` setup error (e.g. no input device), so the log points + // at the right layer instead of flattening both into one message. + match tokio::task::spawn_blocking(move || spawn_capture_thread(tx)).await { + Ok(Ok(())) => { + log::debug!("{LOG_PREFIX} microphone capture stream ready"); + } + Ok(Err(e)) => { + log::error!("{LOG_PREFIX} could not start microphone capture: {e}"); + RUNNING.store(false, Ordering::SeqCst); + return; + } + Err(join_err) => { + log::error!( + "{LOG_PREFIX} microphone capture setup task failed to join (panicked): {join_err}" + ); + RUNNING.store(false, Ordering::SeqCst); + return; + } } // Privacy hook: pause capture while the screen is locked. diff --git a/src/openhuman/voice/server.rs b/src/openhuman/voice/server.rs index d6cc73118d..86848d4748 100644 --- a/src/openhuman/voice/server.rs +++ b/src/openhuman/voice/server.rs @@ -537,7 +537,7 @@ impl HotkeyListenerKind { fn start_hotkey_listener( hotkey_str: &str, mode: hotkey::ActivationMode, - _server_cancel: &CancellationToken, + server_cancel: &CancellationToken, ) -> Result< ( HotkeyListenerKind, @@ -548,7 +548,7 @@ fn start_hotkey_listener( #[cfg(target_os = "macos")] { if hotkey_str.trim().eq_ignore_ascii_case("fn") { - return start_globe_hotkey_listener(mode, _server_cancel); + return start_globe_hotkey_listener(mode, server_cancel); } // rdev calls TSMGetInputSourceProperty off the main thread; macOS 26 // enforces main-queue-only access and crashes the process. Only the @@ -566,6 +566,10 @@ fn start_hotkey_listener( // Non-macOS: rdev-based listener for all keys. #[cfg(not(target_os = "macos"))] { + // `server_cancel` is only consumed by the macOS Swift-globe listener + // branch above; the rdev listener manages its own lifecycle, so bind it + // here to keep the shared signature warning-free on non-macOS. + let _ = server_cancel; let combo = hotkey::parse_hotkey(hotkey_str)?; let (handle, rx) = hotkey::start_listener(combo, mode)?; Ok((HotkeyListenerKind::Rdev(handle), rx)) From f91b55bc8480a69ee6a0a6eda8fd34894d9f9ead Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Thu, 16 Jul 2026 05:34:50 +0530 Subject: [PATCH 33/86] fix(orchestration): surface async sub-agent failures/awaiting-user in chat (#4896) (#4908) Co-authored-by: Steven Enamakel --- .../background_completions.rs | 247 +++++++++++++++++- .../background_delivery.rs | 79 +++++- .../tools/spawn_async_subagent.rs | 24 ++ 3 files changed, 343 insertions(+), 7 deletions(-) diff --git a/src/openhuman/agent_orchestration/background_completions.rs b/src/openhuman/agent_orchestration/background_completions.rs index 8e7be4ca7c..e3338a60e5 100644 --- a/src/openhuman/agent_orchestration/background_completions.rs +++ b/src/openhuman/agent_orchestration/background_completions.rs @@ -12,6 +12,21 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::{Mutex, OnceLock}; +/// Terminal disposition of a finished background sub-agent. Drives distinct +/// rendering in [`build_batched_notice`] so a failed / awaiting-input async +/// sub-agent surfaces in chat as such instead of being dropped or mistaken for a +/// success (#4896). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum BackgroundAgentOutcome { + /// Ran to a usable result (or partial progress framed as such). + #[default] + Completed, + /// The child errored before producing a result. + Failed, + /// The child paused asking the user a question and was not continued. + AwaitingInput, +} + /// One finished background sub-agent's deliverable result. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct CompletedBackgroundAgent { @@ -24,6 +39,9 @@ pub(crate) struct CompletedBackgroundAgent { /// Parent chat thread id to stream the delivery turn into (captured at /// spawn). `None` for a headless spawn with no originating thread. pub(crate) parent_thread_id: Option, + /// Terminal disposition — success, failure, or awaiting-user — so delivery + /// can render failures/awaiting distinctly (#4896). + pub(crate) outcome: BackgroundAgentOutcome, } /// Upper bound on the cancelled-thread tombstone set. A thread id is a one-shot @@ -111,6 +129,30 @@ pub(crate) fn record_completion( agent_id: impl Into, summary: impl Into, parent_thread_id: Option, +) { + record_outcome( + parent_session, + task_id, + agent_id, + summary, + parent_thread_id, + BackgroundAgentOutcome::Completed, + ); +} + +/// Record a finished background sub-agent carrying an explicit terminal +/// [`BackgroundAgentOutcome`]. This is the general enqueue behind +/// [`record_completion`] (success) and the [`record_failure`] / +/// [`record_awaiting_input`] framing helpers, so a failed or awaiting-input +/// async sub-agent is delivered back into chat too — not only successes (#4896). +/// Same tombstone / idempotency guarantees as [`record_completion`]. +pub(crate) fn record_outcome( + parent_session: impl Into, + task_id: impl Into, + agent_id: impl Into, + summary: impl Into, + parent_thread_id: Option, + outcome: BackgroundAgentOutcome, ) { let parent_session = parent_session.into(); let entry = CompletedBackgroundAgent { @@ -118,6 +160,7 @@ pub(crate) fn record_completion( agent_id: agent_id.into(), summary: summary.into(), parent_thread_id, + outcome, }; let mut state = queue() .lock() @@ -151,6 +194,55 @@ pub(crate) fn record_completion( pending.push(entry); } +/// Queue a **failed** async sub-agent for chat delivery (#4896). The summary is +/// framed with the `[SUBAGENT_FAILED]` envelope the parent agent is prompted to +/// relay, so the user learns the delegated task errored instead of the turn +/// silently finalizing on "Accepted". Enqueues via [`record_outcome`], so it +/// rides the same idle-gated `background_delivery` path as a success. +pub(crate) fn record_failure( + parent_session: impl Into, + task_id: impl Into, + agent_id: impl Into, + error: &str, + parent_thread_id: Option, +) { + let summary = + format!("[SUBAGENT_FAILED] the async sub-agent errored before producing a result: {error}"); + record_outcome( + parent_session, + task_id, + agent_id, + summary, + parent_thread_id, + BackgroundAgentOutcome::Failed, + ); +} + +/// Queue an **awaiting-user** async sub-agent for chat delivery (#4896). A +/// detached child that pauses to ask a question will not continue on its own, so +/// the framed `[SUBAGENT_NEEDS_INPUT]` notice is delivered back into chat for the +/// parent agent to relay to (or answer for) the user. +pub(crate) fn record_awaiting_input( + parent_session: impl Into, + task_id: impl Into, + agent_id: impl Into, + question: &str, + parent_thread_id: Option, +) { + let summary = format!( + "[SUBAGENT_NEEDS_INPUT] the async sub-agent paused to ask the user a question and will \ + not continue on its own: {question}" + ); + record_outcome( + parent_session, + task_id, + agent_id, + summary, + parent_thread_id, + BackgroundAgentOutcome::AwaitingInput, + ); +} + /// Is anything waiting to be delivered for this session? Cheap idle-loop check. pub(crate) fn has_pending(parent_session: &str) -> bool { queue() @@ -272,18 +364,34 @@ pub(crate) fn build_batched_notice(completed: &[CompletedBackgroundAgent]) -> Op let mut out = String::new(); out.push_str(&format!( "[{n} background sub-agent{} finished while you were busy. Review each result \ - below and present what is relevant to the user. Each is tagged with its \ - sub-agent process id.]\n", + below — including any that FAILED or NEED INPUT — and present what is relevant \ + to the user (never silently drop a failure or an awaiting-input pause). Each is \ + tagged with its sub-agent process id.]\n", if n == 1 { "" } else { "s" }, )); for c in completed { + // Distinct tag per terminal outcome so a failure / awaiting-input result + // is not presented as a normal completion (#4896). + let (tag, empty_fallback) = match c.outcome { + BackgroundAgentOutcome::Completed => { + ("background_agent_result", "(no output reported)") + } + BackgroundAgentOutcome::Failed => ( + "background_agent_failure", + "(failed with no detail reported)", + ), + BackgroundAgentOutcome::AwaitingInput => ( + "background_agent_needs_input", + "(the sub-agent paused awaiting user input)", + ), + }; let summary = if c.summary.trim().is_empty() { - "(no output reported)" + empty_fallback } else { c.summary.trim() }; out.push_str(&format!( - "\n\n{}\n\n", + "\n<{tag} id=\"{}\" agent=\"{}\">\n{}\n\n", c.task_id, c.agent_id, summary, )); } @@ -311,6 +419,19 @@ mod tests { agent_id: agent.into(), summary: summary.into(), parent_thread_id: Some("thread-1".into()), + outcome: BackgroundAgentOutcome::Completed, + } + } + + fn c_outcome( + task: &str, + agent: &str, + summary: &str, + outcome: BackgroundAgentOutcome, + ) -> CompletedBackgroundAgent { + CompletedBackgroundAgent { + outcome, + ..c(task, agent, summary) } } @@ -531,4 +652,122 @@ mod tests { "collected tombstone must stay bounded, got {len}" ); } + + // ── #4896: failure / awaiting-user delivery ───────────────────────────── + + #[test] + fn record_failure_queues_a_framed_failure_for_delivery() { + let _guard = test_guard(); + let s = "sess-fail"; + record_failure( + s, + "sub-f", + "researcher", + "provider 500: inference failed", + Some("thread-F".into()), + ); + // The failure rode the SAME queue successes use → it will be delivered. + assert_eq!(pending_count(s), 1); + let drained = take_pending(s); + assert_eq!(drained[0].outcome, BackgroundAgentOutcome::Failed); + assert!(drained[0].summary.starts_with("[SUBAGENT_FAILED]")); + assert!(drained[0] + .summary + .contains("provider 500: inference failed")); + } + + #[test] + fn record_awaiting_input_queues_a_framed_needs_input_for_delivery() { + let _guard = test_guard(); + let s = "sess-await"; + record_awaiting_input( + s, + "sub-a", + "researcher", + "Which repo should I open the PR against?", + Some("thread-A".into()), + ); + assert_eq!(pending_count(s), 1); + let drained = take_pending(s); + assert_eq!(drained[0].outcome, BackgroundAgentOutcome::AwaitingInput); + assert!(drained[0].summary.starts_with("[SUBAGENT_NEEDS_INPUT]")); + assert!(drained[0].summary.contains("Which repo")); + } + + #[test] + fn notice_renders_failure_and_awaiting_with_distinct_tags() { + let notice = build_batched_notice(&[ + c_outcome( + "sub-ok", + "researcher", + "all good", + BackgroundAgentOutcome::Completed, + ), + c_outcome( + "sub-bad", + "researcher", + "[SUBAGENT_FAILED] boom", + BackgroundAgentOutcome::Failed, + ), + c_outcome( + "sub-ask", + "researcher", + "[SUBAGENT_NEEDS_INPUT] which repo?", + BackgroundAgentOutcome::AwaitingInput, + ), + ]) + .expect("non-empty batch"); + + // The header now tells the agent to surface failures / awaiting-input. + assert!(notice.contains("FAILED or NEED INPUT")); + // Each outcome renders under its own tag so a failure is not presented + // as a normal completion. + assert!(notice.contains("")); + assert!(notice.contains("")); + assert!(notice.contains("[SUBAGENT_FAILED] boom")); + assert!( + notice.contains("") + ); + assert!(notice.contains("[SUBAGENT_NEEDS_INPUT] which repo?")); + } + + #[test] + fn empty_summary_fallback_is_outcome_specific() { + let failed = build_batched_notice(&[c_outcome( + "sub-e", + "r", + " ", + BackgroundAgentOutcome::Failed, + )]) + .unwrap(); + assert!(failed.contains("(failed with no detail reported)")); + + let awaiting = build_batched_notice(&[c_outcome( + "sub-e", + "r", + "", + BackgroundAgentOutcome::AwaitingInput, + )]) + .unwrap(); + assert!(awaiting.contains("(the sub-agent paused awaiting user input)")); + } + + #[test] + fn record_outcome_preserves_the_outcome_through_a_drain() { + // Guards the requeue path (background_delivery::requeue re-enqueues via + // record_outcome): a failed batch that fails delivery must not be + // downgraded to a success on retry. + let _guard = test_guard(); + let s = "sess-preserve"; + record_outcome( + s, + "sub-p", + "researcher", + "[SUBAGENT_FAILED] x", + None, + BackgroundAgentOutcome::Failed, + ); + let drained = take_pending(s); + assert_eq!(drained[0].outcome, BackgroundAgentOutcome::Failed); + } } diff --git a/src/openhuman/agent_orchestration/background_delivery.rs b/src/openhuman/agent_orchestration/background_delivery.rs index bae691c788..3cf2423b1c 100644 --- a/src/openhuman/agent_orchestration/background_delivery.rs +++ b/src/openhuman/agent_orchestration/background_delivery.rs @@ -70,8 +70,17 @@ impl EventHandler for BackgroundDeliveryHandler { busy().lock().expect("busy poisoned").remove(session_id); schedule_delivery(session_id.clone(), Duration::from_millis(300)); } - DomainEvent::SubagentCompleted { parent_session, .. } => { - // Debounce so a burst of completions batches into a single turn. + // Any subagent terminal state — completed, failed, or awaiting-user — + // can arrive after the parent turn already went idle. Schedule a + // debounced drain for all three so the pending result is delivered + // promptly instead of sitting until some unrelated later turn. Only + // `SubagentCompleted` used to trigger a drain, so a failure (or an + // awaiting-user pause) after the parent turn went idle left the chat + // stuck on the original "Accepted" response (#4896). Debounce so a + // burst batches into a single turn. + DomainEvent::SubagentCompleted { parent_session, .. } + | DomainEvent::SubagentFailed { parent_session, .. } + | DomainEvent::SubagentAwaitingUser { parent_session, .. } => { schedule_delivery(parent_session.clone(), DEBOUNCE); } _ => {} @@ -107,12 +116,15 @@ fn plan_delivery(session: &str) -> Option) { for c in batch { - background_completions::record_completion( + // Preserve the terminal outcome on requeue so a failed / awaiting-input + // result isn't downgraded to a success when a delivery turn fails (#4896). + background_completions::record_outcome( session, c.task_id, c.agent_id, c.summary, c.parent_thread_id, + c.outcome, ); } } @@ -301,4 +313,65 @@ mod tests { .await; assert!(!is_busy(&sid)); } + + #[tokio::test(start_paused = true)] + async fn every_subagent_terminal_event_schedules_a_drain() { + // #4896 regression: EVERY subagent terminal event must schedule a drain + // for the parent — not just `SubagentCompleted`. Before the fix, + // `SubagentFailed` / `SubagentAwaitingUser` fell through to `_ => {}`, so + // a failure/pause recorded after the parent turn went idle was never + // delivered. Prove behaviour, not just acceptance: queue a headless + // result (no thread → drains without a delivery sink) per session, fire + // the event, advance past the debounce, and assert the pending item was + // consumed. The paused clock elapses the debounce with no wall-clock wait. + let h = BackgroundDeliveryHandler; + + background_completions::record_completion("bd-term-completed", "t", "a", "s", None); + background_completions::record_completion("bd-term-failed", "t", "a", "s", None); + background_completions::record_completion("bd-term-awaiting", "t", "a", "s", None); + + h.handle(&DomainEvent::SubagentCompleted { + parent_session: "bd-term-completed".into(), + task_id: "t".into(), + agent_id: "a".into(), + elapsed_ms: 0, + output_chars: 0, + iterations: 0, + }) + .await; + h.handle(&DomainEvent::SubagentFailed { + parent_session: "bd-term-failed".into(), + task_id: "t".into(), + agent_id: "a".into(), + error: "boom".into(), + }) + .await; + h.handle(&DomainEvent::SubagentAwaitingUser { + parent_session: "bd-term-awaiting".into(), + task_id: "t".into(), + agent_id: "a".into(), + question: "?".into(), + }) + .await; + + // Advance the virtual clock past the debounce so every scheduled drain + // runs; the headless `try_deliver` completes synchronously (no sink). + tokio::time::sleep(DEBOUNCE + Duration::from_millis(50)).await; + for _ in 0..8 { + tokio::task::yield_now().await; + } + + assert!( + !background_completions::has_pending("bd-term-completed"), + "SubagentCompleted must schedule a drain that consumes the pending result" + ); + assert!( + !background_completions::has_pending("bd-term-failed"), + "SubagentFailed must schedule a drain (regression #4896)" + ); + assert!( + !background_completions::has_pending("bd-term-awaiting"), + "SubagentAwaitingUser must schedule a drain (regression #4896)" + ); + } } diff --git a/src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs b/src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs index beac656df3..4e710e0b3f 100644 --- a/src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs +++ b/src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs @@ -663,6 +663,18 @@ impl Tool for SpawnAsyncSubagentTool { let error = format!( "async sub-agent requested user clarification and was not continued: {question}" ); + // #4896: a detached child that pauses for input won't + // continue on its own — queue a framed notice so the + // parent chat learns the delegated task needs input, + // instead of finalizing silently on "Accepted". Rides the + // same idle-gated background_delivery path as a success. + crate::openhuman::agent_orchestration::background_completions::record_awaiting_input( + background_parent_session.clone(), + outcome.task_id.clone(), + outcome.agent_id.clone(), + question, + background_parent_thread_id.clone(), + ); crate::openhuman::agent_orchestration::subagent_events::publish_subagent_failed( background_parent_session, outcome.task_id.clone(), @@ -699,6 +711,18 @@ impl Tool for SpawnAsyncSubagentTool { let _ = status_tx.send(SubagentStatus::Failed { error: error.clone(), }); + // #4896: a detached child that errors previously only + // published an event — nothing reached chat, so the parent + // turn finalized on "Accepted" and the failure was lost. + // Queue a framed failure notice so background_delivery + // surfaces it as a follow-up chat turn. + crate::openhuman::agent_orchestration::background_completions::record_failure( + background_parent_session.clone(), + background_task_id.clone(), + background_agent_id.clone(), + &error, + background_parent_thread_id.clone(), + ); crate::openhuman::agent_orchestration::subagent_events::publish_subagent_failed( background_parent_session, background_task_id.clone(), From 38161fef8d5927d1bb96b2509c4b4b892ac66d3a Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Thu, 16 Jul 2026 05:35:05 +0530 Subject: [PATCH 34/86] fix(inference): honor Retry-After in the streaming retry loop (#4895) (#4909) Co-authored-by: Steven Enamakel --- .../inference/provider/error_classify.rs | 274 ++++++++++++++++-- src/openhuman/inference/provider/reliable.rs | 51 +++- .../inference/provider/reliable_tests.rs | 197 +++++++++++++ 3 files changed, 491 insertions(+), 31 deletions(-) diff --git a/src/openhuman/inference/provider/error_classify.rs b/src/openhuman/inference/provider/error_classify.rs index bd6efce235..2ebf9d32d8 100644 --- a/src/openhuman/inference/provider/error_classify.rs +++ b/src/openhuman/inference/provider/error_classify.rs @@ -112,6 +112,13 @@ pub(crate) fn is_non_retryable(err: &anyhow::Error) -> bool { /// Classify a StreamError without losing type information. /// Inspects the inner reqwest::Error status directly for Http variants. pub(crate) fn is_stream_error_non_retryable(err: &StreamError) -> bool { + // A plan/quota `429` is terminal even though `429` is normally retryable — + // match the non-streaming loop so a plan-restricted stream fails fast with a + // clear message instead of burning its (now larger) rate-limit retry budget + // (#4895). + if is_stream_non_retryable_rate_limit(err) { + return true; + } match err { StreamError::Http(reqwest_err) => { if let Some(status) = reqwest_err.status() { @@ -197,6 +204,13 @@ pub(crate) fn is_upstream_unhealthy(err: &anyhow::Error) -> bool { || lower.contains("504 gateway timeout") } +/// Text-only rate-limit heuristic, shared by the anyhow and streaming +/// classifiers so both read the same `429` signal. +fn msg_is_rate_limited(msg: &str) -> bool { + msg.contains("429") + && (msg.contains("Too Many") || msg.contains("rate") || msg.contains("limit")) +} + /// Check if an error is a rate-limit (429) error. pub(crate) fn is_rate_limited(err: &anyhow::Error) -> bool { if let Some(reqwest_err) = err.downcast_ref::() { @@ -204,9 +218,7 @@ pub(crate) fn is_rate_limited(err: &anyhow::Error) -> bool { return status.as_u16() == 429; } } - let msg = err.to_string(); - msg.contains("429") - && (msg.contains("Too Many") || msg.contains("rate") || msg.contains("limit")) + msg_is_rate_limited(&err.to_string()) } /// Check if a 429 is a business/quota-plan error that retries cannot fix. @@ -216,11 +228,15 @@ pub(crate) fn is_rate_limited(err: &anyhow::Error) -> bool { /// - insufficient balance / package not active /// - known provider business codes (e.g. Z.AI: 1311, 1113) pub(crate) fn is_non_retryable_rate_limit(err: &anyhow::Error) -> bool { - if !is_rate_limited(err) { - return false; - } + is_rate_limited(err) && msg_indicates_non_retryable_rate_limit(&err.to_string()) +} - let msg = err.to_string(); +/// Core business/plan/quota-`429` detector, shared by the anyhow +/// ([`is_non_retryable_rate_limit`]) and streaming +/// ([`is_stream_non_retryable_rate_limit`]) classifiers so a plan/quota refusal +/// fails fast on **both** the streaming and non-streaming paths (#4895). The +/// caller is responsible for having already confirmed the error is a `429`. +fn msg_indicates_non_retryable_rate_limit(msg: &str) -> bool { let lower = msg.to_lowercase(); let business_hints = [ @@ -255,39 +271,139 @@ pub(crate) fn is_non_retryable_rate_limit(err: &anyhow::Error) -> bool { false } +/// Whether a [`StreamError`] is a `429` rate-limit. Reads the typed reqwest +/// status for `Http`, and the same text signal as [`is_rate_limited`] for the +/// `Provider` string envelope (the managed backend surfaces its `429` body — +/// including `errorCode`/`retryAfter` — through `StreamError::Provider`). +pub(crate) fn is_stream_rate_limited(err: &StreamError) -> bool { + match err { + StreamError::Http(reqwest_err) => reqwest_err + .status() + .is_some_and(|status| status.as_u16() == 429), + StreamError::Provider(msg) => msg_is_rate_limited(msg), + _ => false, + } +} + +/// Whether a streaming `429` is a business/plan/quota refusal that retries +/// cannot fix (the streaming analogue of [`is_non_retryable_rate_limit`]). +pub(crate) fn is_stream_non_retryable_rate_limit(err: &StreamError) -> bool { + is_stream_rate_limited(err) && msg_indicates_non_retryable_rate_limit(&err.to_string()) +} + +/// Extract a `Retry-After` (milliseconds) carried by a streaming error. The +/// managed backend embeds its rate-limit `retryAfter` (and any provider +/// `Retry-After` header text) in the `StreamError::Provider` envelope, so parse +/// it from the error's `Display` string. +pub(crate) fn parse_stream_retry_after_ms(err: &StreamError) -> Option { + parse_retry_after_ms_from_str_at(&err.to_string(), chrono::Utc::now()) +} + +/// Streaming-retry backoff (ms): honor a server `Retry-After` (capped at +/// [`RETRY_AFTER_CAP_MS`], floored at `base`) when present, else the caller's +/// exponential `base`. Mirrors `ReliableProvider::compute_backoff` for the +/// streaming path, which previously ignored `Retry-After` entirely (#4895). +pub(crate) fn compute_stream_backoff_ms(base: u64, err: &StreamError) -> u64 { + match parse_stream_retry_after_ms(err) { + Some(retry_after) => retry_after.min(RETRY_AFTER_CAP_MS).max(base), + None => base, + } +} + +/// Cap on any honored `Retry-After` wait, in milliseconds. Mirrors the +/// non-streaming `ReliableProvider::compute_backoff` cap and +/// `agent::triage::evaluator::RETRY_AFTER_CAP` so a hostile or mis-set header +/// can't wedge a turn for minutes. +pub(crate) const RETRY_AFTER_CAP_MS: u64 = 30_000; + /// Try to extract a Retry-After value (in milliseconds) from an error message. -/// Looks for patterns like `Retry-After: 5` or `retry_after: 2.5` in the error string. +/// Looks for patterns like `Retry-After: 5` or `retry_after: 2.5` in the error +/// string. Convenience wrapper over [`parse_retry_after_ms_from_str_at`] using +/// the current wall-clock (only relevant for the HTTP-date form). pub(crate) fn parse_retry_after_ms(err: &anyhow::Error) -> Option { - let msg = err.to_string(); + parse_retry_after_ms_from_str_at(&err.to_string(), chrono::Utc::now()) +} + +/// Extract a `Retry-After` value (milliseconds) from a raw error/body string. +/// +/// Recognises, case-insensitively: +/// - the HTTP header form `Retry-After: ` / `retry_after ` +/// (integer or fractional **seconds**), +/// - the backend JSON body field the managed proxy emits, +/// `"retryAfter": ` (camelCase, see `error_code.rs`), and +/// - an HTTP-date (RFC 7231 IMF-fixdate, e.g. `Wed, 21 Oct 2025 07:28:00 GMT`), +/// whose delay is computed relative to `now` and floored at zero. +/// +/// `now` is injected so the HTTP-date branch is deterministically testable. +pub(crate) fn parse_retry_after_ms_from_str_at( + msg: &str, + now: chrono::DateTime, +) -> Option { let lower = msg.to_lowercase(); - // Look for "retry-after: " or "retry_after: " - for prefix in &[ - "retry-after:", - "retry_after:", - "retry-after ", - "retry_after ", - ] { - if let Some(pos) = lower.find(prefix) { - let after = &msg[pos + prefix.len()..]; - let num_str: String = after - .trim() - .chars() - .take_while(|c| c.is_ascii_digit() || *c == '.') - .collect(); + // Base keys without a trailing separator, so a single pass covers the + // header (`Retry-After: 5`), space (`retry-after 5`), and JSON + // (`"retryAfter":30`) spellings once the punctuation is trimmed. + for key in &["retry-after", "retry_after", "retryafter"] { + let Some(pos) = lower.find(key) else { + continue; + }; + // Work on the original-case slice so an HTTP-date still parses. Use + // `get` (not direct slicing) so a non-ASCII byte earlier in the string — + // which would make the lowercased `pos` land off a char boundary — yields + // no match instead of panicking. + let Some(after) = msg.get(pos + key.len()..) else { + continue; + }; + let value = after + .trim_start_matches(|c: char| c == '"' || c == ':' || c == '=' || c.is_whitespace()); + + // Numeric seconds (integer or fractional) first. + let num_str: String = value + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '.') + .collect(); + if !num_str.is_empty() { if let Ok(secs) = num_str.parse::() { if secs.is_finite() && secs >= 0.0 { - let millis = Duration::from_secs_f64(secs).as_millis(); - if let Ok(value) = u64::try_from(millis) { + if let Ok(value) = u64::try_from(Duration::from_secs_f64(secs).as_millis()) { return Some(value); } } } } + + // Otherwise try an HTTP-date value. + if let Some(ms) = parse_http_date_delay_ms(value, now) { + return Some(ms); + } } None } +/// Parse an HTTP-date `Retry-After` value (RFC 7231 IMF-fixdate) and return the +/// delay from `now` in milliseconds (floored at zero for past dates), or `None` +/// when `value` doesn't lead with a parseable GMT date. +fn parse_http_date_delay_ms(value: &str, now: chrono::DateTime) -> Option { + let trimmed = value.trim(); + // Bound the candidate to the date itself (ends at the "GMT" zone marker) so + // trailing JSON/prose (`… GMT"}`) doesn't defeat the strict parser. + let end = trimmed.find("GMT")? + 3; + // IMF-fixdate ("… 07:28:00 GMT") differs from RFC 2822 only in the zone + // spelling; normalise GMT → +0000 so chrono's RFC 2822 parser accepts it. + let normalized = trimmed[..end].replace("GMT", "+0000"); + let parsed = chrono::DateTime::parse_from_rfc2822(normalized.trim()).ok()?; + let delta_ms = parsed + .with_timezone(&chrono::Utc) + .signed_duration_since(now) + .num_milliseconds(); + if delta_ms <= 0 { + Some(0) + } else { + u64::try_from(delta_ms).ok() + } +} + pub(crate) fn failure_reason( rate_limited: bool, non_retryable: bool, @@ -635,6 +751,114 @@ mod tests { ); } + // ── #4895: streaming Retry-After honoring + plan-vs-transient 429 split ── + + #[test] + fn parse_retry_after_json_body_field() { + let now = chrono::Utc::now(); + // The managed backend embeds `retryAfter` (camelCase, seconds) in the + // JSON error body that reaches the loop as a `StreamError::Provider`. + let body = r#"OpenHuman API error (429 Too Many Requests): {"error":{"message":"slow down","errorCode":"RATE_LIMITED","retryAfter":30}}"#; + assert_eq!( + parse_retry_after_ms_from_str_at(body, now), + Some(30_000), + "camelCase JSON retryAfter must be honored" + ); + // Pretty-printed body with a space after the colon. + let spaced = r#"{"errorCode":"RATE_LIMITED","retryAfter": 12}"#; + assert_eq!(parse_retry_after_ms_from_str_at(spaced, now), Some(12_000)); + } + + #[test] + fn parse_retry_after_header_forms_still_work() { + let now = chrono::Utc::now(); + assert_eq!( + parse_retry_after_ms_from_str_at("429 Too Many Requests, Retry-After: 5", now), + Some(5_000) + ); + assert_eq!( + parse_retry_after_ms_from_str_at("rate limited. retry_after: 2.5 seconds", now), + Some(2_500) + ); + assert_eq!( + parse_retry_after_ms_from_str_at("500 Internal Server Error", now), + None + ); + } + + #[test] + fn parse_retry_after_http_date_form() { + // Anchor on chrono's own RFC-2822 parse so the fixture stays consistent + // with the parser (and the weekday can't silently drift). + let target = chrono::DateTime::parse_from_rfc2822("Tue, 21 Oct 2025 07:28:00 +0000") + .expect("valid rfc2822 anchor") + .with_timezone(&chrono::Utc); + let msg = "OpenHuman API error (429): Retry-After: Tue, 21 Oct 2025 07:28:00 GMT"; + + // 5s before the target → ~5000ms wait. + let now = target - chrono::Duration::seconds(5); + assert_eq!(parse_retry_after_ms_from_str_at(msg, now), Some(5_000)); + + // A date already in the past floors to 0 (never negative). + let past_now = target + chrono::Duration::seconds(10); + assert_eq!(parse_retry_after_ms_from_str_at(msg, past_now), Some(0)); + } + + #[test] + fn stream_rate_limited_detection() { + assert!(is_stream_rate_limited(&StreamError::Provider( + "OpenHuman API error (429 Too Many Requests): rate limit".into() + ))); + assert!(!is_stream_rate_limited(&StreamError::Provider( + "500 Internal Server Error".into() + ))); + assert!(!is_stream_rate_limited(&StreamError::InvalidSse( + "bad frame".into() + ))); + } + + #[test] + fn stream_non_retryable_rate_limit_splits_plan_from_transient() { + // Plan/quota 429 → terminal (retries can't fix it). + let plan = StreamError::Provider( + r#"OpenHuman API error (429 Too Many Requests): {"code":1311,"message":"the current account plan does not include glm-5"}"# + .into(), + ); + assert!(is_stream_non_retryable_rate_limit(&plan)); + assert!( + is_stream_error_non_retryable(&plan), + "a plan 429 must fail fast on the streaming path too" + ); + + // Transient 429 → retryable (must NOT be flagged terminal). + let transient = + StreamError::Provider("OpenHuman API error (429 Too Many Requests): slow down".into()); + assert!(!is_stream_non_retryable_rate_limit(&transient)); + assert!( + !is_stream_error_non_retryable(&transient), + "a transient 429 must remain retryable so it can be backed off" + ); + } + + #[test] + fn compute_stream_backoff_honors_and_caps_retry_after() { + let base = 500; + // Retry-After present → honored (floored at base). + let five_s = StreamError::Provider( + r#"OpenHuman API error (429): {"errorCode":"RATE_LIMITED","retryAfter":5}"#.into(), + ); + assert_eq!(compute_stream_backoff_ms(base, &five_s), 5_000); + // Oversized Retry-After is capped. + let huge = StreamError::Provider(r#"{"errorCode":"RATE_LIMITED","retryAfter":600}"#.into()); + assert_eq!(compute_stream_backoff_ms(base, &huge), RETRY_AFTER_CAP_MS); + // Tiny Retry-After is floored at the caller's base backoff. + let tiny = StreamError::Provider(r#"{"retryAfter":0}"#.into()); + assert_eq!(compute_stream_backoff_ms(base, &tiny), base); + // No Retry-After → base backoff unchanged. + let none = StreamError::Provider("transient upstream blip".into()); + assert_eq!(compute_stream_backoff_ms(base, &none), base); + } + // ── upstream_unhealthy classification and failure_reason precedence ── #[test] diff --git a/src/openhuman/inference/provider/reliable.rs b/src/openhuman/inference/provider/reliable.rs index 526bc41a19..27007f6ff1 100644 --- a/src/openhuman/inference/provider/reliable.rs +++ b/src/openhuman/inference/provider/reliable.rs @@ -39,6 +39,14 @@ use std::time::Duration; // `reliable::format_failure_aggregate` import paths continue to resolve. pub(crate) use super::error_classify::*; +/// Minimum retry budget for a **transient** streaming `429`, so a multi-second +/// server `Retry-After` window can actually be waited out. The configured +/// `provider_retries` (default 2, ~1.5 s of fixed backoff) is too small to ride +/// out a rate-limit window; rate-limited streams get at least this many retries +/// while every other failure keeps the configured budget (#4895). Bounded, and +/// only applies to retryable (non-plan/quota) `429`s. +const STREAM_RATE_LIMIT_MIN_RETRIES: u32 = 3; + fn push_failure( failures: &mut Vec, provider_name: &str, @@ -825,6 +833,10 @@ impl Provider for ReliableProvider { let max_retries = self.max_retries; tokio::spawn(async move { + // Tracks whether the *last* candidate error was a rate-limit (429), + // so the terminal message surfaced on exhaustion is a clear + // rate-limit notice rather than a generic failure (#4895). + let mut last_error_rate_limited = false; for (provider_name, provider, current_model) in candidates { let mut backoff_ms = base_backoff_ms; let mut attempts = 0u32; @@ -859,6 +871,19 @@ impl Provider for ReliableProvider { } Some(Err(ref e)) => { let non_retryable = is_stream_error_non_retryable(e); + // A retryable (transient) 429 — as opposed to a + // plan/quota 429, which `is_stream_error_non_retryable` + // already flags — gets a larger, bounded retry budget + // so a multi-second Retry-After window can be waited + // out (#4895). + let retryable_rate_limited = + !non_retryable && is_stream_rate_limited(e); + last_error_rate_limited = is_stream_rate_limited(e); + let effective_max = if retryable_rate_limited { + max_retries.max(STREAM_RATE_LIMIT_MIN_RETRIES) + } else { + max_retries + }; tracing::warn!( provider = provider_name, @@ -868,12 +893,17 @@ impl Provider for ReliableProvider { "Streaming failed{}", if non_retryable { " (non-retryable)" } else { "" } ); - if non_retryable || attempts >= max_retries { + if non_retryable || attempts >= effective_max { break; // Move to next candidate } attempts += 1; - tokio::time::sleep(Duration::from_millis(backoff_ms)).await; + // Honor the server's Retry-After (capped) on + // rate-limits instead of the raw exponential doubling, + // which previously fired all attempts within ~1.5 s and + // ignored the backend's requested wait entirely (#4895). + let wait = compute_stream_backoff_ms(backoff_ms, e); + tokio::time::sleep(Duration::from_millis(wait)).await; backoff_ms = (backoff_ms.saturating_mul(2)).min(10_000); // Re-create the candidate stream on the next iteration. continue; @@ -893,11 +923,20 @@ impl Provider for ReliableProvider { } } - // All providers/models exhausted + // All providers/models exhausted. When the terminal failure was a + // rate-limit, surface a clear, user-actionable message instead of a + // generic one so the turn no longer dies silently (#4895) — this + // string propagates to the chat error surface via + // `crate_provider`'s `ProviderFailed` → `bail!` mapping. + let terminal = if last_error_rate_limited { + "You're being rate-limited (sending requests faster than your current plan allows). \ + Please wait a few seconds and try again." + .to_string() + } else { + "All streaming providers/models failed".to_string() + }; let _ = tx - .send(Err(super::traits::StreamError::Provider( - "All streaming providers/models failed".to_string(), - ))) + .send(Err(super::traits::StreamError::Provider(terminal))) .await; }); diff --git a/src/openhuman/inference/provider/reliable_tests.rs b/src/openhuman/inference/provider/reliable_tests.rs index 377bb08e42..1692e9a78f 100644 --- a/src/openhuman/inference/provider/reliable_tests.rs +++ b/src/openhuman/inference/provider/reliable_tests.rs @@ -1029,3 +1029,200 @@ async fn chat_with_system_bail_omits_hint_when_fallbacks_configured_but_all_fail "expected dump to mention every model tried: {err}" ); } + +// ── #4895: streaming rate-limit (429) Retry-After honoring + budget/message ── + +/// Streaming mock that fails with a configurable `StreamError::Provider` for the +/// first `fail_until` stream creations, then yields a single `"hello"` chunk. +/// Mirrors the real provider's one-item-per-stream shape so a retry that +/// re-polled a dead stream would see `None` and give up. +struct StreamingRateLimitMock { + stream_calls: Arc, + fail_until: usize, + error: String, +} + +#[async_trait] +impl Provider for StreamingRateLimitMock { + async fn chat_with_system( + &self, + _system_prompt: Option<&str>, + _message: &str, + _model: &str, + _temperature: f64, + ) -> anyhow::Result { + anyhow::bail!("unused") + } + + async fn chat_with_history( + &self, + _messages: &[ChatMessage], + _model: &str, + _temperature: f64, + ) -> anyhow::Result { + anyhow::bail!("unused") + } + + fn supports_streaming(&self) -> bool { + true + } + + fn stream_chat_with_system( + &self, + _system_prompt: Option<&str>, + _message: &str, + _model: &str, + _temperature: f64, + _options: StreamOptions, + ) -> futures_util::stream::BoxStream<'static, StreamResult> { + use futures_util::{stream, StreamExt}; + let n = self.stream_calls.fetch_add(1, Ordering::SeqCst) + 1; + let succeed = n > self.fail_until; + let error = self.error.clone(); + stream::once(async move { + if succeed { + Ok(StreamChunk::delta("hello")) + } else { + Err(StreamError::Provider(error)) + } + }) + .boxed() + } +} + +/// A transient streaming `429` carrying a `Retry-After` must be retried, must +/// actually WAIT the server's requested window (not the old ~1.5 s fixed +/// backoff), and then recover. Uses tokio's paused clock so the 5 s wait is +/// virtual/instant yet asserted via virtual elapsed time. +#[tokio::test(start_paused = true)] +async fn streaming_rate_limit_honors_retry_after_and_recovers() { + use futures_util::StreamExt; + + let stream_calls = Arc::new(AtomicUsize::new(0)); + let provider = ReliableProvider::new( + vec![( + "openhuman".into(), + Box::new(StreamingRateLimitMock { + stream_calls: Arc::clone(&stream_calls), + fail_until: 1, + error: r#"OpenHuman API error (429 Too Many Requests): {"errorCode":"RATE_LIMITED","retryAfter":5}"# + .to_string(), + }), + )], + 2, + 50, + ); + + let start = tokio::time::Instant::now(); + let mut stream = + provider.stream_chat_with_system(None, "hi", "reasoning-v1", 0.0, StreamOptions::new(true)); + let mut chunks = Vec::new(); + while let Some(item) = stream.next().await { + if let Ok(chunk) = item { + chunks.push(chunk.delta); + } + } + let elapsed = start.elapsed(); + + assert_eq!( + chunks, + vec!["hello".to_string()], + "a transient 429 with Retry-After must be retried and recover" + ); + assert_eq!( + stream_calls.load(Ordering::SeqCst), + 2, + "one rate-limited attempt + one successful retry" + ); + assert!( + elapsed >= Duration::from_secs(5), + "must wait the server's 5s Retry-After before retrying, waited {elapsed:?}" + ); +} + +/// A plan/quota `429` (retries can't fix it) must fail fast — a single stream +/// creation, no retries — and surface a clear rate-limit message. +#[tokio::test] +async fn streaming_plan_rate_limit_fails_fast_with_clear_message() { + use futures_util::StreamExt; + + let stream_calls = Arc::new(AtomicUsize::new(0)); + let provider = ReliableProvider::new( + vec![( + "openhuman".into(), + Box::new(StreamingRateLimitMock { + stream_calls: Arc::clone(&stream_calls), + fail_until: 100, // always fail + error: r#"OpenHuman API error (429 Too Many Requests): {"code":1311,"message":"the current account plan does not include glm-5"}"# + .to_string(), + }), + )], + 3, + 50, + ); + + let mut stream = + provider.stream_chat_with_system(None, "hi", "reasoning-v1", 0.0, StreamOptions::new(true)); + let mut terminal: Option = None; + while let Some(item) = stream.next().await { + if let Err(StreamError::Provider(msg)) = item { + terminal = Some(msg); + } + } + + assert_eq!( + stream_calls.load(Ordering::SeqCst), + 1, + "a plan/quota 429 must fail fast without burning the retry budget" + ); + let terminal = terminal.expect("stream must surface a terminal error"); + assert!( + terminal.to_lowercase().contains("rate-limited"), + "plan 429 terminal must be a clear rate-limit message, got: {terminal}" + ); +} + +/// A persistently transient `429` must use the dedicated rate-limit retry budget +/// (strictly more than the small configured `provider_retries`) before giving +/// up with a clear terminal rate-limit message. `retryAfter:0` keeps the waits +/// at the base backoff so the test stays fast. +#[tokio::test(start_paused = true)] +async fn streaming_transient_rate_limit_uses_dedicated_budget_then_clear_message() { + use futures_util::StreamExt; + + let stream_calls = Arc::new(AtomicUsize::new(0)); + let provider = ReliableProvider::new( + vec![( + "openhuman".into(), + Box::new(StreamingRateLimitMock { + stream_calls: Arc::clone(&stream_calls), + fail_until: 100, // always fail + error: r#"OpenHuman API error (429 Too Many Requests): {"errorCode":"RATE_LIMITED","retryAfter":0}"# + .to_string(), + }), + )], + 2, // configured retries; rate-limit floor is STREAM_RATE_LIMIT_MIN_RETRIES (3) + 50, + ); + + let mut stream = + provider.stream_chat_with_system(None, "hi", "reasoning-v1", 0.0, StreamOptions::new(true)); + let mut terminal: Option = None; + while let Some(item) = stream.next().await { + if let Err(StreamError::Provider(msg)) = item { + terminal = Some(msg); + } + } + + // 1 initial attempt + 3 rate-limit retries (dedicated budget > configured 2). + assert_eq!( + stream_calls.load(Ordering::SeqCst), + 4, + "a transient 429 must use the dedicated rate-limit retry budget, not the smaller configured one" + ); + let terminal = terminal.expect("stream must surface a terminal error"); + assert!( + terminal.to_lowercase().contains("rate-limited"), + "an exhausted transient 429 must end with a clear rate-limit message, got: {terminal}" + ); +} From 1bdf362006085f9d32c3e069746ba86cf2abfb3b Mon Sep 17 00:00:00 2001 From: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com> Date: Thu, 16 Jul 2026 05:35:20 +0530 Subject: [PATCH 35/86] fix(channels): correct channel-setup nav paths, add in-app guidance (#4910) Co-authored-by: Steven Enamakel --- .../channels/ChannelConnectHelp.test.tsx | 23 ++++++++++ .../channels/ChannelConnectHelp.tsx | 32 +++++++++++++ .../components/channels/ChannelSetupModal.tsx | 20 +++++++-- app/src/lib/i18n/ar.ts | 7 +++ app/src/lib/i18n/bn.ts | 7 +++ app/src/lib/i18n/de.ts | 7 +++ app/src/lib/i18n/en.ts | 9 ++++ app/src/lib/i18n/es.ts | 7 +++ app/src/lib/i18n/fr.ts | 7 +++ 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 | 7 +++ app/src/lib/i18n/zh-CN.ts | 7 +++ app/src/pages/Skills.tsx | 3 ++ gitbooks/features/channels.md | 13 +++++- gitbooks/features/integrations/README.md | 6 +-- .../features/integrations/mcp-and-skills.md | 2 +- gitbooks/features/privacy-and-security.md | 2 +- gitbooks/overview/getting-started.md | 2 +- src/core/jsonrpc_tests.rs | 4 +- src/core/observability.rs | 2 +- src/openhuman/about_app/catalog_data.rs | 30 ++++++------- src/openhuman/about_app/catalog_tests.rs | 45 +++++++++++++++++++ .../tools/spawn_subagent.rs | 26 ++++++----- .../agents/crypto_agent/prompt.md | 4 +- .../agent_registry/agents/help/prompt.md | 3 +- .../agents/integrations_agent/agent.toml | 2 +- .../agents/integrations_agent/prompt.md | 10 ++--- .../agents/integrations_agent/prompt.rs | 5 ++- .../agents/markets_agent/prompt.md | 6 +-- .../agents/orchestrator/prompt.md | 8 ++-- .../composio/direct_auth/messages.rs | 2 +- src/openhuman/composio/direct_auth/mod.rs | 2 +- src/openhuman/composio/error_mapping.rs | 4 +- src/openhuman/composio/error_mapping_tests.rs | 11 ++--- src/openhuman/composio/identity.rs | 2 +- src/openhuman/composio/tools.rs | 10 ++--- src/openhuman/skills/preflight.rs | 4 +- src/openhuman/tinyagents/middleware.rs | 16 +++---- 43 files changed, 315 insertions(+), 84 deletions(-) create mode 100644 app/src/components/channels/ChannelConnectHelp.test.tsx create mode 100644 app/src/components/channels/ChannelConnectHelp.tsx diff --git a/app/src/components/channels/ChannelConnectHelp.test.tsx b/app/src/components/channels/ChannelConnectHelp.test.tsx new file mode 100644 index 0000000000..69203cbb40 --- /dev/null +++ b/app/src/components/channels/ChannelConnectHelp.test.tsx @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { renderWithProviders } from '../../test/test-utils'; +import ChannelConnectHelp from './ChannelConnectHelp'; + +describe(' (issue #4884)', () => { + it('renders grounded connect steps for Discord', () => { + const { getByText } = renderWithProviders(); + expect(getByText('How to connect')).toBeInTheDocument(); + expect(getByText(/Discord developer portal/i)).toBeInTheDocument(); + }); + + it('renders grounded connect steps for Telegram', () => { + const { getByText } = renderWithProviders(); + expect(getByText('How to connect')).toBeInTheDocument(); + expect(getByText(/@BotFather/i)).toBeInTheDocument(); + }); + + it('renders nothing for a channel without documented guidance', () => { + const { container } = renderWithProviders(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/app/src/components/channels/ChannelConnectHelp.tsx b/app/src/components/channels/ChannelConnectHelp.tsx new file mode 100644 index 0000000000..c5634b0ba2 --- /dev/null +++ b/app/src/components/channels/ChannelConnectHelp.tsx @@ -0,0 +1,32 @@ +/** + * In-app "how to connect" guidance shown at the top of a channel's setup card. + * + * Grounds users in the real connect flow so they don't have to ask the agent + * for a navigation path (the agent previously hallucinated non-existent menus + * like "Settings → Automation & Channels"). Only channels with a documented + * flow render a callout; everything else renders nothing. + */ +import { useT } from '../../lib/i18n/I18nContext'; + +/** Per-channel help copy. Add a key here to surface guidance for a channel. */ +const CHANNEL_HELP_KEY: Record = { + discord: 'channels.connectHelp.discord', + telegram: 'channels.connectHelp.telegram', +}; + +interface ChannelConnectHelpProps { + channelId: string; +} + +export default function ChannelConnectHelp({ channelId }: ChannelConnectHelpProps) { + const { t } = useT(); + const bodyKey = CHANNEL_HELP_KEY[channelId]; + if (!bodyKey) return null; + + return ( +
+

{t('channels.connectHelp.title')}

+

{t(bodyKey)}

+
+ ); +} diff --git a/app/src/components/channels/ChannelSetupModal.tsx b/app/src/components/channels/ChannelSetupModal.tsx index 2e87ac8ab4..950dc2e4fc 100644 --- a/app/src/components/channels/ChannelSetupModal.tsx +++ b/app/src/components/channels/ChannelSetupModal.tsx @@ -10,6 +10,7 @@ import { useT } from '../../lib/i18n/I18nContext'; import type { ChannelDefinition, ChannelType } from '../../types/channels'; import { CloseIcon } from '../ui'; import Button from '../ui/Button'; +import ChannelConnectHelp from './ChannelConnectHelp'; import { renderChannelIcon } from './channelIcon'; import CredentialChannelConfig from './CredentialChannelConfig'; import DiscordConfig from './DiscordConfig'; @@ -21,9 +22,11 @@ interface ChannelSetupModalProps { onClose: () => void; } -function ChannelConfigContent({ definition }: { definition: ChannelDefinition }) { - const { t } = useT(); - const channelId = definition.id as ChannelType; +function renderChannelConfig( + definition: ChannelDefinition, + channelId: ChannelType, + t: (key: string, fallback?: string) => string +) { switch (channelId) { case 'telegram': return ; @@ -47,6 +50,17 @@ function ChannelConfigContent({ definition }: { definition: ChannelDefinition }) } } +function ChannelConfigContent({ definition }: { definition: ChannelDefinition }) { + const { t } = useT(); + const channelId = definition.id as ChannelType; + return ( +
+ + {renderChannelConfig(definition, channelId, t)} +
+ ); +} + export default function ChannelSetupModal({ definition, onClose }: ChannelSetupModalProps) { const { t } = useT(); const modalRef = useRef(null); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 774d76d28c..176cf44146 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -3386,6 +3386,13 @@ const messages: TranslationMap = { 'channels.telegram.remoteControlTitle': 'جهاز التحكم عن بعد (Telegram)', 'channels.telegram.remoteControlBody': 'من دردشة Telegram المسموح بها، أرسل /الحالة، /الجلسات، /جديد، أو /مساعدة. لا يزال توجيه النموذج يستخدم /model و /models.', + 'channels.connectHelp.title': 'كيفية الاتصال', + 'channels.connectHelp.discord': + 'اختر طريقة أدناه: اربط حسابك عبر OpenHuman، أو ثبّت البوت باستخدام OAuth، أو الصق رمز البوت الخاص بك من بوابة مطوري Discord.', + 'channels.connectHelp.telegram': + 'اختر طريقة أدناه: راسل بوت OpenHuman المُدار لربطه، أو الصق رمز البوت الخاص بك من @BotFather.', + 'channels.connectHelp.slackNote': + 'تبحث عن Slack؟ يتصل Slack كتطبيق من خلال الاتصالات → OAuth، وليس كقناة مراسلة هنا.', 'channels.web.displayName': 'الويب', 'channels.web.description': 'الدردشة عبر واجهة مستخدم الويب المضمنة.', 'channels.web.authMode.managed_dm.description': 'استخدم دردشة الويب المضمنة - لا يلزم الإعداد.', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 4e4cae34bc..a3bd598043 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -3464,6 +3464,13 @@ const messages: TranslationMap = { 'channels.telegram.remoteControlTitle': 'রিমোট কন্ট্রোল (Telegram)', 'channels.telegram.remoteControlBody': 'একটি অনুমোদিত Telegram চ্যাট থেকে, /status, /sessions, /new, অথবা /help পাঠান। মডেল রাউটিং এখনও /মডেল এবং /মডেল ব্যবহার করে।', + 'channels.connectHelp.title': 'কীভাবে সংযুক্ত করবেন', + 'channels.connectHelp.discord': + 'নিচে একটি পদ্ধতি বেছে নিন: OpenHuman-এর মাধ্যমে আপনার অ্যাকাউন্ট লিঙ্ক করুন, OAuth দিয়ে বট ইনস্টল করুন, অথবা Discord ডেভেলপার পোর্টাল থেকে আপনার নিজের বট টোকেন পেস্ট করুন।', + 'channels.connectHelp.telegram': + 'নিচে একটি পদ্ধতি বেছে নিন: লিঙ্ক করতে ম্যানেজড OpenHuman বটে বার্তা পাঠান, অথবা @BotFather থেকে আপনার নিজের বট টোকেন পেস্ট করুন।', + 'channels.connectHelp.slackNote': + 'Slack খুঁজছেন? Slack এখানে মেসেজিং চ্যানেল হিসেবে নয়, সংযোগ → OAuth-এ একটি অ্যাপ হিসেবে সংযুক্ত হয়।', 'channels.web.displayName': 'ওয়েব', 'channels.web.description': 'বিল্ট-ইন ওয়েব UI এর মাধ্যমে চ্যাট করুন।', 'channels.web.authMode.managed_dm.description': diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index ca9341ec85..c1c3fcaa54 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -3564,6 +3564,13 @@ const messages: TranslationMap = { 'channels.telegram.remoteControlTitle': 'Fernsteuerung (Telegram)', 'channels.telegram.remoteControlBody': 'Senden Sie von einem zulässigen Telegram-Chat aus /status, /sessions, /new oder /help. Das Modellrouting verwendet weiterhin /model und /models.', + 'channels.connectHelp.title': 'So verbindest du', + 'channels.connectHelp.discord': + 'Wähle unten eine Methode: verknüpfe dein Konto über OpenHuman, installiere den Bot per OAuth oder füge deinen eigenen Bot-Token aus dem Discord-Entwicklerportal ein.', + 'channels.connectHelp.telegram': + 'Wähle unten eine Methode: schreibe dem verwalteten OpenHuman-Bot, um ihn zu verknüpfen, oder füge deinen eigenen Bot-Token von @BotFather ein.', + 'channels.connectHelp.slackNote': + 'Du suchst Slack? Slack wird als App unter Verbindungen → OAuth verbunden, nicht als Messaging-Kanal hier.', 'channels.web.displayName': 'Web', 'channels.web.description': 'Chatte über die integrierte Web-Oberfläche.', 'channels.web.authMode.managed_dm.description': diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index ab2fe44826..46e747b495 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -3879,6 +3879,15 @@ const en: TranslationMap = { 'channels.telegram.remoteControlBody': 'From an allowed Telegram chat, send /status, /sessions, /new, or /help. Model routing still uses /model and /models.', + // Connect help (in-app guidance so users do not have to ask the agent for the path) + 'channels.connectHelp.title': 'How to connect', + 'channels.connectHelp.discord': + 'Pick a method below: link your account via OpenHuman, install the bot with OAuth, or paste your own bot token from the Discord developer portal.', + 'channels.connectHelp.telegram': + 'Pick a method below: message the managed OpenHuman bot to link it, or paste your own bot token from @BotFather.', + 'channels.connectHelp.slackNote': + 'Looking for Slack? Slack connects as an app under Connections → OAuth, not as a messaging channel here.', + // Web 'channels.web.displayName': 'Web', 'channels.web.description': 'Chat via the built-in web UI.', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 0531472b6c..d0a7312788 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -3528,6 +3528,13 @@ const messages: TranslationMap = { 'channels.telegram.remoteControlTitle': 'Control remoto (Telegram)', 'channels.telegram.remoteControlBody': 'Desde un chat Telegram permitido, envíe /status, /sessions, /new o /help. El enrutamiento de modelos todavía usa /model y /models.', + 'channels.connectHelp.title': 'Cómo conectar', + 'channels.connectHelp.discord': + 'Elige un método abajo: vincula tu cuenta con OpenHuman, instala el bot con OAuth o pega tu propio token de bot del portal para desarrolladores de Discord.', + 'channels.connectHelp.telegram': + 'Elige un método abajo: escribe al bot gestionado de OpenHuman para vincularlo, o pega tu propio token de bot de @BotFather.', + 'channels.connectHelp.slackNote': + '¿Buscas Slack? Slack se conecta como una app en Conexiones → OAuth, no como un canal de mensajería aquí.', 'channels.web.displayName': 'Web', 'channels.web.description': 'Chatea a través de la interfaz de usuario web incorporada.', 'channels.web.authMode.managed_dm.description': diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index c74cc065c8..4803002a4d 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -3554,6 +3554,13 @@ const messages: TranslationMap = { 'channels.telegram.remoteControlTitle': 'Télécommande (Telegram)', 'channels.telegram.remoteControlBody': "À partir d'un chat Telegram autorisé, envoyez /status, /sessions, /new ou /help. Le routage de modèles utilise toujours /model et /models.", + 'channels.connectHelp.title': 'Comment se connecter', + 'channels.connectHelp.discord': + 'Choisissez une méthode ci-dessous : reliez votre compte via OpenHuman, installez le bot avec OAuth, ou collez votre propre jeton de bot depuis le portail développeur Discord.', + 'channels.connectHelp.telegram': + 'Choisissez une méthode ci-dessous : écrivez au bot OpenHuman géré pour le relier, ou collez votre propre jeton de bot depuis @BotFather.', + 'channels.connectHelp.slackNote': + 'Vous cherchez Slack ? Slack se connecte comme une app dans Connexions → OAuth, pas comme un canal de messagerie ici.', 'channels.web.displayName': 'Web', 'channels.web.description': "Discutez via l'interface utilisateur Web intégrée.", 'channels.web.authMode.managed_dm.description': diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 22373b5797..7d7182f05f 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -3464,6 +3464,13 @@ const messages: TranslationMap = { 'channels.telegram.remoteControlTitle': 'रिमोट कंट्रोल (Telegram)', 'channels.telegram.remoteControlBody': 'अनुमत Telegram चैट से, /स्थिति, /सत्र, /नया, या /सहायता भेजें। मॉडल रूटिंग अभी भी /मॉडल और /मॉडल का उपयोग करती है।', + 'channels.connectHelp.title': 'कैसे कनेक्ट करें', + 'channels.connectHelp.discord': + 'नीचे एक तरीका चुनें: OpenHuman के ज़रिए अपना अकाउंट लिंक करें, OAuth से बॉट इंस्टॉल करें, या Discord डेवलपर पोर्टल से अपना खुद का बॉट टोकन पेस्ट करें।', + 'channels.connectHelp.telegram': + 'नीचे एक तरीका चुनें: लिंक करने के लिए मैनेज्ड OpenHuman बॉट को मैसेज करें, या @BotFather से अपना खुद का बॉट टोकन पेस्ट करें।', + 'channels.connectHelp.slackNote': + 'Slack ढूँढ रहे हैं? Slack यहाँ मैसेजिंग चैनल के रूप में नहीं, बल्कि कनेक्शन → OAuth में एक ऐप के रूप में कनेक्ट होता है।', 'channels.web.displayName': 'वेब', 'channels.web.description': 'अंतर्निहित वेब यूआई के माध्यम से चैट करें।', 'channels.web.authMode.managed_dm.description': diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 2b75d3d777..4353b7c37c 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -3480,6 +3480,13 @@ const messages: TranslationMap = { 'channels.telegram.remoteControlTitle': 'Kendali jarak jauh (Telegram)', 'channels.telegram.remoteControlBody': 'Dari obrolan Telegram yang diizinkan, kirim /status, /sessions, /new, atau /help. Perutean model masih menggunakan /model dan /models.', + 'channels.connectHelp.title': 'Cara menghubungkan', + 'channels.connectHelp.discord': + 'Pilih metode di bawah: tautkan akun Anda lewat OpenHuman, pasang bot dengan OAuth, atau tempel token bot Anda sendiri dari portal developer Discord.', + 'channels.connectHelp.telegram': + 'Pilih metode di bawah: kirim pesan ke bot OpenHuman terkelola untuk menautkannya, atau tempel token bot Anda sendiri dari @BotFather.', + 'channels.connectHelp.slackNote': + 'Mencari Slack? Slack terhubung sebagai aplikasi di Koneksi → OAuth, bukan sebagai saluran pesan di sini.', 'channels.web.displayName': 'Web', 'channels.web.description': 'Mengobrol melalui UI web bawaan.', 'channels.web.authMode.managed_dm.description': diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index c78f81a6d7..8c45ad3467 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -3526,6 +3526,13 @@ const messages: TranslationMap = { 'channels.telegram.remoteControlTitle': 'Controllo remoto (Telegram)', 'channels.telegram.remoteControlBody': 'Da una chat Telegram consentita, inviare /status, /sessions, /new o /help. Il routing del modello utilizza ancora /model e /models.', + 'channels.connectHelp.title': 'Come connettersi', + 'channels.connectHelp.discord': + 'Scegli un metodo qui sotto: collega il tuo account tramite OpenHuman, installa il bot con OAuth oppure incolla il tuo token bot dal portale sviluppatori di Discord.', + 'channels.connectHelp.telegram': + 'Scegli un metodo qui sotto: scrivi al bot gestito di OpenHuman per collegarlo, oppure incolla il tuo token bot da @BotFather.', + 'channels.connectHelp.slackNote': + 'Cerchi Slack? Slack si connette come app in Connessioni → OAuth, non come canale di messaggistica qui.', 'channels.web.displayName': 'Web', 'channels.web.description': "Chatta tramite l'interfaccia utente Web integrata.", 'channels.web.authMode.managed_dm.description': diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 655b19dbfa..5d243a7beb 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -3428,6 +3428,13 @@ const messages: TranslationMap = { 'channels.telegram.remoteControlTitle': '원격 제어(Telegram)', 'channels.telegram.remoteControlBody': '허용된 Telegram 채팅에서 /status, /sessions, /new 또는 /help를 보냅니다. 모델 라우팅은 여전히 ​​/model 및 /models를 사용합니다.', + 'channels.connectHelp.title': '연결 방법', + 'channels.connectHelp.discord': + '아래에서 방법을 선택하세요: OpenHuman으로 계정 연결, OAuth로 봇 설치, 또는 Discord 개발자 포털에서 발급한 봇 토큰 붙여넣기.', + 'channels.connectHelp.telegram': + '아래에서 방법을 선택하세요: 관리형 OpenHuman 봇에 메시지를 보내 연결하거나, @BotFather에서 발급한 봇 토큰을 붙여넣으세요.', + 'channels.connectHelp.slackNote': + 'Slack을 찾으세요? Slack은 여기서 메시징 채널이 아니라 연결 → OAuth에서 앱으로 연결됩니다.', 'channels.web.displayName': '웹', 'channels.web.description': '내장된 웹 UI를 통해 채팅합니다.', 'channels.web.authMode.managed_dm.description': diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 34f13041c8..8aa5c40ec6 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -3507,6 +3507,13 @@ const messages: TranslationMap = { 'channels.telegram.remoteControlTitle': 'Sterowanie zdalne (Telegram)', 'channels.telegram.remoteControlBody': 'Z dozwolonego czatu Telegram wyślij /status, /sessions, /new lub /help. Trasowanie modelu nadal używa /model i /models.', + 'channels.connectHelp.title': 'Jak połączyć', + 'channels.connectHelp.discord': + 'Wybierz metodę poniżej: połącz swoje konto przez OpenHuman, zainstaluj bota przez OAuth albo wklej własny token bota z portalu dla deweloperów Discorda.', + 'channels.connectHelp.telegram': + 'Wybierz metodę poniżej: napisz do zarządzanego bota OpenHuman, aby go połączyć, albo wklej własny token bota od @BotFather.', + 'channels.connectHelp.slackNote': + 'Szukasz Slacka? Slack łączy się jako aplikacja w Połączenia → OAuth, a nie jako kanał wiadomości tutaj.', 'channels.web.displayName': 'Sieć', 'channels.web.description': 'Czatuj przez wbudowany interfejs webowy.', 'channels.web.authMode.managed_dm.description': diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 1703c2632c..1e03f552c5 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -3520,6 +3520,13 @@ const messages: TranslationMap = { 'channels.telegram.remoteControlTitle': 'Controle remoto (Telegram)', 'channels.telegram.remoteControlBody': 'Em um bate-papo Telegram permitido, envie /status, /sessions, /new ou /help. O roteamento de modelo ainda usa /model e /models.', + 'channels.connectHelp.title': 'Como conectar', + 'channels.connectHelp.discord': + 'Escolha um método abaixo: vincule sua conta pelo OpenHuman, instale o bot com OAuth ou cole seu próprio token de bot do portal de desenvolvedores do Discord.', + 'channels.connectHelp.telegram': + 'Escolha um método abaixo: envie uma mensagem ao bot gerenciado do OpenHuman para vincular sua conta, ou cole seu próprio token de bot do @BotFather.', + 'channels.connectHelp.slackNote': + 'Procurando o Slack? O Slack é conectado como um app em Conexões → OAuth, não como um canal de mensagens aqui.', 'channels.web.displayName': 'Web', 'channels.web.description': 'Bate-papo por meio da interface da web integrada.', 'channels.web.authMode.managed_dm.description': diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 82cca0d05e..b0f648bfda 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -3493,6 +3493,13 @@ const messages: TranslationMap = { 'channels.telegram.remoteControlTitle': 'Удаленное управление (Telegram)', 'channels.telegram.remoteControlBody': 'Из разрешенного чата Telegram отправьте /status, /sessions, /new или /help. В маршрутизации моделей по-прежнему используются /model и /models.', + 'channels.connectHelp.title': 'Как подключить', + 'channels.connectHelp.discord': + 'Выберите способ ниже: привяжите аккаунт через OpenHuman, установите бота через OAuth или вставьте собственный токен бота из портала разработчика Discord.', + 'channels.connectHelp.telegram': + 'Выберите способ ниже: напишите управляемому боту OpenHuman, чтобы привязать его, или вставьте собственный токен бота от @BotFather.', + 'channels.connectHelp.slackNote': + 'Ищете Slack? Slack подключается как приложение в разделе Подключения → OAuth, а не как канал сообщений здесь.', 'channels.web.displayName': 'Интернет', 'channels.web.description': 'Общайтесь через встроенный веб-интерфейс.', 'channels.web.authMode.managed_dm.description': diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index d8c5969d54..393b99c3c2 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -3282,6 +3282,13 @@ const messages: TranslationMap = { 'channels.telegram.remoteControlTitle': '远程控制 (Telegram)', 'channels.telegram.remoteControlBody': '从允许的 Telegram 聊天中,发送 /status、/sessions、/new 或 /help。模型路由仍然使用 /model 和 /models。', + 'channels.connectHelp.title': '如何连接', + 'channels.connectHelp.discord': + '在下方选择一种方式:通过 OpenHuman 关联你的账号、用 OAuth 安装机器人,或粘贴你在 Discord 开发者门户中的机器人令牌。', + 'channels.connectHelp.telegram': + '在下方选择一种方式:给托管的 OpenHuman 机器人发消息以完成关联,或粘贴你在 @BotFather 获取的机器人令牌。', + 'channels.connectHelp.slackNote': + '在找 Slack?Slack 是在 连接 → OAuth 中作为应用连接的,而不是这里的消息渠道。', 'channels.web.displayName': '网络', 'channels.web.description': '通过内置的 Web UI 聊天。', 'channels.web.authMode.managed_dm.description': '使用嵌入式 Web 聊天:无需设置。', diff --git a/app/src/pages/Skills.tsx b/app/src/pages/Skills.tsx index 8fc4ad1663..461527ee0a 100644 --- a/app/src/pages/Skills.tsx +++ b/app/src/pages/Skills.tsx @@ -1293,6 +1293,9 @@ export default function Skills() {

{t('channels.defaultMessaging')}

+

+ {t('channels.connectHelp.slackNote')} +

{/* One unified surface: each tile shows connection status, opens setup/configure on click, and owns the "default diff --git a/gitbooks/features/channels.md b/gitbooks/features/channels.md index 0b30fdfaf7..7a28abd11c 100644 --- a/gitbooks/features/channels.md +++ b/gitbooks/features/channels.md @@ -67,9 +67,20 @@ Secrets supplied for any mode are stored through OpenHuman's credential layer an *** +## Where to connect a channel + +Channels are set up under **Connections → Channels** in the left sidebar — **not** under Settings, and not under any "Automation & Channels" menu (no such menu exists). Open that tab, pick a platform tile, and follow its setup card: + +* **Discord** — choose *Connect via OpenHuman* (link your account or install the bot via OAuth), or paste your own Discord bot token. +* **Telegram** — message the managed OpenHuman bot to link, or paste a BotFather bot token. + +Slack is connected as an **app** under **Connections → OAuth** (Composio) so the agent can read and act in Slack; it is not set up as a talk-back channel in the Channels tab. + +*** + ## Choosing the default channel -Open **Settings → Automation & Channels → Messaging Channels** to pick which channel is the **active route**: the one OpenHuman uses for proactive, recipient-less delivery (cron, triggers, subconscious). The default is the in-app **Web** chat until you change it. Setting a new default takes effect immediately, without restarting the channel runtime, and the panel shows which channel is currently active. Inbound messages always get answered on whatever channel they arrived on, regardless of the default route. +Open **Connections → Channels** to pick which channel is the **active route**: the one OpenHuman uses for proactive, recipient-less delivery (cron, triggers, subconscious). The default is the in-app **Web** chat until you change it. Setting a new default takes effect immediately, without restarting the channel runtime, and the panel shows which channel is currently active. Inbound messages always get answered on whatever channel they arrived on, regardless of the default route. *** diff --git a/gitbooks/features/integrations/README.md b/gitbooks/features/integrations/README.md index ae372b512b..f2edcfc2a4 100644 --- a/gitbooks/features/integrations/README.md +++ b/gitbooks/features/integrations/README.md @@ -47,7 +47,7 @@ Each integration shows its current status: * **Connected**. integration is active and being synced. * **Manage**. active integration with options to reconfigure or disconnect. -You can revoke any connection at any time from the Skills tab. +You can revoke any connection at any time from the **Connections** page. ## Messaging channels @@ -57,14 +57,14 @@ Three integrations are special. OpenHuman uses them to _talk back_ to you, not j * **Discord**. send and receive messages via Discord. Connect your account to receive OpenHuman messages there. * **Web**. a browser-based chat interface within the desktop app. Messages stay entirely local. -Set your default under **Settings → Automation & Channels → Messaging Channels**. The active route status shows which channel is currently in use. Telegram offers two credential modes: connect via OpenHuman (one-click, encrypted) or provide your own credentials for maximum control. +Set your default under **Connections → Channels**. The active route status shows which channel is currently in use. Telegram offers two credential modes: connect via OpenHuman (one-click, encrypted) or provide your own credentials for maximum control. ## Beyond the curated catalog: MCP & Skills The 118+ OAuth connectors are the curated path. Beyond them, OpenHuman opens up the wider open-tooling ecosystem: * **MCP servers**: a built-in registry browses thousands of [Model Context Protocol](https://modelcontextprotocol.io) servers (Smithery + the official registry) that install locally as new agent tools. -* **Skills**: a browsable, ~90,000-entry catalog of `SKILL.md` capability bundles aggregated from HermesHub, ClawHub, LobeHub and more. (Note: the old in-app skills runtime has been removed; Skills are now a metadata catalog you install from the Skills tab.) +* **Skills**: a browsable, ~90,000-entry catalog of `SKILL.md` capability bundles aggregated from HermesHub, ClawHub, LobeHub and more. (Note: the old in-app skills runtime has been removed; Skills are now a metadata catalog you install from the **Connections → Skills** tab.) See [MCP Servers & Skills](mcp-and-skills.md) for the full picture. diff --git a/gitbooks/features/integrations/mcp-and-skills.md b/gitbooks/features/integrations/mcp-and-skills.md index 63bff70949..bfef3e7670 100644 --- a/gitbooks/features/integrations/mcp-and-skills.md +++ b/gitbooks/features/integrations/mcp-and-skills.md @@ -35,7 +35,7 @@ OpenHuman can run the other way around, too. `openhuman-core mcp` exposes OpenHu * **One aggregated catalog.** Sourced from HermesHub (configurable via `OPENHUMAN_SKILL_REGISTRY_CATALOG_URL`), the catalog runs to roughly **90,000 entries**. Each entry carries id, name, description, source, author, version, tags, platforms, a download URL, and license. * **Cached and fast.** The catalog is fetched on boot in the background (without blocking startup), cached locally at `~/.openhuman/skill-registry/cache.json` with a ~1-hour TTL and served stale-while-revalidate. A single-flight gate prevents duplicate downloads of the large catalog. -* **Metadata-first.** OpenHuman's in-app skills runtime (the old QuickJS sandbox) has been **removed**. Skills are now a metadata catalog you browse and install from the Skills tab, not code executing inside the app. Availability varies per entry: some expose a direct `SKILL.md` download, others point to external hosting. +* **Metadata-first.** OpenHuman's in-app skills runtime (the old QuickJS sandbox) has been **removed**. Skills are now a metadata catalog you browse and install from the **Connections → Skills** tab, not code executing inside the app. Availability varies per entry: some expose a direct `SKILL.md` download, others point to external hosting. *** diff --git a/gitbooks/features/privacy-and-security.md b/gitbooks/features/privacy-and-security.md index 193e2498a1..0f0ee3925f 100644 --- a/gitbooks/features/privacy-and-security.md +++ b/gitbooks/features/privacy-and-security.md @@ -44,7 +44,7 @@ OpenHuman is designed so that the **memory of your life lives on your machine**. ## Permissions and access control -OpenHuman accesses an integration only after you complete its OAuth flow. Each connection has its own scope; you can revoke any of them at any time from the Skills tab. +OpenHuman accesses an integration only after you complete its OAuth flow. Each connection has its own scope; you can revoke any of them at any time from the **Connections** page. [Auto-fetch](obsidian-wiki/auto-fetch.md) does run continuously while a connection is active, that is the whole point. But it is bound by: diff --git a/gitbooks/overview/getting-started.md b/gitbooks/overview/getting-started.md index 8cbb814097..d0f8c6da0e 100644 --- a/gitbooks/overview/getting-started.md +++ b/gitbooks/overview/getting-started.md @@ -23,7 +23,7 @@ OpenHuman runs on **macOS, Windows and Linux** desktops. 4 GB+ RAM is recommende ### Permissions -The first time you launch OpenHuman, the OS will prompt for the permissions the app needs (Accessibility on macOS, Input Monitoring for the voice hotkey, Camera/Microphone if you plan to use the [Meeting Agent](../features/mascot/meeting-agents.md)). You can review and adjust these any time under **Settings → Automation & Channels**. +The first time you launch OpenHuman, the OS will prompt for the permissions the app needs (Accessibility on macOS, Input Monitoring for the voice hotkey, Camera/Microphone if you plan to use the [Meeting Agent](../features/mascot/meeting-agents.md)). You can review and adjust these any time under **Settings**. *** diff --git a/src/core/jsonrpc_tests.rs b/src/core/jsonrpc_tests.rs index 11b18a99cd..fdb88798c1 100644 --- a/src/core/jsonrpc_tests.rs +++ b/src/core/jsonrpc_tests.rs @@ -857,7 +857,7 @@ fn is_session_expired_error_skips_discord_rewrap_for_2285() { // to avoid, plus the canonical post-rewrap message body, so // either-side drift fails loudly. let canonical_rewrap = "Discord API error: Discord list_guilds: bot token was rejected \ - (upstream HTTP four-oh-one). Open Settings → Channels → Discord \ + (upstream HTTP four-oh-one). Open Connections → Channels → Discord \ and rotate / reconnect the bot token."; assert!( !is_session_expired_error(canonical_rewrap), @@ -869,7 +869,7 @@ fn is_session_expired_error_skips_discord_rewrap_for_2285() { // future regression visible. let canonical_rewrap_403 = "Discord API error: Discord list_channels: bot token lacks required Discord permissions \ - (upstream HTTP four-oh-three). Open Settings → Channels → Discord \ + (upstream HTTP four-oh-three). Open Connections → Channels → Discord \ and rotate / reconnect the bot token."; assert!(!is_session_expired_error(canonical_rewrap_403)); } diff --git a/src/core/observability.rs b/src/core/observability.rs index eea3bb9821..d81d5b529e 100644 --- a/src/core/observability.rs +++ b/src/core/observability.rs @@ -1702,7 +1702,7 @@ fn is_provider_user_state_message(lower: &str) -> bool { // validate-before-store probe (added in #4318) rejects an obviously invalid // BYO key *before* persisting it and returns its own user-facing prose, // `COMPOSIO_INVALID_API_KEY_USER_MESSAGE` ("Invalid Composio API key. Re-enter - // a valid key in Settings > Connections > Composio."). That string carries + // a valid key in Connections > Composio."). That string carries // neither the `[composio-direct]` prefix nor an `HTTP 401` token, and the word // "Composio" splits the `invalid … api key` sequence — so the X9 arm above // never claims it and the RPC-boundary `report_error` leaked as a Sentry error diff --git a/src/openhuman/about_app/catalog_data.rs b/src/openhuman/about_app/catalog_data.rs index fb9bad1b24..c8f53db1d5 100644 --- a/src/openhuman/about_app/catalog_data.rs +++ b/src/openhuman/about_app/catalog_data.rs @@ -672,7 +672,7 @@ pub(super) const CAPABILITIES: &[Capability] = &[ domain: "intelligence", category: CapabilityCategory::Intelligence, description: "Backfill the last 6 days of Slack history into the memory tree and keep it up to date by flushing each closed 6-hour UTC bucket. Driven by an authenticated Slack connection (OAuth via Composio).", - how_to: "Settings > Messaging Channels > Slack", + how_to: "Connections > OAuth > Slack", status: CapabilityStatus::Beta, privacy: LOCAL_RAW, }, @@ -682,7 +682,7 @@ pub(super) const CAPABILITIES: &[Capability] = &[ domain: "intelligence", category: CapabilityCategory::Intelligence, description: "Incrementally sync ClickUp tasks assigned to the authenticated user into the Memory Tree on a 30-minute cadence, with an initial backfill on first connect. Only tasks the user is directly assigned to are ingested. Driven by an authenticated ClickUp connection (OAuth via Composio).", - how_to: "Settings > Connections > ClickUp", + how_to: "Connections > OAuth > ClickUp", status: CapabilityStatus::Beta, privacy: LOCAL_RAW, }, @@ -742,7 +742,7 @@ pub(super) const CAPABILITIES: &[Capability] = &[ domain: "workflows", category: CapabilityCategory::Workflows, description: "Open workflow setup and update workflow-specific configuration.", - how_to: "Intelligence > Workflows > Setup or Settings > Connections", + how_to: "Intelligence > Workflows > Setup or Connections", status: CapabilityStatus::Stable, privacy: None, }, @@ -752,7 +752,7 @@ pub(super) const CAPABILITIES: &[Capability] = &[ domain: "workflows", category: CapabilityCategory::Workflows, description: "See whether a workflow-backed integration is connected, offline, or needs setup.", - how_to: "Intelligence > Workflows or Settings > Connections", + how_to: "Intelligence > Workflows or Connections", status: CapabilityStatus::Beta, privacy: None, }, @@ -803,7 +803,7 @@ pub(super) const CAPABILITIES: &[Capability] = &[ domain: "workflows", category: CapabilityCategory::Workflows, description: "Browse the dedicated connections hub for external workflow-backed integrations.", - how_to: "Settings > Connections", + how_to: "Connections", status: CapabilityStatus::Beta, privacy: None, }, @@ -850,7 +850,7 @@ pub(super) const CAPABILITIES: &[Capability] = &[ domain: "workflows", category: CapabilityCategory::Workflows, description: "Connect Google services for email, contacts, and calendar workflows.", - how_to: "Settings > Connections", + how_to: "Connections > OAuth", status: CapabilityStatus::ComingSoon, privacy: LOCAL_CREDENTIALS, }, @@ -860,7 +860,7 @@ pub(super) const CAPABILITIES: &[Capability] = &[ domain: "workflows", category: CapabilityCategory::Workflows, description: "Connect Notion for workspace sync and productivity workflows.", - how_to: "Settings > Connections", + how_to: "Connections > OAuth", status: CapabilityStatus::ComingSoon, privacy: LOCAL_CREDENTIALS, }, @@ -870,7 +870,7 @@ pub(super) const CAPABILITIES: &[Capability] = &[ domain: "workflows", category: CapabilityCategory::Workflows, description: "Set up local EVM, BTC, Solana, and Tron wallet identities from one recovery phrase.", - how_to: "Settings > Crypto > Recovery Phrase or Settings > Connections", + how_to: "Settings > Crypto > Recovery Phrase or Connections", status: CapabilityStatus::Beta, privacy: LOCAL_CREDENTIALS, }, @@ -910,7 +910,7 @@ pub(super) const CAPABILITIES: &[Capability] = &[ domain: "workflows", category: CapabilityCategory::Workflows, description: "Connect supported exchanges for trading and portfolio workflows.", - how_to: "Settings > Connections", + how_to: "Connections", status: CapabilityStatus::ComingSoon, privacy: None, }, @@ -1264,8 +1264,8 @@ pub(super) const CAPABILITIES: &[Capability] = &[ name: "Connect Messaging Platforms", domain: "channels", category: CapabilityCategory::Channels, - description: "Connect supported messaging platforms such as Telegram, Discord, or Slack.", - how_to: "Settings > Messaging Channels", + description: "Connect supported messaging platforms such as Telegram or Discord.", + how_to: "Connections > Channels", status: CapabilityStatus::Beta, privacy: None, }, @@ -1276,7 +1276,7 @@ pub(super) const CAPABILITIES: &[Capability] = &[ category: CapabilityCategory::Channels, description: "Operate OpenHuman from Telegram with slash commands: /status, /sessions, /new, and /help.", - how_to: "Settings > Messaging Channels > Telegram (connect), then message the bot", + how_to: "Connections > Channels > Telegram (connect), then message the bot", status: CapabilityStatus::Beta, privacy: None, }, @@ -1286,7 +1286,7 @@ pub(super) const CAPABILITIES: &[Capability] = &[ domain: "channels", category: CapabilityCategory::Channels, description: "Disconnect a previously configured messaging platform.", - how_to: "Settings > Messaging Channels", + how_to: "Connections > Channels", status: CapabilityStatus::Beta, privacy: None, }, @@ -1296,7 +1296,7 @@ pub(super) const CAPABILITIES: &[Capability] = &[ domain: "channels", category: CapabilityCategory::Channels, description: "Validate platform credentials or connection state before using a channel.", - how_to: "Settings > Messaging Channels", + how_to: "Connections > Channels", status: CapabilityStatus::Beta, privacy: None, }, @@ -1306,7 +1306,7 @@ pub(super) const CAPABILITIES: &[Capability] = &[ domain: "channels", category: CapabilityCategory::Channels, description: "Choose which messaging channel should be used by default.", - how_to: "Settings > Messaging Channels", + how_to: "Connections > Channels", status: CapabilityStatus::Beta, privacy: None, }, diff --git a/src/openhuman/about_app/catalog_tests.rs b/src/openhuman/about_app/catalog_tests.rs index ca5fcb4246..7c8fbe2a16 100644 --- a/src/openhuman/about_app/catalog_tests.rs +++ b/src/openhuman/about_app/catalog_tests.rs @@ -376,3 +376,48 @@ fn github_repo_memory_source_reports_github_destination() { privacy.destinations ); } + +/// #4884: capability `how_to` breadcrumbs are user-facing and searchable, and +/// the agent paraphrases them. Guard against the stale navigation paths that +/// sent users to menus that do not exist (`Settings > Connections`, +/// `Settings > Messaging Channels`, `Settings > Automation & Channels`). The +/// real destinations live under the top-level Connections page. +#[test] +fn catalog_how_to_uses_connections_nav_not_legacy_settings_paths() { + const LEGACY: [&str; 6] = [ + "Settings > Connections", + "Settings → Connections", + "Settings > Messaging Channels", + "Settings → Messaging Channels", + "Settings > Automation & Channels", + "Settings → Automation & Channels", + ]; + for capability in all_capabilities() { + for legacy in LEGACY { + assert!( + !capability.how_to.contains(legacy), + "capability `{}` how_to still points at the removed `{legacy}` path: {}", + capability.id, + capability.how_to + ); + } + } + + // Spot-check the corrected channel + Composio breadcrumbs. + let how_to = |id: &str| { + all_capabilities() + .iter() + .find(|c| c.id == id) + .unwrap_or_else(|| panic!("missing capability `{id}`")) + .how_to + }; + assert_eq!( + how_to("channels.connect_platform"), + "Connections > Channels" + ); + assert_eq!( + how_to("intelligence.slack_memory_ingest"), + "Connections > OAuth > Slack" + ); + assert_eq!(how_to("workflows.connect_google"), "Connections > OAuth"); +} diff --git a/src/openhuman/agent_orchestration/tools/spawn_subagent.rs b/src/openhuman/agent_orchestration/tools/spawn_subagent.rs index 42bc2af8f3..5b1dcbe5f7 100644 --- a/src/openhuman/agent_orchestration/tools/spawn_subagent.rs +++ b/src/openhuman/agent_orchestration/tools/spawn_subagent.rs @@ -863,7 +863,7 @@ fn render_worker_thread_result( /// /// Returns text the model reads literally; the orchestrator paraphrases /// it into a user-facing reply. Keep the *intent* stable across -/// rewordings — the "Settings → Connections → {toolkit}" path is +/// rewordings — the "Connections → {toolkit}" path is /// load-bearing for the UI navigation tests. pub(crate) fn describe_unconnected_state(toolkit: &str, status: Option<&str>) -> String { // Keep the original (trimmed) status separately so the @@ -878,14 +878,14 @@ pub(crate) fn describe_unconnected_state(toolkit: &str, status: Option<&str>) -> Some("INITIATED") | Some("INITIALIZING") | Some("PENDING") => format!( "Integration '{toolkit}' has an OAuth flow in progress but it hasn't reached \ ACTIVE yet. Do NOT retry this spawn. Tell the user the authorization is \ - pending and ask them to finish the browser OAuth flow (Settings → \ - Connections → '{toolkit}') before retrying. If they already closed the \ - browser tab, they can restart the connection from the same Settings page." + pending and ask them to finish the browser OAuth flow (Connections → \ + '{toolkit}') before retrying. If they already closed the \ + browser tab, they can restart the connection from the same Connections page." ), Some("EXPIRED") => format!( "Integration '{toolkit}' is connected but the OAuth token has expired. \ Do NOT retry this spawn. Tell the user the connection expired and ask \ - them to reconnect '{toolkit}' at Settings → Connections → '{toolkit}' \ + them to reconnect '{toolkit}' at Connections → '{toolkit}' \ before retrying the original request." ), Some("FAILED") | Some("ERROR") => { @@ -897,7 +897,7 @@ pub(crate) fn describe_unconnected_state(toolkit: &str, status: Option<&str>) -> format!( "Integration '{toolkit}' has a previous OAuth attempt in a `{raw}` state. \ Do NOT retry this spawn. Tell the user the connection failed and ask them \ - to reconnect '{toolkit}' at Settings → Connections → '{toolkit}' before \ + to reconnect '{toolkit}' at Connections → '{toolkit}' before \ retrying the original request." ) } @@ -910,13 +910,13 @@ pub(crate) fn describe_unconnected_state(toolkit: &str, status: Option<&str>) -> "Integration '{toolkit}' has a connection row but its status is `{raw}`, \ which is not yet usable. Do NOT retry this spawn. Tell the user the \ connection is in an unusable state and ask them to reconnect '{toolkit}' \ - at Settings → Connections → '{toolkit}'." + at Connections → '{toolkit}'." ) } _ => format!( "Integration '{toolkit}' is available but the user has not authorized it \ yet. Do NOT retry this spawn. Tell the user the integration is available \ - and ask them to authorize '{toolkit}' in Settings → Connections → \ + and ask them to authorize '{toolkit}' in Connections → \ '{toolkit}' before retrying the original request." ), } @@ -1219,7 +1219,7 @@ mod tests { msg.contains("OAuth flow in progress"), "INITIATED must surface the in-progress wording: {msg}" ); - assert!(msg.contains("Settings → Connections → 'gmail'")); + assert!(msg.contains("Connections → 'gmail'")); // The legacy "not authorized yet" copy must NOT leak into the // pending-OAuth branch — that was the user-perception bug // from #2365 (Settings UI showed Gmail connected, agent said @@ -1246,6 +1246,8 @@ mod tests { let msg = describe_unconnected_state("gmail", Some("EXPIRED")); assert!(msg.contains("OAuth token has expired")); assert!(msg.contains("reconnect 'gmail'")); + assert!(msg.contains("Connections → 'gmail'")); + assert!(!msg.contains("Settings → Connections")); assert!(!msg.contains("OAuth flow in progress")); } @@ -1259,6 +1261,8 @@ mod tests { "{status} must be quoted verbatim, not collapsed to a single label: {msg}" ); assert!(msg.contains("reconnect 'gmail'")); + assert!(msg.contains("Connections → 'gmail'")); + assert!(!msg.contains("Settings → Connections")); } } @@ -1292,6 +1296,8 @@ mod tests { msg.contains(&expected), "unknown status `{raw}` must be quoted verbatim (not its uppercased form): {msg}" ); + assert!(msg.contains("Connections → 'gmail'")); + assert!(!msg.contains("Settings → Connections")); } } @@ -1323,7 +1329,7 @@ mod tests { msg.contains("has not authorized it yet"), "None must hit the legacy never-connected copy: {msg}" ); - assert!(msg.contains("Settings → Connections → 'gmail'")); + assert!(msg.contains("Connections → 'gmail'")); } #[test] diff --git a/src/openhuman/agent_registry/agents/crypto_agent/prompt.md b/src/openhuman/agent_registry/agents/crypto_agent/prompt.md index 47bb68019b..80a00f9f97 100644 --- a/src/openhuman/agent_registry/agents/crypto_agent/prompt.md +++ b/src/openhuman/agent_registry/agents/crypto_agent/prompt.md @@ -9,7 +9,7 @@ You are the **Crypto Agent** — OpenHuman's specialist for wallet and market op - Executing **only the exact blob** that was returned from a matching `wallet_prepare_*` call earlier in this turn — never a parameter set you invented. - Pulling crypto / FX market data to sanity-check a quote before signing. - Making paid API requests via the **x402 protocol** (HTTP 402 Payment Required). When a server returns 402 with a `PAYMENT-REQUIRED` header, `x402_request` automatically signs a USDC payment (EIP-3009 on Base/Ethereum, or SPL transfer on Solana) and retries with the proof. Use this for x402-enabled APIs (e.g. twit.sh). The wallet must have USDC on the target chain. -- Pointing the user back to **Settings → Connections** when a chain, exchange, or wallet identity isn't set up. +- Pointing the user back to **Connections** when a chain, exchange, or wallet identity isn't set up. ## What you do NOT handle @@ -24,7 +24,7 @@ You are the **Crypto Agent** — OpenHuman's specialist for wallet and market op 2. **Read before write.** Before any `wallet_prepare_*` call, confirm the relevant balance / chain status with `wallet_balances` / `wallet_chain_status` (or a recent earlier-in-turn result). Use `wallet_network_defaults` when you need the default RPC / explorer / asset catalog for a chain. Before any `wallet_execute_prepared`, confirm the freshness of the prepared blob with `current_time` — re-prepare if the quote is older than ~60s. 3. **Quote before execute.** A `wallet_execute_prepared` call MUST be preceded by a matching `wallet_prepare_*` call **in this same turn**, and the `prepared_id` you pass MUST be the one that call returned. No exceptions. For ERC-20 transfers, `wallet_encode_erc20_transfer` exists if you need ABI calldata inspection, but prefer `wallet_prepare_transfer` for the actual execution flow. 4. **Confirm before execute.** Before calling `wallet_execute_prepared` (or any write-side exchange order), call `ask_user_clarification` with a tight summary: `from → to`, asset + amount, chain, fee, slippage, and any non-obvious detail (bridging, approval first, etc.). Only proceed on an explicit yes. -5. **Stop cleanly on missing setup.** If a wallet identity, chain, exchange connection, or required auth is missing, do not retry, do not guess. Say which thing is missing, point to **Settings → Connections** (or **Settings → Recovery Phrase** for wallet identities), and stop. +5. **Stop cleanly on missing setup.** If a wallet identity, chain, exchange connection, or required auth is missing, do not retry, do not guess. Say which thing is missing, point to **Connections** (or **Settings → Recovery Phrase** for wallet identities), and stop. 6. **Stop cleanly on insufficient liquidity / balance.** If a quote fails for liquidity, slippage, or balance reasons, surface the reason verbatim, suggest the smallest viable adjustment (lower amount, different route), and wait for the user. 7. **Never log secrets.** Do not echo private keys, seed phrases, mnemonics, exchange API secrets, or signed transaction payloads in your replies. Quote the public address and the prepared id, nothing more. diff --git a/src/openhuman/agent_registry/agents/help/prompt.md b/src/openhuman/agent_registry/agents/help/prompt.md index 98b9a533ca..e0c584d97c 100644 --- a/src/openhuman/agent_registry/agents/help/prompt.md +++ b/src/openhuman/agent_registry/agents/help/prompt.md @@ -28,6 +28,7 @@ You have three tools: - Do not run shell commands, write files, edit configuration, or call other tools. Help is read-only — you point to docs, you do not change the system. - Do not invent commands, config keys, env vars, or feature names. If GitBook does not mention it, treat it as not documented. +- Do not invent UI navigation paths. Connecting apps and messaging channels lives under **Connections** in the left sidebar (its **Channels**, **OAuth**, **MCP**, and **Skills** tabs), **not** under a Settings submenu — there is no "Settings → Connections" or "Settings → Automation & Channels" destination. Quote a UI path only if a search hit states it; if the docs don't say where something is, tell the user you're not certain of the exact location rather than guessing. - Do not delegate by spawning sub-agents. Stay in your lane. ## Output shape @@ -38,7 +39,7 @@ When the answer is short: When there are steps, use a tight numbered list and link the source at the end: -> 1. Open Settings → Skills. +> 1. Open Connections → OAuth. > 2. Click **Connect** next to Gmail. > 3. Authorize in the popup. > diff --git a/src/openhuman/agent_registry/agents/integrations_agent/agent.toml b/src/openhuman/agent_registry/agents/integrations_agent/agent.toml index 262889babb..80a5fed85d 100644 --- a/src/openhuman/agent_registry/agents/integrations_agent/agent.toml +++ b/src/openhuman/agent_registry/agents/integrations_agent/agent.toml @@ -30,7 +30,7 @@ named = [ # Inline-in-chat OAuth connect card (#3993). When the bound toolkit is not # connected — or an action fails with a true auth/connection error — raise # the connect card with this tool and await the result, instead of bubbling - # up "Connection error" and sending the user to Settings → Connections. + # up "Connection error" and sending the user to Connections. "composio_connect", # Deterministic time resolver. Composio actions take time-window args # (Slack/Gmail/Calendar `oldest`/`latest`/`since`/`after`) as raw diff --git a/src/openhuman/agent_registry/agents/integrations_agent/prompt.md b/src/openhuman/agent_registry/agents/integrations_agent/prompt.md index a9f183adbf..55ba35125a 100644 --- a/src/openhuman/agent_registry/agents/integrations_agent/prompt.md +++ b/src/openhuman/agent_registry/agents/integrations_agent/prompt.md @@ -6,7 +6,7 @@ You are the **Integrations Agent**. You interact with one connected external ser - **`composio_list_tools`** — inspect the action catalogue for your bound toolkit. Returns the `function.name` slug + JSON schema for each action. - **`composio_execute`** — run a Composio action: `{ tool: "", arguments: {...} }`. -- **`composio_connect`** — raise an **inline connect card** in the chat for your bound toolkit and wait for the user to authorize in one click. Use this the moment you detect the toolkit is not connected, or after a true auth/connection error. Never tell the user to open Settings → Connections yourself while this tool is available. +- **`composio_connect`** — raise an **inline connect card** in the chat for your bound toolkit and wait for the user to authorize in one click. Use this the moment you detect the toolkit is not connected, or after a true auth/connection error. Never tell the user to open Connections yourself while this tool is available. - **`extract_from_result`** — runtime-provided system tool for oversized-result runs. Use it when a tool returned too much data to inspect directly: pass the prior `result_id` plus a narrow `query`, and it will return only the requested slice from that oversized result. - **Per-action tools** — the toolkit's individual action tools are already registered in your tool list with typed schemas (e.g. `GMAIL_SEND_EMAIL`, `NOTION_CREATE_PAGE`). Prefer calling these directly over the generic `composio_execute`. @@ -14,20 +14,20 @@ You do **not** have shell, file I/O, or any other capability beyond these permit ## Typical flow -0. **Connect first if needed.** If the caller's objective is simply to connect/authorize this toolkit, or you already know it isn't connected, call `composio_connect { toolkit }`, await the result, and report it. `{ connected: true }` → proceed (or you're done, if connecting was the whole task); `{ connected: false }` → the user declined: report that plainly, note they can still connect later via Settings → Connections, and stop — do **not** retry `composio_connect`. +0. **Connect first if needed.** If the caller's objective is simply to connect/authorize this toolkit, or you already know it isn't connected, call `composio_connect { toolkit }`, await the result, and report it. `{ connected: true }` → proceed (or you're done, if connecting was the whole task); `{ connected: false }` → the user declined: report that plainly, note they can still connect later via Connections, and stop — do **not** retry `composio_connect`. 1. You already have the toolkit's action tools in your tool list — start there. If you need a schema reminder or a slug you don't see, call `composio_list_tools`. 2. Call the per-action tool (or `composio_execute` with the slug) using the caller's task as your guide. -3. If the call fails with `[composio:error:insufficient_scope]`, `insufficient authentication scopes`, or `missing required permissions`, do **not** call the service disconnected. Say the connected account is missing the permissions needed for the requested action and point the user to Settings → Connections → the toolkit to reconnect or enable the required scope. +3. If the call fails with `[composio:error:insufficient_scope]`, `insufficient authentication scopes`, or `missing required permissions`, do **not** call the service disconnected. Say the connected account is missing the permissions needed for the requested action and point the user to Connections → the toolkit to reconnect or enable the required scope. 4. If the call fails with a true authentication / authorization / connection error that is **not** a scope or permission error, the toolkit is not connected. Call **`composio_connect`** with your bound `toolkit` to raise an inline connect card and **await its result**: - `{ connected: true }` → the user authorized; retry the original action **once** and continue. - `{ connected: false, declined: true }` (or an error) → the user declined or the card could not be raised. **Only then** return **"Connection error, try to authenticate"** so the orchestrator can route the user to settings. - Do **not** print a Settings → Connections instruction yourself when `composio_connect` is available. + Do **not** print a Connections instruction yourself when `composio_connect` is available. ## Rules - **Never fabricate action slugs.** Pull them from `composio_list_tools` or use the per-action tools already in your list. - **Respect rate limits** — Composio and upstream providers both throttle. Back off on errors rather than retrying tightly. -- **Scope errors are not disconnections.** If Gmail or another connected toolkit returns insufficient scope / missing permissions, report the missing permission plainly and direct the user to Settings → Connections → that toolkit. Never say the toolkit is disconnected for this case. +- **Scope errors are not disconnections.** If Gmail or another connected toolkit returns insufficient scope / missing permissions, report the missing permission plainly and direct the user to Connections → that toolkit. Never say the toolkit is disconnected for this case. - **Auth errors → connect inline first.** On a true auth / connection failure (not a scope error), call `composio_connect { toolkit }` to raise the inline connect card and await it. If it returns `connected: true`, retry the action once. Only if the user declines or the card can't be raised, reply exactly: `Connection error, try to authenticate`. Never paste OAuth URLs or name Composio to the user. - **Be precise** — every action expects a specific argument shape. Validate against the schema before calling. - **Report results** — state what action was taken and the outcome, including any cost reported by Composio. diff --git a/src/openhuman/agent_registry/agents/integrations_agent/prompt.rs b/src/openhuman/agent_registry/agents/integrations_agent/prompt.rs index c3e0ae5de3..603556e888 100644 --- a/src/openhuman/agent_registry/agents/integrations_agent/prompt.rs +++ b/src/openhuman/agent_registry/agents/integrations_agent/prompt.rs @@ -237,8 +237,9 @@ mod tests { assert!(body.contains("[composio:error:insufficient_scope]")); assert!(body.contains("Scope errors are not disconnections")); assert!(body.contains("Never say the toolkit is disconnected")); - assert!(body.contains("Settings")); - assert!(body.contains("Connections")); + assert!(body.contains("Connections → the toolkit")); + assert!(!body.contains("Settings → Connections")); + assert!(!body.contains("Settings → Automation & Channels")); } #[test] diff --git a/src/openhuman/agent_registry/agents/markets_agent/prompt.md b/src/openhuman/agent_registry/agents/markets_agent/prompt.md index 2007209f4d..e6d0edabc7 100644 --- a/src/openhuman/agent_registry/agents/markets_agent/prompt.md +++ b/src/openhuman/agent_registry/agents/markets_agent/prompt.md @@ -9,7 +9,7 @@ You are the **Markets Agent** — OpenHuman's specialist for prediction-market a - Proposing buy / sell on YES or NO legs with explicit side, count, and price. - Executing **only the exact order shape** you previously proposed to the user — never a parameter set you invented. - Cancelling open orders on user instruction. -- Pointing the user back to **Settings → Connections** when a venue's API key / secret isn't configured. +- Pointing the user back to **Connections** when a venue's API key / secret isn't configured. ## What you do NOT handle @@ -25,7 +25,7 @@ You are the **Markets Agent** — OpenHuman's specialist for prediction-market a 2. **Read before write.** Before proposing any `place_order`, confirm the market exists and is live with `polymarket` / `kalshi` browse actions (`list_markets` / `get_market` / `get_orderbook`). Cross-check side, count, and price against the orderbook so the order is plausibly fillable. 3. **Approval gate is non-negotiable.** Every write action (`place_order`, `cancel_order`) on Polymarket or Kalshi requires the caller to pass `approved=true`. Before sending that flag, call `ask_user_clarification` with a tight summary: venue, ticker, side (YES/NO), count, price in cents, est. cost. Only proceed on an explicit yes. 4. **Confirm before execute.** Surface the venue's approval-required error verbatim if it bounces — do not silently retry with `approved=true`. The user, not the agent, owns the green light. -5. **Stop cleanly on missing setup.** If a venue's credentials are missing (Polymarket CLOB L2 key/secret/passphrase, or Kalshi API key + RSA/HMAC secret), do not retry, do not guess. Say which thing is missing, point to **Settings → Connections**, and stop. +5. **Stop cleanly on missing setup.** If a venue's credentials are missing (Polymarket CLOB L2 key/secret/passphrase, or Kalshi API key + RSA/HMAC secret), do not retry, do not guess. Say which thing is missing, point to **Connections**, and stop. 6. **Price sanity.** Kalshi prices are integer cents in `1..=99`. Polymarket prices are normalised in `0.01..=0.99`. Refuse proposals outside band. If a user types "buy at $1.50", surface the bug and re-ask in the venue's native units. 7. **Stop cleanly on insufficient balance / liquidity.** If a quote / orderbook lookup shows the requested fill cannot land at the requested price, surface the reason verbatim, suggest the smallest viable adjustment (lower count, different price tier), and wait for the user. 8. **Never log secrets.** Do not echo API keys, RSA private keys, HMAC secrets, Polymarket L2 passphrases, or signed payload bodies in your replies. Quote the ticker, side, count, price, and any order id the venue returned, nothing more. @@ -63,7 +63,7 @@ After execution: On a missing prerequisite: -> no kalshi credentials set up yet — head to **Settings → Connections** to add your KalshiEX API key + secret, then ping me back. +> no kalshi credentials set up yet — head to **Connections** to add your KalshiEX API key + secret, then ping me back. On a failed order: diff --git a/src/openhuman/agent_registry/agents/orchestrator/prompt.md b/src/openhuman/agent_registry/agents/orchestrator/prompt.md index e95122d0ab..dadf5add58 100644 --- a/src/openhuman/agent_registry/agents/orchestrator/prompt.md +++ b/src/openhuman/agent_registry/agents/orchestrator/prompt.md @@ -21,7 +21,7 @@ Follow this sequence for every user message: - Words like "email/inbox/gmail", "calendar", "notion doc", "drive file", "slack/whatsapp/telegram message", "linear ticket", "send to X", "check X", etc. mean the user wants the **live** service. - Find the matching toolkit in the **Connected Integrations** section and call `delegate_to_integrations_agent` with that `toolkit`. - **Do this even if remembered context could plausibly answer.** The user wants the live source of truth, not a stale summary. - - If the relevant toolkit is **not** in **Connected Integrations**, call `composio_connect { toolkit: "" }` **directly** to raise an **inline connect card** so the user can authorize in one click, then continue the task once it returns `connected: true`. Do **not** refuse based on the Connected Integrations list (that is only what is *already* connected, not what is *connectable*), do **not** make "go to Settings → Connections" your first move, and do **not** silently fall back to memory retrieval (see "Connecting external services" below). + - If the relevant toolkit is **not** in **Connected Integrations**, call `composio_connect { toolkit: "" }` **directly** to raise an **inline connect card** so the user can authorize in one click, then continue the task once it returns `connected: true`. Do **not** refuse based on the Connected Integrations list (that is only what is *already* connected, not what is *connectable*), do **not** make "go to Connections" your first move, and do **not** silently fall back to memory retrieval (see "Connecting external services" below). - **Scope gate (required):** treat this as an external-service request ONLY if the ask actually operates on that service's own data or actions — its inbox/messages, files, calendar events, docs, tickets, etc. A service merely being *connected* is **not** a reason to touch it. General-knowledge answers, web/news lookups, headlines, date/time, and math must **not** spawn `delegate_to_integrations_agent` (or any email/inbox/calendar fetch) even when Gmail/Notion/etc. are connected — route those to a direct tool (Step 3) or the matching non-integration specialist (Step 4). When the request neither names nor clearly implies a specific service's own data or actions, do not reach into one — a clear implication ("check my inbox", "send an email") still counts as naming it and should be delivered; only a request that references no service at all (e.g. "today's date") stays off delegation. 3. **Can I solve this with direct tools?** - Yes: use direct tools (`memory_recall`, `read_workspace_state`, `composio_list_connections`, task tools, etc.). @@ -31,7 +31,7 @@ Follow this sequence for every user message: - **Listing conversation threads is direct work.** "List / show my recent threads (or conversations)" is a single `thread_list` call you make yourself — do **not** delegate it to `retrieve_memory` / a memory sub-agent. Memory retrieval walks the *memory tree* (ingested facts), which is the wrong tool for enumerating chat threads. Reserve `retrieve_memory` for questions about remembered content, not the thread index. - No: continue. 4. **Does this need other specialised execution?** - - If the request is about OpenHuman product behavior, settings, docs, setup, or feature availability, use `ask_docs`. + - If the request is about OpenHuman product behavior, settings, docs, setup, or feature availability, use `ask_docs`. This includes **"where do I click / which screen"** UI-navigation questions: route them to `ask_docs` rather than reciting a menu path from memory. Do **not** invent navigation paths — connecting channels and apps lives under **Connections** in the left sidebar (Channels / OAuth tabs), never a "Settings → Connections" or "Settings → Automation & Channels" submenu. If you are not certain of the exact current path, say so instead of guessing. - If the request is to remind, schedule, repeat, pause, remove, or inspect jobs, use `schedule_task`. - If the request is to make slides, build a deck, create a pitch, cite deck sources, or attach/verify deck images, use `make_presentation`. - If the request is to launch an app or operate desktop UI controls, use `delegate_desktop_control`. @@ -202,9 +202,9 @@ When the user asks to connect a service (Gmail, Notion, WhatsApp, Calendar, Driv - **Never** explain OAuth, Composio, or any backend mechanic by name. - **Connect inline, don't redirect.** Call `composio_connect { toolkit: "" }` **directly** to raise an **inline connect card** in the chat — this works for **any** service the user names (gmail, notion, whatsapp, youtube, …), not just ones already connected. The card *is* the confirmation: when the user asks to connect/authorize a service, or wants to use one that isn't connected, just call `composio_connect` — don't ask "want me to raise a card?" first. The user authorizes in one click and the task continues in the same turn. - **Don't confabulate "unsupported".** You do **not** have the list of connectable toolkits in your prompt — only the *connected* ones. Never tell the user a service "isn't available to connect" from memory. `composio_connect` checks the real backend allowlist: if it returns that the toolkit isn't an available integration, relay that message (and the list it provides). That is the only honest "I can't connect this". -- **On decline / fallback.** If `composio_connect` reports the user declined (`connected: false`) or that it couldn't raise the card, acknowledge it and offer `head to Settings → Connections → [Service]` as the alternative. +- **On decline / fallback.** If `composio_connect` reports the user declined (`connected: false`) or that it couldn't raise the card, acknowledge it and offer `head to Connections → [Service]` as the alternative. - If the user already said they connected it, call `composio_list_connections` to verify before continuing. -- Do **not** apply this rule to scope / permission failures such as `[composio:error:insufficient_scope]` or "missing required permissions". For those, say the connection exists but needs additional permissions in **Settings → Connections → [Service]**. +- Do **not** apply this rule to scope / permission failures such as `[composio:error:insufficient_scope]` or "missing required permissions". For those, say the connection exists but needs additional permissions in **Connections → [Service]**. ## Response Style diff --git a/src/openhuman/composio/direct_auth/messages.rs b/src/openhuman/composio/direct_auth/messages.rs index f0feed4119..188809cd56 100644 --- a/src/openhuman/composio/direct_auth/messages.rs +++ b/src/openhuman/composio/direct_auth/messages.rs @@ -16,7 +16,7 @@ /// arm. Reword the tail freely, but keep the anchor phrase or the drift-coupling /// test `demotes_composio_set_key_invalid_key_rejection` fails CI. pub(crate) const COMPOSIO_INVALID_API_KEY_USER_MESSAGE: &str = - "Invalid Composio API key. Re-enter a valid key in Settings > Connections > Composio."; + "Invalid Composio API key. Re-enter a valid key in Connections > Composio."; /// Lowercase substring the observability classifier's TAURI-RUST-K27 arm matches /// on to demote the set-key rejection. Shared with the runtime matcher so the diff --git a/src/openhuman/composio/direct_auth/mod.rs b/src/openhuman/composio/direct_auth/mod.rs index ffd9932586..2eeff8cd08 100644 --- a/src/openhuman/composio/direct_auth/mod.rs +++ b/src/openhuman/composio/direct_auth/mod.rs @@ -83,7 +83,7 @@ pub(crate) fn direct_auth_backoff_error(key_id: u64) -> Option { pub(crate) fn invalid_api_key_backoff_message(consecutive: u32) -> String { format!( - "Direct-mode Composio API key was rejected {consecutive} consecutive times with HTTP 401 Invalid API key; re-enter a valid key in Settings > Connections > Composio to resume polling." + "Direct-mode Composio API key was rejected {consecutive} consecutive times with HTTP 401 Invalid API key; re-enter a valid key in Connections > Composio to resume polling." ) } diff --git a/src/openhuman/composio/error_mapping.rs b/src/openhuman/composio/error_mapping.rs index 1f13a5d705..153e5b8cf7 100644 --- a/src/openhuman/composio/error_mapping.rs +++ b/src/openhuman/composio/error_mapping.rs @@ -156,7 +156,7 @@ fn format_insufficient_scope_message(tool: &str, detail: &str) -> String { let toolkit = derive_toolkit_slug(tool); format!( "`{tool}` was rejected because the connected {toolkit} account is missing required \ - permissions ({detail}). Reconnect the integration in Settings → Connections → \ + permissions ({detail}). Reconnect the integration in Connections → \ {toolkit} and grant the scopes requested during OAuth." ) } @@ -170,7 +170,7 @@ fn format_trigger_permission_message(tool: &str) -> String { let toolkit = derive_toolkit_slug(tool); format!( "Couldn't enable this trigger: the connected {toolkit} account doesn't have \ - permission to manage triggers. Reconnect {toolkit} in Settings → Connections → \ + permission to manage triggers. Reconnect {toolkit} in Connections → \ {toolkit} and grant the permissions requested during OAuth, then try again." ) } diff --git a/src/openhuman/composio/error_mapping_tests.rs b/src/openhuman/composio/error_mapping_tests.rs index 6a9fbd98b3..1b80710239 100644 --- a/src/openhuman/composio/error_mapping_tests.rs +++ b/src/openhuman/composio/error_mapping_tests.rs @@ -39,8 +39,7 @@ fn formats_gmail_insufficient_scope_as_missing_permissions_not_disconnected() { ); assert!(mapped.contains("[composio:error:insufficient_scope]")); assert!(mapped.contains("connected gmail account is missing required permissions")); - assert!(mapped.contains("Settings")); - assert!(mapped.contains("Connections")); + assert!(mapped.contains("Connections → gmail")); assert!(mapped.contains("gmail")); assert!(!mapped.contains("not connected")); assert!(!mapped.contains("Settings → Skills")); @@ -121,7 +120,7 @@ fn action_not_found_message_does_not_recommend_reauth() { "must not tell the user to reconnect a healthy connection: {mapped}" ); assert!( - !mapped.contains("Settings → Connections"), + !mapped.contains("Connections →"), "must not show the re-auth CTA: {mapped}" ); } @@ -197,11 +196,7 @@ fn formats_trigger_permission_as_actionable_reconnect_guidance() { "expected toolkit branding: {mapped}" ); assert!( - mapped.contains("Settings"), - "expected reconnect guidance: {mapped}" - ); - assert!( - mapped.contains("Connections"), + mapped.contains("Connections → gmail"), "expected reconnect guidance: {mapped}" ); assert!( diff --git a/src/openhuman/composio/identity.rs b/src/openhuman/composio/identity.rs index 89246d811b..46c706cfe9 100644 --- a/src/openhuman/composio/identity.rs +++ b/src/openhuman/composio/identity.rs @@ -65,7 +65,7 @@ pub async fn connection_identity(config: &Config, toolkit: &str) -> Option anyhow::Result< } /// Connect a Composio integration **inline in the chat** instead of -/// sending the user off to Settings → Connections. +/// sending the user off to Connections. /// /// Unlike [`ComposioAuthorizeTool`] (which hands the agent a raw /// `connectUrl` it is not allowed to paste), this tool raises an @@ -786,7 +786,7 @@ impl Tool for ComposioConnectTool { Raises an approval card with a Connect button — the user authorizes in one \ click without leaving the conversation, and this tool returns once the \ connection is active (or the user declines). ALWAYS prefer this over telling \ - the user to open Settings → Connections. Returns {toolkit, connected}." + the user to open Connections. Returns {toolkit, connected}." } fn parameters_schema(&self) -> Value { json!({ @@ -837,7 +837,7 @@ impl Tool for ComposioConnectTool { { return Ok(ToolResult::error(format!( "[policy-denied] composio_connect needs an interactive chat turn. \ - Ask the user to connect '{toolkit}' in Settings → Connections." + Ask the user to connect '{toolkit}' in Connections." ))); } @@ -898,7 +898,7 @@ impl Tool for ComposioConnectTool { // can't complete it. Point the user to Settings instead. return Ok(ToolResult::error(format!( "composio_connect: direct Composio mode is active — connect '{toolkit}' \ - in Settings → Connections (your personal Composio account)." + in Connections (your personal Composio account)." ))); } Err(e) => { @@ -958,7 +958,7 @@ impl Tool for ComposioConnectTool { "reason": format!( "A Connect card for {toolkit} was raised but wasn't completed in time \ (no one authorized it). Tell the user to click Connect on the card, or \ - connect {toolkit} in Settings → Connections, then ask again once it's \ + connect {toolkit} in Connections, then ask again once it's \ done. Do not call composio_connect again until they confirm." ), }))?)); diff --git a/src/openhuman/skills/preflight.rs b/src/openhuman/skills/preflight.rs index fff9fbca1e..716803ce5f 100644 --- a/src/openhuman/skills/preflight.rs +++ b/src/openhuman/skills/preflight.rs @@ -79,8 +79,8 @@ impl GithubGateError { let body = match self { GithubGateError::ComposioGithubMissing => { "GitHub preflight failed: no active Composio GitHub connection. \ - Connect via `composio_authorize github` (or Settings → \ - Integrations → GitHub) and re-run." + Connect via `composio_authorize github` (or Connections → \ + OAuth → GitHub) and re-run." .to_string() } GithubGateError::GitBinaryMissing(err) => format!( diff --git a/src/openhuman/tinyagents/middleware.rs b/src/openhuman/tinyagents/middleware.rs index 5cc4f3a7aa..e87b528e14 100644 --- a/src/openhuman/tinyagents/middleware.rs +++ b/src/openhuman/tinyagents/middleware.rs @@ -2351,7 +2351,7 @@ fn user_actionable_escalation(tool: &str, error: &str) -> Option { return None; } // Keep this narrow: some scope/permission failures legitimately tell the - // user to reconnect in Settings, but they are not missing connections. + // user to reconnect in Connections, but they are not missing connections. let missing_connection = lower.contains("[composio:error:composio_platform]") || lower.contains("not connected") || lower.contains("isn't connected") @@ -2364,7 +2364,7 @@ fn user_actionable_escalation(tool: &str, error: &str) -> Option { } Some(format!( "I can't continue without your input: the `{tool}` action needs a service that isn't \ - connected. {}\n\nConnect it (Settings \u{2192} Connections), then tell me to retry — or \ + connected. {}\n\nConnect it (Connections), then tell me to retry — or \ tell me how you'd like to proceed instead.", crate::openhuman::util::truncate_with_ellipsis(error, 400), )) @@ -4208,11 +4208,11 @@ mod tests { // A not-connected blocker → a user-directed ask with a concrete next step. let ask = user_actionable_escalation( "gmail_send", - "Gmail is not connected. Ask the user to connect 'gmail' in Settings → Connections.", + "Gmail is not connected. Ask the user to connect 'gmail' in Connections.", ) .expect("a missing-connection failure is user-actionable"); assert!(ask.contains("without your input")); - assert!(ask.contains("Settings")); + assert!(ask.contains("Connections")); assert!(ask.to_lowercase().contains("connect")); assert!(ask.contains("gmail_send")); // The original tool text is relayed so the user sees which service. @@ -4225,7 +4225,7 @@ mod tests { "gmail_send", "[composio:error:insufficient_scope] `gmail_send` was rejected because the connected \ gmail account is missing required permissions (insufficient authentication scopes). \ - Reconnect the integration in Settings → Connections → gmail and grant the scopes \ + Reconnect the integration in Connections → gmail and grant the scopes \ requested during OAuth." ) .is_none()); @@ -4233,7 +4233,7 @@ mod tests { "gmail_trigger", "[composio:error:trigger_permission] Couldn't enable this trigger: the connected \ gmail account doesn't have permission to manage triggers. Reconnect gmail in \ - Settings → Connections → gmail and grant the permissions requested during OAuth, \ + Connections → gmail and grant the permissions requested during OAuth, \ then try again." ) .is_none()); @@ -4250,7 +4250,7 @@ mod tests { for _ in 0..3 { let mut r = failing_result( "slack_post", - "Slack is not connected — connect it in Settings → Connections.", + "Slack is not connected — connect it in Connections.", ); mw.after_tool(&mut ctx(), &(), &mut r).await.unwrap(); } @@ -4260,7 +4260,7 @@ mod tests { .clone() .expect("halt records a summary"); assert!( - summary.contains("without your input") && summary.contains("Settings"), + summary.contains("without your input") && summary.contains("Connections"), "the halt should ask the user to connect the service: {summary}" ); assert!( From f9838abed5549a5afe043787ca4ab42405bd463e Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Thu, 16 Jul 2026 05:39:04 +0530 Subject: [PATCH 36/86] fix(flows): surface connected platform_user_id so the builder wires self-DMs (no #general fallback) (#4933) Co-authored-by: Steven Enamakel --- .../agents/workflow_builder/builder_prompt.rs | 18 +++++++ .../flows/agents/workflow_builder/prompt.md | 19 +++++-- src/openhuman/flows/builder_tools.rs | 41 +++++++++------ src/openhuman/flows/builder_tools_tests.rs | 23 ++++++++ src/openhuman/flows/ops.rs | 52 ++++++++++++++++++- src/openhuman/flows/ops_tests.rs | 51 ++++++++++++++++-- src/openhuman/flows/types.rs | 9 ++++ 7 files changed, 188 insertions(+), 25 deletions(-) diff --git a/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs b/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs index 80a6c1f008..84b060ec42 100644 --- a/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs +++ b/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs @@ -358,6 +358,24 @@ mod tests { "standing prompt must accurately teach that create_workflow exists and that \ created flows are always born disabled (issue #6)" ); + + // Positive: self-DM resolution — the prompt must teach the builder to + // wire "DM me" onto the connection's own `platform_user_id`, not a + // public channel (the #general/#team-product fallback bug). + assert!( + STANDING_PROMPT.contains("platform_user_id"), + "standing prompt must teach that list_flow_connections surfaces \ + platform_user_id for self-DM resolution" + ); + assert!( + STANDING_PROMPT.contains("DM me"), + "standing prompt must keep the \"DM me\" self-target guidance" + ); + assert!( + STANDING_PROMPT.contains("Never default a personal request to a public channel"), + "standing prompt must explicitly forbid falling back to a public \ + channel (e.g. #general/#team-product) for a personal \"DM me\" request" + ); } #[test] diff --git a/src/openhuman/flows/agents/workflow_builder/prompt.md b/src/openhuman/flows/agents/workflow_builder/prompt.md index 0ab8b7e5cf..8b8de71aca 100644 --- a/src/openhuman/flows/agents/workflow_builder/prompt.md +++ b/src/openhuman/flows/agents/workflow_builder/prompt.md @@ -107,7 +107,10 @@ rather than a general context recall), use `memory_hybrid_search` in its 2. **Ground it in reality before you build:** - `list_flow_connections` → the exact `connection_ref` values available (Composio accounts + named HTTP creds). Put these verbatim on nodes that - act on a connected account. Never invent a connection. + act on a connected account. Never invent a connection. Each Composio + entry also carries `platform_user_id` — the connected account's own + member id on that platform (e.g. Slack `U123ABC`). See "to me" / + "message me" / "DM me" below for how to use it. - `search_tool_catalog { query, toolkit? }` → real Composio action **slugs** from the FULL LIVE catalog for ANY named app — connected or not, curated or not (curated matches come back `featured: true` and are @@ -609,9 +612,17 @@ into exactly one bucket before you write the node: 2. **INFERABLE** — the request implies the value even though nothing upstream produces it: - "to me" / "message me" / "DM me" → the user's OWN Slack/Discord/etc. DM - target, never a public channel. **Never default a personal request to - `#general`** — that's a different destination than the user asked for, - not a safe guess. + target, never a public channel. + **Never default a personal request to a public channel** like + `#general` or `#team-product` — that's a different destination than + the user asked for, not a safe guess. Check `list_flow_connections`: + the matching Composio connection carries `platform_user_id` — the + user's own member id on that platform (e.g. Slack `U123ABC`). Pass + that id verbatim as the `channel` arg on `SLACK_SEND_MESSAGE` (Slack + opens/reuses a DM automatically when `channel` is a user id, not a + `#channel` name) — no need to ask. Only if `platform_user_id` is null + for that connection, ask the user for their member id in ONE concise + question rather than guessing a channel. - Exactly one connected account for the toolkit the step needs → that account (`list_flow_connections` / `composio_list_connections` tell you this; don't ask "which Gmail?" when there's only one). diff --git a/src/openhuman/flows/builder_tools.rs b/src/openhuman/flows/builder_tools.rs index 60495aca0b..d375a1939d 100644 --- a/src/openhuman/flows/builder_tools.rs +++ b/src/openhuman/flows/builder_tools.rs @@ -1515,9 +1515,13 @@ impl Tool for ListFlowConnectionsTool { fn description(&self) -> &str { "List the connection sources a flow node's `connection_ref` can attach to: \ Composio connected accounts and named HTTP credentials. Read-only; \ - returns ids + display labels + kind ONLY (never any secret). Use the \ - `connection_ref` values verbatim on tool_call / http_request nodes so the \ - generated flow carries valid connections." + returns ids + display labels + kind ONLY (never any secret). Each \ + Composio entry also carries `platform_user_id` — the connected \ + account's own member id (e.g. Slack `U123ABC`) — use it to wire a \ + self-targeted action like 'DM me' to that account instead of a \ + public channel. Use the `connection_ref` values verbatim on \ + tool_call / http_request nodes so the generated flow carries valid \ + connections." } fn parameters_schema(&self) -> Value { @@ -1536,19 +1540,7 @@ impl Tool for ListFlowConnectionsTool { tracing::debug!(target: "flows", "[flows] list_flow_connections: enumerating connection refs (read-only)"); match ops::flows_list_connections(&self.config).await { Ok(outcome) => { - let conns: Vec = outcome - .value - .iter() - .map(|c| { - json!({ - "connection_ref": c.connection_ref, - "kind": c.kind, - "display": c.display, - "toolkit": c.toolkit, - "scheme": c.scheme, - }) - }) - .collect(); + let conns: Vec = outcome.value.iter().map(flow_connection_to_json).collect(); Ok(ToolResult::success(serde_json::to_string_pretty( &json!({ "connections": conns }), )?)) @@ -1560,6 +1552,23 @@ impl Tool for ListFlowConnectionsTool { } } +/// Render one [`crate::openhuman::flows::types::FlowConnection`] as the +/// picker JSON shape the agent reads — ids/display/kind/toolkit/scheme plus +/// `platform_user_id` (the connected account's own member id, e.g. Slack +/// `U123ABC`, or `null` when no identity has synced yet). Never secret +/// material. A free function (rather than inline in `execute`) so the +/// mapping is unit-testable without a live Composio backend. +fn flow_connection_to_json(c: &crate::openhuman::flows::types::FlowConnection) -> Value { + json!({ + "connection_ref": c.connection_ref, + "kind": c.kind, + "display": c.display, + "toolkit": c.toolkit, + "scheme": c.scheme, + "platform_user_id": c.platform_user_id, + }) +} + // ───────────────────────────────────────────────────────────────────────────── // search_tool_catalog — read-only: real Composio tool slugs from the FULL // LIVE catalog (systemic tool-contract fix, Part 1) diff --git a/src/openhuman/flows/builder_tools_tests.rs b/src/openhuman/flows/builder_tools_tests.rs index da11ca8972..2c3ee04e7e 100644 --- a/src/openhuman/flows/builder_tools_tests.rs +++ b/src/openhuman/flows/builder_tools_tests.rs @@ -182,6 +182,29 @@ async fn list_flow_connections_is_read_only() { assert!(parsed["connections"].is_array()); } +#[test] +fn list_flow_connections_json_surfaces_platform_user_id() { + use crate::openhuman::flows::types::FlowConnection; + + let with_identity = FlowConnection { + connection_ref: "composio:slack:ca_slack1".to_string(), + kind: "composio".to_string(), + display: "Slack".to_string(), + toolkit: Some("slack".to_string()), + scheme: None, + platform_user_id: Some("U123ABC".to_string()), + }; + let json = flow_connection_to_json(&with_identity); + assert_eq!(json["platform_user_id"], "U123ABC"); + + let without_identity = FlowConnection { + platform_user_id: None, + ..with_identity + }; + let json = flow_connection_to_json(&without_identity); + assert!(json["platform_user_id"].is_null()); +} + // ── search_tool_catalog / get_tool_contract ───────────────────────────────── // The live-catalog cache is process-global (`LIVE_CATALOG_CACHE`) — every // test below seeds the exact toolkit(s)/contract(s) it needs via diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index 72de2d8eae..bfbec21cfc 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -1423,6 +1423,9 @@ pub(crate) async fn validate_connection_refs( Ok(outcome) => Some(build_flow_connections( outcome.value.connections, Vec::new(), + // Identity isn't needed for this existence/toolkit-mismatch + // check — only `connection_ref` and `toolkit` are read. + &[], )), Err(e) => { tracing::debug!( @@ -2358,7 +2361,12 @@ pub async fn flows_list_connections( } }; - let connections = build_flow_connections(composio_conns, http_creds); + // Connected-account identities (email/handle/platform user id), synced + // via each toolkit's whoami-style call (e.g. Slack `SLACK_TEST_AUTH`) on + // connection sync. Loaded once here so `build_flow_connections` can stay + // a pure, unit-testable matcher. + let identities = crate::openhuman::composio::providers::profile::load_connected_identities(); + let connections = build_flow_connections(composio_conns, http_creds, &identities); tracing::debug!( total = connections.len(), "[flows] flows_list_connections: aggregated picker sources" @@ -2374,11 +2382,36 @@ pub async fn flows_list_connections( /// secret-free [`FlowConnection`] picker list. Only ACTIVE Composio connections /// are surfaced — a pending/expired OAuth account cannot execute a tool, so it /// would be a dead pick. Pure (no I/O) so the aggregation shape is -/// unit-testable without a live backend. +/// unit-testable without a live backend; `identities` is loaded once by the +/// caller and matched in here. +/// +/// Each Composio connection is also matched against `identities` (keyed by +/// `(toolkit, connection_id)`, both normalized the same way +/// `enrich_connections_with_identity` in `composio::ops::connections` does) +/// to attach `platform_user_id` — the connected account's own member id +/// (e.g. Slack `U123ABC`). This is what lets the workflow builder wire a +/// self-targeted action ("DM me") to the user's own account instead of +/// guessing a public channel. fn build_flow_connections( composio: Vec, http: Vec, + identities: &[crate::openhuman::composio::providers::profile::ConnectedIdentity], ) -> Vec { + use crate::openhuman::composio::providers::profile::normalize_connection_identifier; + + let identity_lookup: std::collections::HashMap<(String, String), &_> = identities + .iter() + .map(|id| { + ( + ( + normalize_connection_identifier(&id.source), + normalize_connection_identifier(&id.identifier), + ), + id, + ) + }) + .collect(); + let mut out = Vec::with_capacity(composio.len() + http.len()); for conn in composio { if !conn.is_active() { @@ -2391,6 +2424,19 @@ fn build_flow_connections( continue; } let toolkit = conn.normalized_toolkit(); + let lookup_key = ( + normalize_connection_identifier(&toolkit), + normalize_connection_identifier(&conn.id), + ); + let platform_user_id = identity_lookup + .get(&lookup_key) + .and_then(|identity| identity.user_id.clone()); + tracing::debug!( + toolkit = %toolkit, + connection_id = %conn.id, + has_platform_user_id = platform_user_id.is_some(), + "[flows] flows_list_connections: resolved platform_user_id for composio connection" + ); out.push(FlowConnection { // Exactly the shape `tinyflows::caps::composio_connection_id` parses. connection_ref: format!("composio:{}:{}", toolkit, conn.id), @@ -2398,6 +2444,7 @@ fn build_flow_connections( display: composio_connection_display(&toolkit, &conn), toolkit: Some(toolkit), scheme: None, + platform_user_id, }); } for cred in http { @@ -2408,6 +2455,7 @@ fn build_flow_connections( display: http_credential_display(&cred), toolkit: None, scheme: Some(cred.scheme), + platform_user_id: None, }); } out diff --git a/src/openhuman/flows/ops_tests.rs b/src/openhuman/flows/ops_tests.rs index 319445e1da..5744046515 100644 --- a/src/openhuman/flows/ops_tests.rs +++ b/src/openhuman/flows/ops_tests.rs @@ -1963,7 +1963,7 @@ fn build_flow_connections_emits_parseable_refs_for_both_kinds() { )]; let http = vec![http_summary("stripe", "bearer")]; - let out = build_flow_connections(composio, http); + let out = build_flow_connections(composio, http, &[]); assert_eq!(out.len(), 2); let gmail = &out[0]; @@ -1978,6 +1978,7 @@ fn build_flow_connections_emits_parseable_refs_for_both_kinds() { assert_eq!(gmail.toolkit.as_deref(), Some("gmail")); assert_eq!(gmail.display, "Gmail · user@example.com"); assert!(gmail.scheme.is_none()); + assert!(gmail.platform_user_id.is_none()); let stripe = &out[1]; assert_eq!(stripe.kind, "http"); @@ -1989,6 +1990,7 @@ fn build_flow_connections_emits_parseable_refs_for_both_kinds() { assert_eq!(stripe.scheme.as_deref(), Some("bearer")); assert_eq!(stripe.display, "stripe (bearer)"); assert!(stripe.toolkit.is_none()); + assert!(stripe.platform_user_id.is_none()); } #[test] @@ -1997,7 +1999,7 @@ fn build_flow_connections_skips_non_active_composio_accounts() { composio_conn("ca_ok", "notion", "ACTIVE", None), composio_conn("ca_pending", "slack", "PENDING", None), ]; - let out = build_flow_connections(composio, Vec::new()); + let out = build_flow_connections(composio, Vec::new(), &[]); assert_eq!(out.len(), 1, "only the ACTIVE connection is surfaced"); assert_eq!(out[0].connection_ref, "composio:notion:ca_ok"); // No cached identity → title-cased toolkit alone. @@ -2009,10 +2011,11 @@ fn build_flow_connections_never_carries_secret_fields() { let out = build_flow_connections( vec![composio_conn("ca_abc", "gmail", "ACTIVE", Some("u@x.io"))], vec![http_summary("stripe", "header")], + &[], ); let json = serde_json::to_string(&out).unwrap(); // The serialized picker payload must expose only ref/kind/display/toolkit/ - // scheme — no secret-bearing key names at all. + // scheme/platform_user_id — no secret-bearing key names at all. for banned in [ "secret", "token", "password", "\"key\"", "apiKey", "api_key", ] { @@ -2025,6 +2028,47 @@ fn build_flow_connections_never_carries_secret_fields() { } } +#[test] +fn build_flow_connections_attaches_platform_user_id_from_a_seeded_identity() { + use crate::openhuman::composio::providers::profile::ConnectedIdentity; + + let composio = vec![composio_conn("ca_slack1", "slack", "ACTIVE", None)]; + let identities = vec![ConnectedIdentity { + source: "slack".to_string(), + identifier: "ca_slack1".to_string(), + user_id: Some("U123ABC".to_string()), + ..Default::default() + }]; + + let out = build_flow_connections(composio, Vec::new(), &identities); + assert_eq!(out.len(), 1); + assert_eq!(out[0].platform_user_id.as_deref(), Some("U123ABC")); +} + +#[test] +fn build_flow_connections_platform_user_id_is_none_without_a_matching_identity() { + use crate::openhuman::composio::providers::profile::ConnectedIdentity; + + // No identities at all. + let composio = vec![composio_conn("ca_slack1", "slack", "ACTIVE", None)]; + let out = build_flow_connections(composio, Vec::new(), &[]); + assert_eq!(out.len(), 1); + assert!(out[0].platform_user_id.is_none()); + + // An identity exists, but for a different toolkit/connection — must not + // cross-wire onto this connection. + let composio = vec![composio_conn("ca_slack1", "slack", "ACTIVE", None)]; + let identities = vec![ConnectedIdentity { + source: "gmail".to_string(), + identifier: "ca_slack1".to_string(), + user_id: Some("U123ABC".to_string()), + ..Default::default() + }]; + let out = build_flow_connections(composio, Vec::new(), &identities); + assert_eq!(out.len(), 1); + assert!(out[0].platform_user_id.is_none()); +} + #[test] fn title_case_toolkit_handles_underscores_and_dashes() { assert_eq!(title_case_toolkit("gmail"), "Gmail"); @@ -2375,6 +2419,7 @@ fn ws3_flow_conn(toolkit: &str, id: &str) -> FlowConnection { display: toolkit.to_string(), toolkit: Some(toolkit.to_string()), scheme: None, + platform_user_id: None, } } diff --git a/src/openhuman/flows/types.rs b/src/openhuman/flows/types.rs index 30e872ca4b..eaa9e75b5d 100644 --- a/src/openhuman/flows/types.rs +++ b/src/openhuman/flows/types.rs @@ -301,6 +301,15 @@ pub struct FlowConnection { /// `"bearer"` | `"basic"` | `"header"`. Not a secret. #[serde(default, skip_serializing_if = "Option::is_none")] pub scheme: Option, + /// The connected account's own platform user id (`kind = "composio"` + /// only), e.g. Slack's `"U123ABC"` — resolved from the provider profile + /// synced via `SLACK_TEST_AUTH`/auth.test on connection sync (see + /// `memory_sync::composio::providers::profile::load_connected_identities`). + /// Non-secret identity metadata: lets the workflow builder wire a + /// self-targeted action (e.g. "DM me") to the user's own account instead + /// of guessing a public channel. `None` when no identity has synced yet. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platform_user_id: Option, } /// A persisted record of one `flows_run` / `flows_resume` invocation, for the From 27ff4d5ac097ee1655a6fcdc3ab411a8e1d73015 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Thu, 16 Jul 2026 06:56:22 +0300 Subject: [PATCH 37/86] fix(i18n): repair grammar across locale copy (#4939) --- app/src/lib/i18n/ar.ts | 26 +++++++++--------- app/src/lib/i18n/bn.ts | 26 +++++++++--------- app/src/lib/i18n/de.ts | 22 +++++++-------- app/src/lib/i18n/en.ts | 56 +++++++++++++++++++-------------------- app/src/lib/i18n/es.ts | 14 +++++----- app/src/lib/i18n/fr.ts | 28 ++++++++++---------- app/src/lib/i18n/hi.ts | 24 ++++++++--------- app/src/lib/i18n/id.ts | 24 ++++++++--------- app/src/lib/i18n/it.ts | 26 +++++++++--------- app/src/lib/i18n/ko.ts | 16 +++++------ app/src/lib/i18n/pl.ts | 32 +++++++++++----------- app/src/lib/i18n/pt.ts | 30 ++++++++++----------- app/src/lib/i18n/ru.ts | 30 ++++++++++----------- app/src/lib/i18n/zh-CN.ts | 8 +++--- 14 files changed, 181 insertions(+), 181 deletions(-) diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 176cf44146..5673bc812c 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -74,7 +74,7 @@ const messages: TranslationMap = { 'connections.welcome.eyebrow': 'الاتصالات', 'connections.welcome.title': 'كل ما تستخدمه، في مكان واحد', 'connections.welcome.body': - 'اربط تطبيقات المراسلة والبريد الإلكتروني والتقويم والأدوات كي يتمكن وكيلك من قراءة السياق واتخاذ الإجراءات عبرها جميعًا: دون النسخ واللصق بين عشرات التبويبات. تبقى أنت المتحكم فيما يمكنه لمسه.', + 'اربط تطبيقات المراسلة والبريد الإلكتروني والتقويم والأدوات كي يتمكن وكيلك من قراءة السياق واتخاذ الإجراءات عبرها جميعًا، دون النسخ واللصق بين عشرات التبويبات. تبقى أنت المتحكم فيما يمكنه لمسه.', 'connections.welcome.ctaChannel': 'اربط قناة', 'connections.welcome.ctaApps': 'اربط التطبيقات', 'connections.welcome.ctaSkills': 'تصفّح المهارات', @@ -91,7 +91,7 @@ const messages: TranslationMap = { 'notifications.welcome.eyebrow': 'الإشعارات', 'notifications.welcome.title': 'فقط ما يحتاج إليك فعلاً', 'notifications.welcome.body': - 'خلاصة هادئة ومُقيّمة لما فعله وكلاؤك وما يحتاج إلى قرار: كي تظهر الأمور المهمة ويبقى الضجيج بعيدًا عن طريقك.', + 'خلاصة هادئة ومُقيّمة لما فعله وكلاؤك وما يحتاج إلى قرار، كي تظهر الأمور المهمة ويبقى الضجيج بعيدًا عن طريقك.', 'notifications.welcome.ctaView': 'عرض التنبيهات', 'notifications.welcome.featsLabel': 'ما الذي ستراه', 'notifications.welcome.feat1Title': 'ما يحتاج إليك', @@ -106,7 +106,7 @@ const messages: TranslationMap = { 'rewards.welcome.eyebrow': 'المكافآت', 'rewards.welcome.title': 'احصل على مكافأة لحضورك', 'rewards.welcome.body': - 'اكسب نقاطًا أثناء استخدامك OpenHuman ودعوتك للآخرين، وحافظ على سلسلة حضورك، واستبدل ما كسبته: كل ذلك متتبَّع في مكان واحد.', + 'اكسب نقاطًا أثناء استخدامك OpenHuman ودعوتك للآخرين، وحافظ على سلسلة حضورك، واستبدل ما كسبته، كل ذلك متتبَّع في مكان واحد.', 'rewards.welcome.ctaView': 'عرض المكافآت', 'rewards.welcome.featsLabel': 'كيف يعمل', 'rewards.welcome.feat1Title': 'اكسب النقاط', @@ -120,7 +120,7 @@ const messages: TranslationMap = { 'flows.welcome.eyebrow': 'سير العمل', 'flows.welcome.title': 'ضع الأعمال الروتينية على الطيّار الآلي', 'flows.welcome.body': - 'صِف شيئًا تفعله مرارًا وتكرارًا: الفرز والمتابعات والخلاصات: ويحوّله وكيلك إلى سير عمل يمكنه تشغيله من البداية إلى النهاية، وفق جدول أو عند الطلب.', + 'صِف شيئًا تفعله مرارًا وتكرارًا (الفرز والمتابعات والخلاصات) ويحوّله وكيلك إلى سير عمل يمكنه تشغيله من البداية إلى النهاية، وفق جدول أو عند الطلب.', 'flows.welcome.ctaNew': 'سير عمل جديد', 'flows.welcome.ctaBrowse': 'تصفّح سير العمل', 'flows.welcome.featsLabel': 'ما الذي يمكنك أتمتته', @@ -318,7 +318,7 @@ const messages: TranslationMap = { 'orchPage.medulla.title': 'Medulla', 'orchPage.medulla.tagline': 'نموذج التنسيق من OpenHuman', 'orchPage.medulla.body': - 'Medulla هو نموذج اللغة الكبير الذي طوّرته OpenHuman خصيصًا، مصمَّم لتنسيق آلاف الوكلاء في آنٍ واحد: بنافذة سياق تتسع لـ 10 ملايين توكن وتنسيق منخفض التكلفة بشكل جذري.', + 'Medulla هو نموذج اللغة الكبير الذي طوّرته OpenHuman خصيصًا، مصمَّم لتنسيق آلاف الوكلاء في آنٍ واحد، بنافذة سياق تتسع لـ 10 ملايين توكن وتنسيق منخفض التكلفة بشكل جذري.', 'orchPage.medulla.featAgents': 'آلاف الوكلاء', 'orchPage.medulla.featContext': 'سياق بسعة 10M توكن', 'orchPage.medulla.featCost': 'تنسيق منخفض التكلفة', @@ -1091,7 +1091,7 @@ const messages: TranslationMap = { 'memory.tab.associations': 'Associations', 'entityAssociations.title': 'ارتباطات الكيانات', 'entityAssociations.intro': - 'الكيانات التي تتشارك العديد من الروابط نفسها تكون مرتبطة: حتى لو لم تربطها حقيقة واحدة مباشرة. تشابه Jaccard يكشف هذه الارتباطات المخفية.', + 'الكيانات التي تتشارك العديد من الروابط نفسها تكون مرتبطة، حتى لو لم تربطها حقيقة واحدة مباشرة. تشابه Jaccard يكشف هذه الارتباطات المخفية.', 'entityAssociations.loading': 'جارٍ حساب الارتباطات…', 'entityAssociations.errorPrefix': 'تعذّر تحميل الرسم البياني:', 'entityAssociations.retry': 'إعادة المحاولة', @@ -1606,7 +1606,7 @@ const messages: TranslationMap = { 'settings.search.placeholderQuerit': 'مفتاح API الخاص بـ Querit', 'settings.search.allowedSitesLabel': 'المواقع الشبكية المسموح بها', 'settings.search.allowedSitesHint': - 'المضيفون الذين يُسمح للمساعد بفتحهم وقراءتهم: عبر جلب الويب وأداة المتصفح: مضيف واحد في كل سطر، مثل reuters.com. يشمل المضيف نطاقاته الفرعية أيضًا. البحث على الويب نفسه لا يتقيّد بهذه القائمة.', + 'المضيفون الذين يُسمح للمساعد بفتحهم وقراءتهم (عبر جلب الويب وأداة المتصفح) مضيف واحد في كل سطر، مثل reuters.com. يشمل المضيف نطاقاته الفرعية أيضًا. البحث على الويب نفسه لا يتقيّد بهذه القائمة.', 'settings.search.allowedSitesAllOn': 'يمكن للمساعد فتح أي موقع علني العناوين المحلية والخاصة تبقى مغلقة', 'settings.search.allowedSitesPlaceholder': 'reuters.com\napnews.com\ngithub.com', @@ -2774,7 +2774,7 @@ const messages: TranslationMap = { 'workspace.vaultNotRegisteredHelp': 'يفتح Obsidian فقط المجلدات التي أضفتها كخزنة. في Obsidian، اختر «فتح المجلد كخزنة» واختر المجلد أدناه: تحتاج إلى القيام بذلك مرة واحدة فقط. ثم انقر «عرض الخزنة» مجدداً.', 'workspace.obsidianNotFoundHelp': - 'لم نتمكن من العثور على Obsidian على هذا الجهاز. ثبّته، أو: إذا كان مثبتاً في مكان غير قياسي: حدد مجلد إعداداته ضمن خيارات متقدمة.', + 'لم نتمكن من العثور على Obsidian على هذا الجهاز. ثبّته، أو (إذا كان مثبتاً في مكان غير قياسي) حدد مجلد إعداداته ضمن خيارات متقدمة.', 'workspace.openAnyway': 'فتح في Obsidian على أي حال', 'workspace.installObsidian': 'تثبيت Obsidian', 'workspace.obsidianAdvanced': 'هل Obsidian مثبّت في مكان آخر؟', @@ -5337,7 +5337,7 @@ const messages: TranslationMap = { 'الدخول الكامل يُديرُ الأوامرَ بوصولِ حسابِكَ الكاملِ وهو لَيسَ مُربّطَ رملَ. فقط قم بتمكينها عندما تضغطين على العميل بهذه الآلة ولا تزال الأدلة الإبداعية والنظامية مجمدة، وما زالت أعمال التدمير والشبكات والتركيب تتطلب الموافقة.', 'settings.agentAccess.confine.label': 'مقصورة على مكان العمل', 'settings.agentAccess.confine.desc': - 'قيّد الوكيل بدليل مساحة العمل (بالإضافة إلى أي مجلدات ممنوحة)، بصرف النظر عن وضع الوصول المحدد. عند إيقاف التشغيل، يمكنه الوصول إلى أي مكان يمكن لمستخدمك الوصول إليه: باستثناء أدلة بيانات الاعتماد والنظام المحظورة دائمًا.', + 'قيّد الوكيل بدليل مساحة العمل (بالإضافة إلى أي مجلدات ممنوحة)، بصرف النظر عن وضع الوصول المحدد. عند إيقاف التشغيل، يمكنه الوصول إلى أي مكان يمكن لمستخدمك الوصول إليه، باستثناء أدلة بيانات الاعتماد والنظام المحظورة دائمًا.', 'settings.agentAccess.requireTaskPlanApproval.label': 'الموافقة على خطة العمل المطلوبة', 'settings.agentAccess.requireTaskPlanApproval.desc': 'وقف أمام عميل معين يقوم بتنفيذ موجز عمل مشرف على عميل', @@ -5406,7 +5406,7 @@ const messages: TranslationMap = { 'اختر مقدار الحرية التي يتمتع بها المساعد عند اتخاذ الإجراءات على جهاز الكمبيوتر الخاص بك.', 'settings.permissions.preset.readonly.title': 'انظر ولا تلمس', 'settings.permissions.preset.readonly.desc': - 'يمكن للمساعد قراءة الملفات والاستكشاف: لكنه لن يكتب أو يعدّل أو يُشغّل أي شيء يغير الحالة.', + 'يمكن للمساعد قراءة الملفات والاستكشاف، لكنه لن يكتب أو يعدّل أو يُشغّل أي شيء يغير الحالة.', 'settings.permissions.preset.supervised.title': 'اسألني أولاً', 'settings.permissions.preset.supervised.desc': 'يمكنه إنشاء ملفات جديدة بحرية، لكنه يطلب موافقتك دائمًا قبل التعديل أو تشغيل الأوامر أو الوصول إلى الشبكة.', @@ -5552,7 +5552,7 @@ const messages: TranslationMap = { 'settings.appearance.fontSizeXLarge': 'كبير جدًا', 'settings.appearance.fontSizeXLargeDesc': 'أكبر حجم للنص لأقصى قدر من الوضوح.', 'settings.appearance.fontSizeHelperText': - 'يضبط حجم النص في التطبيق بأكمله: المحادثة والإعدادات واللوحات: بشكل مستقل عن إعداد خط نظامك.', + 'يضبط حجم النص في التطبيق بأكمله (المحادثة والإعدادات واللوحات) بشكل مستقل عن إعداد خط نظامك.', 'settings.appearance.fontSizeCustomLabel': 'حجم مخصص', 'settings.appearance.fontSizeCustomAria': 'حجم الخط المخصص بالبكسل', 'settings.appearance.fontSizeCustomSliderAria': 'شريط تمرير حجم الخط المخصص، بالبكسل', @@ -5688,7 +5688,7 @@ const messages: TranslationMap = { 'افتراضي معقول: استمرارية جيدة دون استهلاك رموز إضافية في كل تشغيل.', 'settings.memoryWindow.balanced.label': 'متوازن', 'settings.memoryWindow.description': - 'مقدار السياق المحفوظ الذي يحقنه OpenHuman في كل تشغيل وكيل جديد. النوافذ الأكبر تشعر بإدراك أكثر للمحادثات السابقة لكنها تستخدم رموزًا أكثر: وتكلّف أكثر: في كل تشغيل.', + 'مقدار السياق المحفوظ الذي يحقنه OpenHuman في كل تشغيل وكيل جديد. النوافذ الأكبر تمنح الوكيل وعيًا أكبر بالمحادثات السابقة، لكنها تستخدم رموزًا أكثر وتكلّف أكثر في كل تشغيل.', 'settings.memoryWindow.extended.badge': 'سياق أكثر', 'settings.memoryWindow.extended.hint': 'حقن ذاكرة طويلة المدى أكثر في كل تشغيل. تكلفة رموز أعلى لكل دور.', @@ -6680,7 +6680,7 @@ const messages: TranslationMap = { 'graphCohesion.emptyHint': 'كلما سجّل المساعد حقائق مترابطة عنك، ستظهر هنا بنية تجميعها.', 'graphCohesion.errorPrefix': 'تعذّر تحميل الرسم البياني:', 'graphCohesion.intro': - 'مدى تماسك الجوار حول كل كيان. الوسطاء: كيانات لا يرتبط جيرانها ببعضهم: هم النقاط الوحيدة التي تربط بين عناقيد كانت ستظل منفصلة، وهو ما لا يمكن لفرز التكرار أو PageRank الكشف عنه.', + 'مدى تماسك الجوار حول كل كيان. الوسطاء (كيانات لا يرتبط جيرانها ببعضهم) هم النقاط الوحيدة التي تربط بين عناقيد كانت ستظل منفصلة، وهو ما لا يمكن لفرز التكرار أو PageRank الكشف عنه.', 'graphCohesion.loading': 'يجري حساب التماسك…', 'graphCohesion.metricConnections': 'الاتصالات', 'graphCohesion.metricEntities': 'الكيانات', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index a3bd598043..a05e1d5c99 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -78,7 +78,7 @@ const messages: TranslationMap = { 'connections.welcome.eyebrow': 'সংযোগ', 'connections.welcome.title': 'আপনি যা ব্যবহার করেন সবকিছু এক জায়গায়', 'connections.welcome.body': - 'আপনার মেসেজিং অ্যাপ, ইমেল, ক্যালেন্ডার এবং টুল সংযুক্ত করুন যাতে আপনার এজেন্ট প্রেক্ষাপট পড়তে এবং সেগুলো জুড়ে পদক্ষেপ নিতে পারে: এক ডজন ট্যাবের মধ্যে কপি-পেস্ট না করে। এটি কী স্পর্শ করতে পারে তার নিয়ন্ত্রণ আপনার হাতেই থাকে।', + 'আপনার মেসেজিং অ্যাপ, ইমেল, ক্যালেন্ডার এবং টুল সংযুক্ত করুন যাতে আপনার এজেন্ট প্রেক্ষাপট পড়তে এবং সেগুলো জুড়ে পদক্ষেপ নিতে পারে, এক ডজন ট্যাবের মধ্যে কপি-পেস্ট না করে। এটি কী স্পর্শ করতে পারে তার নিয়ন্ত্রণ আপনার হাতেই থাকে।', 'connections.welcome.ctaChannel': 'একটি চ্যানেল সংযুক্ত করুন', 'connections.welcome.ctaApps': 'অ্যাপ সংযুক্ত করুন', 'connections.welcome.ctaSkills': 'দক্ষতা ব্রাউজ করুন', @@ -98,7 +98,7 @@ const messages: TranslationMap = { 'notifications.welcome.eyebrow': 'বিজ্ঞপ্তি', 'notifications.welcome.title': 'শুধু যা সত্যিই আপনাকে প্রয়োজন', 'notifications.welcome.body': - 'আপনার এজেন্টরা কী করেছে এবং কী একটি সিদ্ধান্তের প্রয়োজন তার একটি শান্ত, স্কোরকৃত সারসংক্ষেপ: যাতে গুরুত্বপূর্ণ বিষয়গুলো সামনে আসে এবং কোলাহল আপনার পথ থেকে দূরে থাকে।', + 'আপনার এজেন্টরা কী করেছে এবং কী একটি সিদ্ধান্তের প্রয়োজন তার একটি শান্ত, স্কোরকৃত সারসংক্ষেপ, যাতে গুরুত্বপূর্ণ বিষয়গুলো সামনে আসে এবং কোলাহল আপনার পথ থেকে দূরে থাকে।', 'notifications.welcome.ctaView': 'সতর্কতা দেখুন', 'notifications.welcome.featsLabel': 'আপনি যা দেখবেন', 'notifications.welcome.feat1Title': 'যা আপনাকে প্রয়োজন', @@ -113,7 +113,7 @@ const messages: TranslationMap = { 'rewards.welcome.eyebrow': 'পুরস্কার', 'rewards.welcome.title': 'হাজির থাকার জন্য পুরস্কৃত হন', 'rewards.welcome.body': - 'আপনি OpenHuman ব্যবহার করার সাথে সাথে এবং অন্যদের আমন্ত্রণ জানানোর সাথে পয়েন্ট অর্জন করুন, আপনার স্ট্রিক বজায় রাখুন এবং যা অর্জন করেছেন তা রিডিম করুন: সবই এক জায়গায় ট্র্যাক করা।', + 'আপনি OpenHuman ব্যবহার এবং অন্যদের আমন্ত্রণ জানানোর সময় পয়েন্ট অর্জন করুন, আপনার স্ট্রিক বজায় রাখুন এবং যা অর্জন করেছেন তা রিডিম করুন। সবকিছু এক জায়গায় ট্র্যাক করুন।', 'rewards.welcome.ctaView': 'পুরস্কার দেখুন', 'rewards.welcome.featsLabel': 'এটি কীভাবে কাজ করে', 'rewards.welcome.feat1Title': 'পয়েন্ট অর্জন করুন', @@ -127,7 +127,7 @@ const messages: TranslationMap = { 'flows.welcome.eyebrow': 'ওয়ার্কফ্লো', 'flows.welcome.title': 'ব্যস্ততার কাজকে অটোপাইলটে দিন', 'flows.welcome.body': - 'আপনি বারবার যা করেন তা বর্ণনা করুন: ট্রায়াজ, ফলো-আপ, সারসংক্ষেপ: এবং আপনার এজেন্ট এটিকে একটি ওয়ার্কফ্লোতে পরিণত করে যা সে শুরু থেকে শেষ পর্যন্ত, একটি সময়সূচি অনুযায়ী বা চাহিদামতো চালাতে পারে।', + 'আপনি বারবার যা করেন তা বর্ণনা করুন (ট্রায়াজ, ফলো-আপ, সারসংক্ষেপ) এবং আপনার এজেন্ট এটিকে একটি ওয়ার্কফ্লোতে পরিণত করে যা সে শুরু থেকে শেষ পর্যন্ত, একটি সময়সূচি অনুযায়ী বা চাহিদামতো চালাতে পারে।', 'flows.welcome.ctaNew': 'নতুন ওয়ার্কফ্লো', 'flows.welcome.ctaBrowse': 'ওয়ার্কফ্লো ব্রাউজ করুন', 'flows.welcome.featsLabel': 'আপনি যা স্বয়ংক্রিয় করতে পারেন', @@ -331,7 +331,7 @@ const messages: TranslationMap = { 'orchPage.medulla.title': 'Medulla', 'orchPage.medulla.tagline': 'OpenHuman-এর অর্কেস্ট্রেশন মডেল', 'orchPage.medulla.body': - 'Medulla হলো OpenHuman-এর নিজস্বভাবে তৈরি LLM, যা একসঙ্গে হাজার হাজার এজেন্ট অর্কেস্ট্রেট করার জন্য প্রকৌশলিত: 10 মিলিয়ন-টোকেন কনটেক্সট উইন্ডো এবং অত্যন্ত কম খরচের অর্কেস্ট্রেশন সহ।', + 'Medulla হলো OpenHuman-এর নিজস্বভাবে তৈরি LLM, যা একসঙ্গে হাজার হাজার এজেন্ট অর্কেস্ট্রেট করার জন্য প্রকৌশলিত, 10 মিলিয়ন-টোকেন কনটেক্সট উইন্ডো এবং অত্যন্ত কম খরচের অর্কেস্ট্রেশন সহ।', 'orchPage.medulla.featAgents': 'হাজার হাজার এজেন্ট', 'orchPage.medulla.featContext': '10M-টোকেন কনটেক্সট', 'orchPage.medulla.featCost': 'কম খরচের অর্কেস্ট্রেশন', @@ -1119,7 +1119,7 @@ const messages: TranslationMap = { 'memory.tab.associations': 'Associations', 'entityAssociations.title': 'সত্তা সম্পর্ক', 'entityAssociations.intro': - 'যে সত্তাগুলো অনেক একই সংযোগ ভাগ করে, তারা সম্পর্কিত: এমনকি কোনো একক তথ্য তাদের সরাসরি সংযুক্ত না করলেও। Jaccard সাদৃশ্য এই লুকানো সম্পর্কগুলো প্রকাশ করে।', + 'যে সত্তাগুলো অনেক একই সংযোগ ভাগ করে, তারা সম্পর্কিত, এমনকি কোনো একক তথ্য তাদের সরাসরি সংযুক্ত না করলেও। Jaccard সাদৃশ্য এই লুকানো সম্পর্কগুলো প্রকাশ করে।', 'entityAssociations.loading': 'সম্পর্ক স্কোর করা হচ্ছে…', 'entityAssociations.errorPrefix': 'গ্রাফ লোড করা যায়নি:', 'entityAssociations.retry': 'Retry', @@ -1642,7 +1642,7 @@ const messages: TranslationMap = { 'settings.search.placeholderQuerit': 'কিউ- টি xxqx কি', 'settings.search.allowedSitesLabel': 'ওয়েবসাইটের অনুমতি দেওয়া হয়েছে', 'settings.search.allowedSitesHint': - 'যেসব হোস্ট অ্যাসিস্ট্যান্ট খুলতে ও পড়তে পারবে: ওয়েব ফেচ এবং ব্রাউজার টুলের মাধ্যমে: প্রতি লাইনে একটি করে, যেমন reuters.com। একটি হোস্ট তার সাবডোমেইনগুলোও অন্তর্ভুক্ত করে। ওয়েব সার্চ নিজে এই তালিকা দ্বারা সীমাবদ্ধ নয়।', + 'যেসব হোস্ট অ্যাসিস্ট্যান্ট খুলতে ও পড়তে পারবে (ওয়েব ফেচ এবং ব্রাউজার টুলের মাধ্যমে) প্রতি লাইনে একটি করে, যেমন reuters.com। একটি হোস্ট তার সাবডোমেইনগুলোও অন্তর্ভুক্ত করে। ওয়েব সার্চ নিজে এই তালিকা দ্বারা সীমাবদ্ধ নয়।', 'settings.search.allowedSitesAllOn': 'সহকারীটি যেকোন পাবলিক ওয়েবসাইট খুলতে পারে। স্থানীয় এবং ব্যক্তিগত ঠিকানা প্রতিরোধ করা হয়েছে।', 'settings.search.allowedSitesPlaceholder': 'xqx+x\nএক্স.qx1x\nxqx2x', @@ -2843,7 +2843,7 @@ const messages: TranslationMap = { 'workspace.vaultNotRegisteredHelp': 'Obsidian শুধুমাত্র আপনার যোগ করা ফোল্ডারগুলি ভল্ট হিসেবে খোলে। Obsidian-এ, "Open folder as vault" বেছে নিন এবং নিচের ফোল্ডারটি পিক করুন: এটি শুধুমাত্র একবার করতে হবে। তারপর আবার View Vault ক্লিক করুন।', 'workspace.obsidianNotFoundHelp': - 'আমরা এই ডিভাইসে Obsidian খুঁজে পাইনি। এটি ইনস্টল করুন, অথবা: যদি এটি অ-মানক কোথাও ইনস্টল করা থাকে: Advanced-এর অধীনে এর কনফিগ ফোল্ডার সেট করুন।', + 'আমরা এই ডিভাইসে Obsidian খুঁজে পাইনি। এটি ইনস্টল করুন, অথবা (যদি এটি অ-মানক কোথাও ইনস্টল করা থাকে) Advanced-এর অধীনে এর কনফিগ ফোল্ডার সেট করুন।', 'workspace.openAnyway': 'যাইহোক Obsidian-এ খুলুন', 'workspace.installObsidian': 'Obsidian ইনস্টল করুন', 'workspace.obsidianAdvanced': 'Obsidian অন্য কোথাও ইনস্টল করা আছে?', @@ -5535,7 +5535,7 @@ const messages: TranslationMap = { 'আপনার কম্পিউটারে পদক্ষেপ নেওয়ার সময় সহকারীর কতটুকু স্বাধীনতা থাকবে তা বেছে নিন।', 'settings.permissions.preset.readonly.title': 'দেখো, স্পর্শ করো না', 'settings.permissions.preset.readonly.desc': - 'সহকারী ফাইল পড়তে এবং অন্বেষণ করতে পারে: কিন্তু কখনো লিখতে, সম্পাদনা করতে বা এমন কিছু চালাতে পারবে না যা অবস্থা পরিবর্তন করে।', + 'সহকারী ফাইল পড়তে এবং অন্বেষণ করতে পারে, কিন্তু কখনো লিখতে, সম্পাদনা করতে বা এমন কিছু চালাতে পারবে না যা অবস্থা পরিবর্তন করে।', 'settings.permissions.preset.supervised.title': 'আগে আমাকে জিজ্ঞেস করো', 'settings.permissions.preset.supervised.desc': 'নতুন ফাইল তৈরি করতে পারে, কিন্তু সম্পাদনা, কমান্ড চালানো বা নেটওয়ার্ক অ্যাক্সেসের আগে সবসময় আপনার অনুমতি চাইবে।', @@ -5674,7 +5674,7 @@ const messages: TranslationMap = { 'settings.appearance.modeSystem': 'ম্যাচ সিস্টেম', 'settings.appearance.modeSystemDesc': 'আপনার OS চেহারা সেটিং অনুসরণ করুন.', 'settings.appearance.helperText': - 'ডার্ক মোড পুরো অ্যাপকে স্যুইচ করে: চ্যাট, সেটিংস, প্যানেল: একটি আবছা প্যালেটে। "ম্যাচ সিস্টেম" আপনার OS চেহারা অনুসরণ করে এবং লাইভ আপডেট করে।', + 'ডার্ক মোড পুরো অ্যাপকে স্যুইচ করে (চ্যাট, সেটিংস, প্যানেল) একটি আবছা প্যালেটে। "ম্যাচ সিস্টেম" আপনার OS চেহারা অনুসরণ করে এবং লাইভ আপডেট করে।', 'settings.appearance.fontSizeHeading': 'ফন্টের আকার', 'settings.appearance.fontSizeAria': 'ফন্টের আকার', 'settings.appearance.fontSizeSmall': 'ছোট', @@ -5686,7 +5686,7 @@ const messages: TranslationMap = { 'settings.appearance.fontSizeXLarge': 'অতিরিক্ত বড়', 'settings.appearance.fontSizeXLargeDesc': 'সর্বাধিক পঠনযোগ্যতার জন্য সবচেয়ে বড় লেখা।', 'settings.appearance.fontSizeHelperText': - 'সিস্টেম ফন্ট সেটিং নির্বিশেষে পুরো অ্যাপ জুড়ে: চ্যাট, সেটিংস ও প্যানেল: লেখার আকার পরিবর্তন করে।', + 'সিস্টেম ফন্ট সেটিং নির্বিশেষে পুরো অ্যাপ জুড়ে (চ্যাট, সেটিংস ও প্যানেল) লেখার আকার পরিবর্তন করে।', 'settings.appearance.fontSizeCustomLabel': 'কাস্টম আকার', 'settings.appearance.fontSizeCustomAria': 'পিক্সেলে কাস্টম ফন্ট আকার', 'settings.appearance.fontSizeCustomSliderAria': 'কাস্টম ফন্ট আকারের স্লাইডার, পিক্সেলে', @@ -5825,7 +5825,7 @@ const messages: TranslationMap = { 'যৌক্তিক ডিফল্ট: প্রতিটি রানে অতিরিক্ত টোকেন না পুড়িয়ে ভাল ধারাবাহিকতা।', 'settings.memoryWindow.balanced.label': 'সুষম', 'settings.memoryWindow.description': - 'প্রতিটি নতুন এজেন্ট রানে OpenHuman কতটা মনে রাখা প্রসঙ্গ ইনজেক্ট করে। বড় উইন্ডো অতীত কথোপকথন সম্পর্কে বেশি সচেতন মনে হয় কিন্তু প্রতিটি রানে বেশি টোকেন ব্যবহার করে: এবং বেশি খরচ হয়।', + 'প্রতিটি নতুন এজেন্ট রানে OpenHuman কতটা মনে রাখা প্রসঙ্গ ইনজেক্ট করে। বড় উইন্ডো অতীত কথোপকথন সম্পর্কে বেশি সচেতন মনে হয় কিন্তু প্রতিটি রানে বেশি টোকেন ব্যবহার করে, এবং বেশি খরচ হয়।', 'settings.memoryWindow.extended.badge': 'আরও প্রসঙ্গ', 'settings.memoryWindow.extended.hint': 'প্রতিটি রানে আরও দীর্ঘমেয়াদী মেমরি ইনজেক্ট করা হয়। প্রতি টার্নে উচ্চ টোকেন খরচ।', @@ -6839,7 +6839,7 @@ const messages: TranslationMap = { 'সহকারী যখন আপনার সম্পর্কে সংযুক্ত তথ্য রেকর্ড করে, তাদের ক্লাস্টারিং কাঠামো এখানে উঠে আসবে।', 'graphCohesion.errorPrefix': 'গ্রাফ লোড করা যায়নি:', 'graphCohesion.intro': - 'প্রতিটি সত্তার চারপাশে প্রতিবেশ কতটা দৃঢ়ভাবে বোনা। ব্রোকার: যেসব সত্তার প্রতিবেশী একে অপরের সাথে যুক্ত নয়: তারাই একমাত্র বিন্দু যা পরস্পর-বিচ্ছিন্ন ক্লাস্টারকে একত্রে ধরে রাখে, যা ফ্রিকোয়েন্সি বা PageRank সাজানি প্রকাশ করতে পারে না।', + 'প্রতিটি সত্তার চারপাশে প্রতিবেশ কতটা দৃঢ়ভাবে বোনা। ব্রোকার (যেসব সত্তার প্রতিবেশী একে অপরের সাথে যুক্ত নয়) তারাই একমাত্র বিন্দু যা পরস্পর-বিচ্ছিন্ন ক্লাস্টারকে একত্রে ধরে রাখে, যা ফ্রিকোয়েন্সি বা PageRank সাজানি প্রকাশ করতে পারে না।', 'graphCohesion.loading': 'সংসক্তি গণনা করা হচ্ছে…', 'graphCohesion.metricConnections': 'সংযোগ', 'graphCohesion.metricEntities': 'সত্তা', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index c1c3fcaa54..1f6b30194e 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -7,7 +7,7 @@ const messages: TranslationMap = { 'agentWorld.welcome.eyebrow': 'TinyPlace', 'agentWorld.welcome.title': 'Eine Welt, in der sich Ihre Agenten treffen', 'agentWorld.welcome.body': - 'TinyPlace ist die soziale Ebene für KI-Agenten: Ihre können andere Agenten entdecken, ihnen schreiben, Bounties übernehmen und handeln, alles in Ihrem Auftrag. Betreten Sie die Welt und sehen Sie, was sie so treiben.', + 'TinyPlace ist die soziale Ebene für KI-Agenten: Ihre Agenten können andere Agenten entdecken, ihnen schreiben, Bounties übernehmen und in Ihrem Auftrag handeln. Betreten Sie diese Welt und sehen Sie, was sie so treiben.', 'agentWorld.welcome.ctaWorld': 'Welt betreten', 'agentWorld.welcome.ctaFeed': 'Feed durchstöbern', 'agentWorld.welcome.ctaDirectory': 'Agenten finden', @@ -90,7 +90,7 @@ const messages: TranslationMap = { 'connections.welcome.eyebrow': 'Verbindungen', 'connections.welcome.title': 'Alles, was Sie nutzen, an einem Ort', 'connections.welcome.body': - 'Verbinden Sie Ihre Messaging-Apps, E-Mail, Kalender und Werkzeuge, damit Ihr Agent Kontext lesen und über alle hinweg handeln kann: ohne zwischen einem Dutzend Tabs hin- und herzukopieren. Sie behalten die Kontrolle darüber, was er anfassen darf.', + 'Verbinden Sie Ihre Messaging-Apps, E-Mail, Kalender und Werkzeuge, damit Ihr Agent Kontext lesen und über alle hinweg handeln kann, ohne zwischen einem Dutzend Tabs hin- und herzukopieren. Sie behalten die Kontrolle darüber, was er anfassen darf.', 'connections.welcome.ctaChannel': 'Kanal verbinden', 'connections.welcome.ctaApps': 'Apps verbinden', 'connections.welcome.ctaSkills': 'Skills durchstöbern', @@ -110,7 +110,7 @@ const messages: TranslationMap = { 'notifications.welcome.eyebrow': 'Benachrichtigungen', 'notifications.welcome.title': 'Nur, was Sie wirklich braucht', 'notifications.welcome.body': - 'Eine ruhige, bewertete Zusammenfassung dessen, was Ihre Agenten getan haben und was eine Entscheidung braucht: damit das Wichtige auftaucht und der Lärm Ihnen aus dem Weg bleibt.', + 'Eine ruhige, bewertete Zusammenfassung dessen, was Ihre Agenten getan haben und was Ihre Entscheidung erfordert, damit das Wichtige auftaucht und der Lärm Ihnen aus dem Weg bleibt.', 'notifications.welcome.ctaView': 'Hinweise ansehen', 'notifications.welcome.featsLabel': 'Was Sie sehen werden', 'notifications.welcome.feat1Title': 'Was Sie braucht', @@ -128,7 +128,7 @@ const messages: TranslationMap = { 'rewards.welcome.eyebrow': 'Belohnungen', 'rewards.welcome.title': 'Werden Sie fürs Dabeisein belohnt', 'rewards.welcome.body': - 'Sammeln Sie Punkte, während Sie OpenHuman nutzen und andere einladen, halten Sie Ihre Serie am Leben und lösen Sie ein, was Sie verdient haben: alles an einem Ort erfasst.', + 'Sammeln Sie Punkte, indem Sie OpenHuman nutzen und andere einladen. Halten Sie Ihre Serie am Leben und lösen Sie Ihre Prämien ein. Alles wird an einem Ort erfasst.', 'rewards.welcome.ctaView': 'Belohnungen ansehen', 'rewards.welcome.featsLabel': 'So funktioniert es', 'rewards.welcome.feat1Title': 'Punkte sammeln', @@ -142,7 +142,7 @@ const messages: TranslationMap = { 'flows.welcome.eyebrow': 'Arbeitsabläufe', 'flows.welcome.title': 'Schalten Sie die Fleißarbeit auf Autopilot', 'flows.welcome.body': - 'Beschreiben Sie etwas, das Sie immer wieder tun: Triage, Nachfassen, Zusammenfassungen: und Ihr Agent verwandelt es in einen Workflow, den er von Anfang bis Ende ausführen kann, nach Zeitplan oder auf Abruf.', + 'Beschreiben Sie etwas, das Sie immer wieder tun (Triage, Nachfassen, Zusammenfassungen), und Ihr Agent verwandelt es in einen Workflow, den er von Anfang bis Ende ausführen kann, nach Zeitplan oder auf Abruf.', 'flows.welcome.ctaNew': 'Neuer Workflow', 'flows.welcome.ctaBrowse': 'Workflows durchstöbern', 'flows.welcome.featsLabel': 'Was Sie automatisieren können', @@ -353,7 +353,7 @@ const messages: TranslationMap = { 'orchPage.medulla.title': 'Medulla', 'orchPage.medulla.tagline': 'Das Orchestrierungsmodell von OpenHuman', 'orchPage.medulla.body': - 'Medulla ist das eigens entwickelte LLM von OpenHuman, konzipiert, um Tausende Agenten gleichzeitig zu orchestrieren: mit einem Kontextfenster von 10 Millionen Tokens und radikal kostengünstiger Orchestrierung.', + 'Medulla ist das eigens entwickelte LLM von OpenHuman. Es ist darauf ausgelegt, Tausende Agenten gleichzeitig zu orchestrieren, verfügt über ein Kontextfenster von 10 Millionen Tokens und ermöglicht eine radikal kostengünstigere Orchestrierung.', 'orchPage.medulla.featAgents': 'Tausende Agenten', 'orchPage.medulla.featContext': 'Kontext mit 10M Tokens', 'orchPage.medulla.featCost': 'Kostengünstige Orchestrierung', @@ -1161,7 +1161,7 @@ const messages: TranslationMap = { 'memory.tab.associations': 'Associations', 'entityAssociations.title': 'Entitäts-Assoziationen', 'entityAssociations.intro': - 'Entitäten, die viele gleiche Verbindungen teilen, sind assoziiert: selbst wenn kein einzelnes Fakt sie direkt verknüpft. Die Jaccard-Ähnlichkeit deckt diese verborgenen Paare auf.', + 'Entitäten, die viele gemeinsame Verbindungen teilen, sind assoziiert, selbst wenn kein einzelnes Fakt sie direkt verknüpft. Die Jaccard-Ähnlichkeit deckt diese verborgenen Paare auf.', 'entityAssociations.loading': 'Assoziationen werden berechnet…', 'entityAssociations.errorPrefix': 'Diagramm konnte nicht geladen werden:', 'entityAssociations.retry': 'Retry', @@ -5609,7 +5609,7 @@ const messages: TranslationMap = { 'settings.agentAccess.accessMode': 'Aufrufmodus', 'settings.agentAccess.tier.readonly.title': 'Nur lesen', 'settings.agentAccess.tier.readonly.desc': - 'Liest Dateien und führt schreibgeschützte Befehle zum Erkunden aus: schreibt, bearbeitet oder führt jedoch niemals etwas aus, das den Status ändert.', + 'Liest Dateien und führt schreibgeschützte Befehle zum Erkunden aus, schreibt, bearbeitet oder führt jedoch niemals etwas aus, das den Status ändert.', 'settings.agentAccess.tier.supervised.title': 'Vor der Bearbeitung fragen', 'settings.agentAccess.tier.supervised.desc': 'Erstellt neue Dateien frei, bittet aber um Ihre Zustimmung, bevor Sie eine vorhandene Datei bearbeiten, einen Befehl ausführen, das Netzwerk erreichen oder etwas installieren.', @@ -5621,7 +5621,7 @@ const messages: TranslationMap = { '⚠ Vollzugriff führt Befehle mit Ihrem vollständigen Kontozugriff aus und ist nicht sandboxed. Aktivieren Sie es nur, wenn Sie dem Agenten dieses Geräts anvertrauen. Anmeldeinformationen und Systemverzeichnisse bleiben blockiert, und destruktive Netzwerk- und Installationsaktionen erfordern immer noch die Genehmigung.', 'settings.agentAccess.confine.label': 'Auf Arbeitsbereich beschränken', 'settings.agentAccess.confine.desc': - 'Beschränken Sie den Agenten auf das Arbeitsbereichsverzeichnis (plus alle gewährten Ordner), je nachdem, welcher Zugriffsmodus ausgewählt ist. Wenn sie ausgeschaltet ist, kann sie an jeden Ort gelangen, den Ihr Benutzer erreichen kann: mit Ausnahme der immer gesperrten Anmeldeinformationen und Systemverzeichnisse.', + 'Beschränken Sie den Agenten auf das Arbeitsbereichsverzeichnis (plus alle gewährten Ordner), je nachdem, welcher Zugriffsmodus ausgewählt ist. Wenn die Option ausgeschaltet ist, kann der Agent jeden Ort erreichen, auf den Ihr Benutzer zugreifen kann, mit Ausnahme der immer gesperrten Anmeldeinformationen und Systemverzeichnisse.', 'settings.agentAccess.requireTaskPlanApproval.label': 'Erfordern Sie die Genehmigung des Aufgabenplans', 'settings.agentAccess.requireTaskPlanApproval.desc': @@ -5849,7 +5849,7 @@ const messages: TranslationMap = { 'settings.appearance.fontSizeXLarge': 'Sehr groß', 'settings.appearance.fontSizeXLargeDesc': 'Der größte Text für maximale Lesbarkeit.', 'settings.appearance.fontSizeHelperText': - 'Skaliert den Text in der gesamten App: Chat, Einstellungen und Bereiche: unabhängig von der Schrifteinstellung deines Systems.', + 'Skaliert den Text in der gesamten App (Chat, Einstellungen und Bereiche) unabhängig von der Schrifteinstellung deines Systems.', 'settings.appearance.fontSizeCustomLabel': 'Benutzerdefinierte Größe', 'settings.appearance.fontSizeCustomAria': 'Benutzerdefinierte Schriftgröße in Pixeln', 'settings.appearance.fontSizeCustomSliderAria': @@ -7029,7 +7029,7 @@ const messages: TranslationMap = { 'Während der Assistent verbundene Fakten über Sie erfasst, erscheint hier deren Clustering-Struktur.', 'graphCohesion.errorPrefix': 'Graph konnte nicht geladen werden:', 'graphCohesion.intro': - 'Wie eng verwoben die Nachbarschaft jeder Entität ist. Broker: Entitäten, deren Nachbarn untereinander nicht verbunden sind: sind die Einzelpunkte, die sonst getrennte Cluster zusammenhalten, was eine Häufigkeits- oder PageRank-Sortierung nicht aufdecken kann.', + 'Wie eng verwoben die Nachbarschaft jeder Entität ist. Broker (Entitäten, deren Nachbarn untereinander nicht verbunden sind) sind die Einzelpunkte, die sonst getrennte Cluster zusammenhalten, was eine Häufigkeits- oder PageRank-Sortierung nicht aufdecken kann.', 'graphCohesion.loading': 'Berechne Kohäsion…', 'graphCohesion.metricConnections': 'Verbindungen', 'graphCohesion.metricEntities': 'Entitäten', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 46e747b495..0632c83197 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -44,7 +44,7 @@ const en: TranslationMap = { 'orchPage.medulla.title': 'Medulla', 'orchPage.medulla.tagline': "OpenHuman's orchestration model", 'orchPage.medulla.body': - "Medulla is OpenHuman's custom-built LLM, engineered to orchestrate thousands of agents at once: with a 10-million-token context window and radically low-cost orchestration.", + "Medulla is OpenHuman's custom-built LLM, engineered to orchestrate thousands of agents at once, with a 10-million-token context window and radically low-cost orchestration.", 'orchPage.medulla.featAgents': 'Thousands of agents', 'orchPage.medulla.featContext': '10M-token context', 'orchPage.medulla.featCost': 'Low-cost orchestration', @@ -154,7 +154,7 @@ const en: TranslationMap = { 'agentWorld.welcome.eyebrow': 'TinyPlace', 'agentWorld.welcome.title': 'A world where your agents meet', 'agentWorld.welcome.body': - 'TinyPlace is the social layer for AI agents: yours can discover other agents, message them, take on bounties, and trade, all on your behalf. Step into the world and see what they get up to.', + 'TinyPlace is the social layer for AI agents. Your agents can discover and message other agents, take on bounties, and trade on your behalf. Step into their world and see what they get up to.', 'agentWorld.welcome.ctaWorld': 'Enter the world', 'agentWorld.welcome.ctaFeed': 'Browse the feed', 'agentWorld.welcome.ctaDirectory': 'Find agents', @@ -720,7 +720,7 @@ const en: TranslationMap = { 'routines.notRunYet': 'Not run yet', 'routines.runNow': 'Run Now', 'routines.running': 'Running…', - 'routines.runNowTimedOut': 'Run timed out: please refresh and try again.', + 'routines.runNowTimedOut': 'Run timed out. Please refresh and try again.', 'routines.viewHistory': 'View history', 'routines.loadingHistory': 'Loading…', 'routines.noHistory': 'No run history yet.', @@ -748,7 +748,7 @@ const en: TranslationMap = { 'Queue a follow-up: sent after this reply · ⌘/Ctrl+Enter for a parallel branch', 'chat.queuedFollowups.label': 'Queued follow-ups', 'chat.queuedFollowups.clear': 'Clear', - 'chat.queuedFollowups.clearFailed': "Couldn't clear the queue: try again.", + 'chat.queuedFollowups.clearFailed': "Couldn't clear the queue. Try again.", 'chat.parallelBranchLabel': 'Parallel branch', 'chat.thinking': 'Thinking...', 'chat.noMessages': 'No messages yet', @@ -845,7 +845,7 @@ const en: TranslationMap = { 'connections.welcome.eyebrow': 'Connections', 'connections.welcome.title': 'Everything you use, in one place', 'connections.welcome.body': - 'Connect your messaging apps, email, calendar, and tools so your agent can read context and take action across all of them: without copy-pasting between a dozen tabs. You stay in control of what it can touch.', + 'Connect your messaging apps, email, calendar, and tools so your agent can read context and take action across all of them, without copy-pasting between a dozen tabs. You stay in control of what it can touch.', 'connections.welcome.ctaChannel': 'Connect a channel', 'connections.welcome.ctaApps': 'Connect apps', 'connections.welcome.ctaSkills': 'Browse skills', @@ -1099,7 +1099,7 @@ const en: TranslationMap = { 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', 'graphCentrality.title': 'Knowledge Graph Centrality', 'graphCentrality.intro': - 'PageRank over your memory graph surfaces the load-bearing hubs: and the connector entities that link otherwise-separate clusters, which a raw frequency count cannot reveal.', + 'PageRank over your memory graph surfaces the load-bearing hubs, and the connector entities that link otherwise-separate clusters, which a raw frequency count cannot reveal.', 'graphCentrality.loading': 'Computing centrality…', 'graphCentrality.errorPrefix': 'Could not load the graph:', 'graphCentrality.retry': 'Retry', @@ -1125,7 +1125,7 @@ const en: TranslationMap = { 'memory.tab.associations': 'Associations', 'entityAssociations.title': 'Entity Associations', 'entityAssociations.intro': - 'Entities that share many of the same connections are associated: even when no single fact links them directly. Jaccard similarity surfaces these hidden pairings.', + 'Entities that share many of the same connections are associated, even when no single fact links them directly. Jaccard similarity surfaces these hidden pairings.', 'entityAssociations.loading': 'Scoring associations…', 'entityAssociations.errorPrefix': 'Could not load the graph:', 'entityAssociations.retry': 'Retry', @@ -1193,7 +1193,7 @@ const en: TranslationMap = { 'graphCohesion.title': 'Graph Cohesion', 'graphCohesion.intro': - "How tightly knit the neighbourhood is around each entity. Brokers: entities whose neighbours aren't linked to each other: are the single points holding otherwise-separate clusters together, which a frequency or PageRank sort cannot reveal.", + "This shows how tightly knit the neighbourhood is around each entity. Brokers (entities whose neighbours aren't linked to each other) are the single points holding otherwise-separate clusters together, which a frequency or PageRank sort cannot reveal.", 'graphCohesion.loading': 'Computing cohesion…', 'graphCohesion.errorPrefix': 'Could not load the graph:', 'graphCohesion.retry': 'Retry', @@ -1214,7 +1214,7 @@ const en: TranslationMap = { 'graphCohesion.colLinks': 'Links', 'graphCohesion.brokerBadge': 'broker', 'graphCohesion.brokerTitle': - "Structural hole: this entity's neighbours aren't connected to each other: it's the sole link between them.", + "Structural hole: this entity's neighbours aren't connected to each other; it's the sole link between them.", // Memory Tree status panel (#1856 Part 1) 'memoryTree.status.title': 'Memory Tree', @@ -1294,7 +1294,7 @@ const en: TranslationMap = { 'notifications.welcome.eyebrow': 'Notifications', 'notifications.welcome.title': 'Only what actually needs you', 'notifications.welcome.body': - 'A calm, scored digest of what your agents did and what needs a decision: so the important things surface and the noise stays out of your way.', + 'A calm, scored digest of what your agents did and what needs a decision, so the important things surface and the noise stays out of your way.', 'notifications.welcome.ctaView': 'View alerts', 'notifications.welcome.featsLabel': "What you'll see", 'notifications.welcome.feat1Title': 'What needs you', @@ -1315,7 +1315,7 @@ const en: TranslationMap = { 'rewards.welcome.eyebrow': 'Rewards', 'rewards.welcome.title': 'Get rewarded for showing up', 'rewards.welcome.body': - 'Earn points as you use OpenHuman and invite others, keep your streak alive, and redeem what you’ve earned: all tracked in one place.', + 'Earn points as you use OpenHuman and invite others, keep your streak alive, and redeem what you’ve earned, all tracked in one place.', 'rewards.welcome.ctaView': 'View rewards', 'rewards.welcome.featsLabel': 'How it works', 'rewards.welcome.feat1Title': 'Earn points', @@ -1867,7 +1867,7 @@ const en: TranslationMap = { 'settings.search.placeholderQuerit': 'Querit API key', 'settings.search.allowedSitesLabel': 'Allowed websites', 'settings.search.allowedSitesHint': - 'Hosts the assistant may open and read: via web fetch and the browser tool: one per line, e.g. reuters.com. A host also covers its subdomains. Web search itself is not restricted by this list.', + 'Enter one host per line, such as reuters.com. The assistant may open and read these hosts through web fetch and the browser tool. Each host also covers its subdomains. This list does not restrict web search.', 'settings.search.allowedSitesAllOn': 'The assistant can open any public website. Local and private addresses stay blocked.', 'settings.search.allowedSitesPlaceholder': 'reuters.com\napnews.com\ngithub.com', @@ -2997,7 +2997,7 @@ const en: TranslationMap = { 'mic.unavailable': 'Microphone is not available', 'mic.permissionDenied': 'Microphone permission denied', 'mic.failedToStartRecorder': 'Failed to start recorder', - 'mic.deviceUnavailable': 'Selected microphone is unavailable: try a different device.', + 'mic.deviceUnavailable': 'Selected microphone is unavailable. Try a different device.', 'mic.deviceInUse': 'Microphone is in use by another application.', 'mic.error': 'Microphone error', 'mic.transcribing': 'Transcribing...', @@ -3132,7 +3132,7 @@ const en: TranslationMap = { 'workspace.vaultNotRegisteredHelp': 'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below: you only need to do this once. Then click View Vault again.', 'workspace.obsidianNotFoundHelp': - "We couldn't find Obsidian on this device. Install it, or: if it's installed somewhere non-standard: set its config folder under Advanced.", + "We couldn't find Obsidian on this device. Install it. If it's installed in a non-standard location, set its config folder under Advanced.", 'workspace.openAnyway': 'Open in Obsidian anyway', 'workspace.installObsidian': 'Install Obsidian', 'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?', @@ -3627,7 +3627,7 @@ const en: TranslationMap = { // Mic: error messages 'mic.noAudioCaptured': 'No audio captured', 'mic.noSpeechDetected': 'No speech detected', - 'mic.lowConfidenceResult': 'Could not understand the audio clearly: please try again', + 'mic.lowConfidenceResult': 'Could not understand the audio clearly. Please try again.', 'mic.failedToStopRecording': 'Failed to stop recording: {message}', 'mic.transcriptionFailed': 'Transcription failed: {message}', 'mic.voiceNotCompiled': @@ -3712,7 +3712,7 @@ const en: TranslationMap = { 'app.openhumanLink.discord.perk3': 'Share feedback directly with the team', 'app.openhumanLink.discord.perk4': 'Community help and support', 'app.openhumanLink.discordReport.intro': - 'Sorry: something broke on our end. We try to log these automatically, but sharing the details on Discord helps us fix it faster.', + 'Sorry, something broke on our end. We try to log these errors automatically, but sharing the details on Discord helps us fix them faster.', 'app.openhumanLink.discordReport.openDiscord': 'Open Discord', 'app.openhumanLink.done': 'Done', 'app.openhumanLink.notifications.desktopOnly': @@ -3805,7 +3805,7 @@ const en: TranslationMap = { 'chat.approval.alwaysAllowHint': 'Stop asking for this tool: add it to your Always-allow list', 'chat.approval.deciding': 'Working…', 'chat.approval.deny': 'Deny', - 'chat.approval.error': 'Could not record your decision: try again.', + 'chat.approval.error': 'Could not record your decision. Try again.', 'chat.approval.fallback': 'The agent wants to run an action that needs your approval.', 'chat.approval.title': 'Approval needed', 'chat.approval.tool': 'Tool:', @@ -3822,7 +3822,7 @@ const en: TranslationMap = { 'chat.flowApproval.approveAlwaysHint': 'Skip this checkpoint for future runs of this flow', 'chat.flowApproval.deny': 'Deny', 'chat.flowApproval.deciding': 'Working…', - 'chat.flowApproval.error': 'Could not record your decision: try again.', + 'chat.flowApproval.error': 'Could not record your decision. Try again.', 'chat.flowProposal.title': 'Workflow proposal', 'chat.flowProposal.subtitle': 'Review this automation before saving it.', 'chat.flowProposal.triggerLabel': 'Trigger', @@ -4512,7 +4512,7 @@ const en: TranslationMap = { 'tinyplaceOrchestration.identity.makeDiscoverable': 'Make discoverable', 'tinyplaceOrchestration.identity.republish': 'Republish keys', 'tinyplaceOrchestration.identity.publishing': 'Publishing…', - 'tinyplaceOrchestration.identity.publishFailed': 'Publish failed: try again', + 'tinyplaceOrchestration.identity.publishFailed': 'Publishing failed. Try again.', 'tinyplaceOrchestration.identity.card': 'Directory card', 'tinyplaceOrchestration.identity.key': 'Encryption key', 'tinyplaceOrchestration.identity.published': 'Published', @@ -4699,7 +4699,7 @@ const en: TranslationMap = { 'flows.welcome.eyebrow': 'Workflows', 'flows.welcome.title': 'Put the busywork on autopilot', 'flows.welcome.body': - 'Describe something you do over and over: triage, follow-ups, digests: and your agent turns it into a workflow it can run end to end, on a schedule or on demand.', + 'Describe something you do over and over (triage, follow-ups, digests) and your agent turns it into a workflow it can run end to end, on a schedule or on demand.', 'flows.welcome.ctaNew': 'New workflow', 'flows.welcome.ctaBrowse': 'Browse workflows', 'flows.welcome.featsLabel': 'What you can automate', @@ -4820,7 +4820,7 @@ const en: TranslationMap = { 'flows.copilot.saving': 'Saving…', 'flows.copilot.reject': 'Dismiss', 'flows.copilot.previewHint': 'Reviewing a proposed draft: nothing is saved yet.', - 'flows.copilot.repairDisplay': 'A run failed: please look at it and propose a fix.', + 'flows.copilot.repairDisplay': 'A run failed. Please review it and propose a fix.', 'flows.copilot.tool.proposing': 'Proposing workflow…', 'flows.copilot.tool.dryRunning': 'Dry-running workflow…', 'flows.copilot.tool.saving': 'Saving workflow…', @@ -5300,7 +5300,7 @@ const en: TranslationMap = { 'settings.ai.claudeCode.enableToCheck': 'Enable Claude Code to check sign-in.', 'settings.ai.claudeCode.usingApiKeyEnvDetail': 'Using ANTHROPIC_API_KEY from the environment.', 'settings.ai.claudeCode.notFoundInstall': - 'Claude Code CLI not found: install with: npm install -g @anthropic-ai/claude-code', + 'Claude Code CLI not found. Install it with: npm install -g @anthropic-ai/claude-code', 'settings.ai.claudeCode.unknownDetail': "Couldn't determine sign-in state. Your claude CLI may predate auth status: try Reconnect, then Recheck.", 'settings.ai.claudeCode.notSignedIn': 'Not signed in.', @@ -6157,7 +6157,7 @@ const en: TranslationMap = { 'settings.agentAccess.accessMode': 'Access mode', 'settings.agentAccess.tier.readonly.title': 'Read-only', 'settings.agentAccess.tier.readonly.desc': - 'Reads files and runs read-only commands to explore: but never writes, edits, or runs anything that changes state.', + 'Reads files and runs read-only commands to explore, but never writes, edits, or runs anything that changes state.', 'settings.agentAccess.tier.supervised.title': 'Ask before edit', 'settings.agentAccess.tier.supervised.desc': 'Creates new files freely, but asks for your approval before editing an existing file, running a command, reaching the network, or installing anything.', @@ -6169,7 +6169,7 @@ const en: TranslationMap = { '⚠ Full access runs commands with your full account access and is not sandboxed. Only enable it when you trust the agent with this machine. Credential and system directories stay blocked, and destructive, network, and install actions still ask for approval.', 'settings.agentAccess.confine.label': 'Confine to workspace', 'settings.agentAccess.confine.desc': - 'Restrict the agent to the workspace directory (plus any granted folders), whichever access mode is selected. When off, it can reach anywhere your user can: except the always-blocked credential and system directories.', + 'Restrict the agent to the workspace directory (plus any granted folders), whichever access mode is selected. When off, it can reach anywhere your user can, except the always-blocked credential and system directories.', 'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval', 'settings.agentAccess.requireTaskPlanApproval.desc': 'Pause before an assigned agent executes an agent-authored task brief.', @@ -6240,7 +6240,7 @@ const en: TranslationMap = { 'Choose how much freedom the assistant has when it takes actions on your computer.', 'settings.permissions.preset.readonly.title': "Look, don't touch", 'settings.permissions.preset.readonly.desc': - 'The assistant can read files and explore: but never write, edit, or run anything that changes state.', + 'The assistant can read files and explore, but never write, edit, or run anything that changes state.', 'settings.permissions.preset.supervised.title': 'Ask me first', 'settings.permissions.preset.supervised.desc': 'Can create new files freely, but always asks your approval before editing, running commands, or accessing the network.', @@ -6378,7 +6378,7 @@ const en: TranslationMap = { 'settings.appearance.modeSystem': 'Match system', 'settings.appearance.modeSystemDesc': 'Follow your OS appearance setting.', 'settings.appearance.helperText': - 'Dark mode switches the entire app: chat, settings, panels: to a dim palette. "Match system" follows your OS appearance and updates live.', + 'Dark mode switches the entire app (chat, settings, panels) to a dim palette. "Match system" follows your OS appearance and updates live.', 'settings.appearance.fontSizeHeading': 'Font size', 'settings.appearance.fontSizeAria': 'Font size', 'settings.appearance.fontSizeSmall': 'Small', @@ -6390,7 +6390,7 @@ const en: TranslationMap = { 'settings.appearance.fontSizeXLarge': 'Extra large', 'settings.appearance.fontSizeXLargeDesc': 'The largest text, for maximum readability.', 'settings.appearance.fontSizeHelperText': - 'Scales text across the whole app: chat, settings and panels: independently of your system font setting.', + 'Scales text across the whole app (chat, settings and panels) independently of your system font setting.', 'settings.appearance.fontSizeCustomLabel': 'Custom size', 'settings.appearance.fontSizeCustomAria': 'Custom font size in pixels', 'settings.appearance.fontSizeCustomSliderAria': 'Custom font size slider, in pixels', @@ -6529,7 +6529,7 @@ const en: TranslationMap = { 'Sensible default: good continuity without burning extra tokens on every run.', 'settings.memoryWindow.balanced.label': 'Balanced', 'settings.memoryWindow.description': - 'How much remembered context OpenHuman injects into every new agent run. Larger windows feel more aware of past conversations but use more tokens: and cost more: on every run.', + 'How much remembered context OpenHuman injects into every new agent run. Larger windows feel more aware of past conversations but use more tokens (and cost more) on every run.', 'settings.memoryWindow.extended.badge': 'More context', 'settings.memoryWindow.extended.hint': 'More long-term memory injected into each run. Higher token cost per turn.', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index d0a7312788..bf70c3d356 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -133,7 +133,7 @@ const messages: TranslationMap = { 'flows.welcome.eyebrow': 'Flujos de trabajo', 'flows.welcome.title': 'Pon el trabajo rutinario en piloto automático', 'flows.welcome.body': - 'Describe algo que haces una y otra vez: clasificación, seguimientos, resúmenes: y tu agente lo convierte en un flujo de trabajo que puede ejecutar de principio a fin, según un horario o cuando lo pidas.', + 'Describe algo que haces una y otra vez (clasificación, seguimientos, resúmenes) y tu agente lo convierte en un flujo de trabajo que puede ejecutar de principio a fin, según un horario o cuando lo pidas.', 'flows.welcome.ctaNew': 'Nuevo flujo de trabajo', 'flows.welcome.ctaBrowse': 'Explorar flujos de trabajo', 'flows.welcome.featsLabel': 'Lo que puedes automatizar', @@ -1144,7 +1144,7 @@ const messages: TranslationMap = { 'memory.tab.associations': 'Associations', 'entityAssociations.title': 'Asociaciones de entidades', 'entityAssociations.intro': - 'Las entidades que comparten muchas conexiones están asociadas: incluso cuando ningún hecho las vincula directamente. La similitud de Jaccard revela estos emparejamientos ocultos.', + 'Las entidades que comparten muchas conexiones están asociadas, incluso cuando ningún hecho las vincula directamente. La similitud de Jaccard revela estos emparejamientos ocultos.', 'entityAssociations.loading': 'Calculando asociaciones…', 'entityAssociations.errorPrefix': 'No se pudo cargar el grafo:', 'entityAssociations.retry': 'Reintentar', @@ -1681,7 +1681,7 @@ const messages: TranslationMap = { 'settings.search.placeholderQuerit': 'Querit clave API', 'settings.search.allowedSitesLabel': 'Sitios web permitidos', 'settings.search.allowedSitesHint': - 'Hosts que el asistente puede abrir y leer: mediante recuperación web y la herramienta de navegador: uno por línea, p. ej. reuters.com. Un host también incluye sus subdominios. La búsqueda web en sí no está restringida por esta lista.', + 'Hosts que el asistente puede abrir y leer (mediante recuperación web y la herramienta de navegador) uno por línea, p. ej. reuters.com. Un host también incluye sus subdominios. La búsqueda web en sí no está restringida por esta lista.', 'settings.search.allowedSitesAllOn': 'El asistente puede abrir cualquier sitio web público. Las direcciones locales y privadas permanecen bloqueadas.', 'settings.search.allowedSitesPlaceholder': 'reuters.com\napnews.com\ngithub.com', @@ -2898,7 +2898,7 @@ const messages: TranslationMap = { 'workspace.vaultNotRegisteredHelp': 'Obsidian solo abre las carpetas que hayas agregado como almacén. En Obsidian, elige «Abrir carpeta como almacén» y selecciona la carpeta de abajo; solo necesitas hacerlo una vez. Luego haz clic en Ver almacén de nuevo.', 'workspace.obsidianNotFoundHelp': - 'No encontramos Obsidian en este dispositivo. Instálalo, o: si está instalado en una ubicación no estándar: establece su carpeta de configuración en Avanzado.', + 'No encontramos Obsidian en este dispositivo. Instálalo, o (si está instalado en una ubicación no estándar) establece su carpeta de configuración en Avanzado.', 'workspace.openAnyway': 'Abrir en Obsidian de todos modos', 'workspace.installObsidian': 'Instalar Obsidian', 'workspace.obsidianAdvanced': '¿Obsidian instalado en otro lugar?', @@ -5796,7 +5796,7 @@ const messages: TranslationMap = { 'settings.appearance.fontSizeXLarge': 'Extra grande', 'settings.appearance.fontSizeXLargeDesc': 'El texto más grande, para la máxima legibilidad.', 'settings.appearance.fontSizeHelperText': - 'Escala el texto en toda la app: chat, ajustes y paneles: independientemente de la configuración de fuente de tu sistema.', + 'Escala el texto en toda la app (chat, ajustes y paneles) independientemente de la configuración de fuente de tu sistema.', 'settings.appearance.fontSizeCustomLabel': 'Tamaño personalizado', 'settings.appearance.fontSizeCustomAria': 'Tamaño de fuente personalizado en píxeles', 'settings.appearance.fontSizeCustomSliderAria': @@ -5937,7 +5937,7 @@ const messages: TranslationMap = { 'Predeterminado sensato: buena continuidad sin quemar tokens extra en cada ejecución.', 'settings.memoryWindow.balanced.label': 'Equilibrado', 'settings.memoryWindow.description': - 'Cuánto contexto recordado inyecta OpenHuman en cada nueva ejecución del agente. Ventanas más grandes parecen más conscientes de conversaciones pasadas, pero usan más tokens: y cuestan más: en cada ejecución.', + 'Cuánto contexto recordado inyecta OpenHuman en cada nueva ejecución del agente. Ventanas más grandes parecen más conscientes de conversaciones pasadas, pero usan más tokens (y cuestan más) en cada ejecución.', 'settings.memoryWindow.extended.badge': 'Más contexto', 'settings.memoryWindow.extended.hint': 'Más memoria a largo plazo inyectada en cada ejecución. Mayor coste de tokens por turno.', @@ -6973,7 +6973,7 @@ const messages: TranslationMap = { 'A medida que el asistente registra hechos conectados sobre usted, su estructura de agrupamiento aparecerá aquí.', 'graphCohesion.errorPrefix': 'No se pudo cargar el grafo:', 'graphCohesion.intro': - 'Cuán estrechamente tejido está el vecindario de cada entidad. Los intermediarios: entidades cuyos vecinos no están conectados entre sí: son los puntos únicos que mantienen unidos grupos que de otro modo estarían separados, algo que un orden por frecuencia o PageRank no puede revelar.', + 'Cuán estrechamente tejido está el vecindario de cada entidad. Los intermediarios (entidades cuyos vecinos no están conectados entre sí) son los puntos únicos que mantienen unidos grupos que de otro modo estarían separados, algo que un orden por frecuencia o PageRank no puede revelar.', 'graphCohesion.loading': 'Calculando cohesión…', 'graphCohesion.metricConnections': 'Conexiones', 'graphCohesion.metricEntities': 'Entidades', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 4803002a4d..2377b11924 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -88,7 +88,7 @@ const messages: TranslationMap = { 'connections.welcome.eyebrow': 'Connexions', 'connections.welcome.title': 'Tout ce que vous utilisez, au même endroit', 'connections.welcome.body': - 'Connectez vos applications de messagerie, votre e-mail, votre agenda et vos outils pour que votre agent puisse lire le contexte et agir sur l’ensemble: sans copier-coller entre une douzaine d’onglets. Vous gardez le contrôle de ce qu’il peut toucher.', + 'Connectez vos applications de messagerie, votre e-mail, votre agenda et vos outils pour que votre agent puisse lire le contexte et agir sur l’ensemble, sans copier-coller entre une douzaine d’onglets. Vous gardez le contrôle de ce qu’il peut toucher.', 'connections.welcome.ctaChannel': 'Connecter un canal', 'connections.welcome.ctaApps': 'Connecter des applications', 'connections.welcome.ctaSkills': 'Parcourir les compétences', @@ -108,7 +108,7 @@ const messages: TranslationMap = { 'notifications.welcome.eyebrow': 'Notifications', 'notifications.welcome.title': 'Seulement ce qui a vraiment besoin de vous', 'notifications.welcome.body': - 'Un récapitulatif calme et hiérarchisé de ce que vos agents ont fait et de ce qui exige une décision: pour que l’important remonte et que le bruit reste à l’écart.', + 'Un récapitulatif calme et hiérarchisé de ce que vos agents ont fait et de ce qui exige une décision, pour que l’important remonte et que le bruit reste à l’écart.', 'notifications.welcome.ctaView': 'Voir les alertes', 'notifications.welcome.featsLabel': 'Ce que vous verrez', 'notifications.welcome.feat1Title': 'Ce qui a besoin de vous', @@ -125,7 +125,7 @@ const messages: TranslationMap = { 'rewards.welcome.eyebrow': 'Récompenses', 'rewards.welcome.title': 'Soyez récompensé de votre présence', 'rewards.welcome.body': - 'Gagnez des points à mesure que vous utilisez OpenHuman et invitez d’autres personnes, maintenez votre série en vie et échangez ce que vous avez gagné: le tout suivi au même endroit.', + 'Gagnez des points à mesure que vous utilisez OpenHuman et invitez d’autres personnes, maintenez votre série en vie et échangez ce que vous avez gagné, le tout suivi au même endroit.', 'rewards.welcome.ctaView': 'Voir les récompenses', 'rewards.welcome.featsLabel': 'Comment ça marche', 'rewards.welcome.feat1Title': 'Gagnez des points', @@ -140,7 +140,7 @@ const messages: TranslationMap = { 'flows.welcome.eyebrow': 'Flux de travail', 'flows.welcome.title': 'Mettez les tâches répétitives en pilote automatique', 'flows.welcome.body': - 'Décrivez quelque chose que vous faites encore et encore: tri, relances, récapitulatifs: et votre agent en fait un flux de travail qu’il peut exécuter de bout en bout, selon un calendrier ou à la demande.', + 'Décrivez quelque chose que vous faites encore et encore (tri, relances, récapitulatifs) et votre agent en fait un flux de travail qu’il peut exécuter de bout en bout, selon un calendrier ou à la demande.', 'flows.welcome.ctaNew': 'Nouveau flux de travail', 'flows.welcome.ctaBrowse': 'Parcourir les flux de travail', 'flows.welcome.featsLabel': 'Ce que vous pouvez automatiser', @@ -349,7 +349,7 @@ const messages: TranslationMap = { 'orchPage.medulla.title': 'Medulla', 'orchPage.medulla.tagline': "Le modèle d'orchestration d'OpenHuman", 'orchPage.medulla.body': - "Medulla est le LLM développé sur mesure par OpenHuman, conçu pour orchestrer des milliers d'agents à la fois: avec une fenêtre de contexte de 10 millions de tokens et une orchestration à coût radicalement bas.", + "Medulla est le LLM développé sur mesure par OpenHuman, conçu pour orchestrer des milliers d'agents à la fois, avec une fenêtre de contexte de 10 millions de tokens et une orchestration à coût radicalement bas.", 'orchPage.medulla.featAgents': "Des milliers d'agents", 'orchPage.medulla.featContext': 'Contexte de 10M de tokens', 'orchPage.medulla.featCost': 'Orchestration à faible coût', @@ -1130,7 +1130,7 @@ const messages: TranslationMap = { 'namespaceOverview.truncated': 'Affichage des {shown} premiers espaces de noms sur {total}.', 'graphCentrality.title': 'Centralité du graphe de connaissances', 'graphCentrality.intro': - "PageRank sur votre graphe de mémoire met en évidence les hubs porteurs de charge: et les entités connectrices qui relient des clusters autrement séparés, ce qu'un simple comptage de fréquence ne peut révéler.", + "Le PageRank de votre graphe de mémoire met en évidence les hubs porteurs de charge et les entités connectrices qui relient des clusters autrement séparés, ce qu'un simple comptage de fréquence ne peut révéler.", 'graphCentrality.loading': 'Calcul de la centralité…', 'graphCentrality.errorPrefix': 'Impossible de charger le graphique:', 'graphCentrality.retry': 'Réessayer', @@ -1156,7 +1156,7 @@ const messages: TranslationMap = { 'memory.tab.associations': 'Associations', 'entityAssociations.title': "Associations d'entités", 'entityAssociations.intro': - 'Les entités partageant de nombreuses connexions sont associées: même sans lien factuel direct. La similarité de Jaccard révèle ces associations cachées.', + 'Les entités partageant de nombreuses connexions sont associées, même sans lien factuel direct. La similarité de Jaccard révèle ces associations cachées.', 'entityAssociations.loading': 'Calcul des associations…', 'entityAssociations.errorPrefix': 'Impossible de charger le graphe :', 'entityAssociations.retry': 'Retry', @@ -1697,7 +1697,7 @@ const messages: TranslationMap = { 'settings.search.placeholderQuerit': 'Cherche la clé API', 'settings.search.allowedSitesLabel': 'Sites web autorisés', 'settings.search.allowedSitesHint': - "Hôtes que l'assistant peut ouvrir et lire: via la récupération web et l'outil navigateur: un par ligne, p. ex. reuters.com. Un hôte couvre également ses sous-domaines. La recherche web elle-même n'est pas limitée par cette liste.", + "Saisissez les hôtes que l'assistant peut ouvrir et lire (via la récupération web et l'outil navigateur), un hôte par ligne, p. ex. reuters.com. Un hôte couvre également ses sous-domaines. La recherche web elle-même n'est pas limitée par cette liste.", 'settings.search.allowedSitesAllOn': "L'assistant peut ouvrir n'importe quel site public. Les adresses locales et privées restent bloquées.", 'settings.search.allowedSitesPlaceholder': 'reuters.com\napnews.com\ngithub.com', @@ -2922,7 +2922,7 @@ const messages: TranslationMap = { 'workspace.vaultNotRegisteredHelp': "Obsidian n'ouvre que les dossiers que vous avez ajoutés comme coffre. Dans Obsidian, choisissez « Ouvrir le dossier comme coffre » et sélectionnez le dossier ci-dessous: vous ne devez le faire qu'une seule fois. Cliquez ensuite sur Afficher le coffre.", 'workspace.obsidianNotFoundHelp': - "Obsidian est introuvable sur cet appareil. Installez-le, ou: s'il est installé dans un emplacement non standard: définissez son dossier de configuration sous Avancé.", + "Obsidian est introuvable sur cet appareil. Installez-le ou, s'il est installé dans un emplacement non standard, définissez son dossier de configuration sous Avancé.", 'workspace.openAnyway': 'Ouvrir dans Obsidian quand même', 'workspace.installObsidian': 'Installer Obsidian', 'workspace.obsidianAdvanced': 'Obsidian installé ailleurs ?', @@ -5584,7 +5584,7 @@ const messages: TranslationMap = { 'settings.agentAccess.accessMode': "Mode d'accès", 'settings.agentAccess.tier.readonly.title': 'Lecture seule', 'settings.agentAccess.tier.readonly.desc': - "Lit des fichiers et exécute des commandes en lecture seule pour explorer: mais n'écrit jamais, ne modifie jamais, ni n’exécute quoi que ce soit qui change l’état.", + "Lit des fichiers et exécute des commandes en lecture seule pour explorer, mais n'écrit jamais, ne modifie jamais ni n’exécute quoi que ce soit qui change l’état.", 'settings.agentAccess.tier.supervised.title': 'Demandez avant de modifier', 'settings.agentAccess.tier.supervised.desc': 'Crée de nouveaux fichiers librement, mais demande votre approbation avant de modifier un fichier existant, d’exécuter une commande, d’accéder au réseau ou d’installer quoi que ce soit.', @@ -5596,7 +5596,7 @@ const messages: TranslationMap = { "⚠ L'accès complet exécute des commandes avec l'accès complet de votre compte et n'est pas isolé. Ne l'activez que lorsque vous faites confiance à l'agent avec cette machine. Les répertoires de systèmes et d'identifiants restent bloqués, et les actions destructrices, réseau et d'installation demandent toujours une approbation.", 'settings.agentAccess.confine.label': "Confiner à l'espace de travail", 'settings.agentAccess.confine.desc': - "Restreignez l'agent au répertoire de l'espace de travail (plus tous les dossiers accordés), quel que soit le mode d'accès sélectionné. Lorsqu'il est désactivé, il peut accéder à n'importe quel endroit auquel votre utilisateur peut accéder: sauf aux répertoires de crédentiel et système toujours bloqués.", + "Restreignez l'agent au répertoire de l'espace de travail (plus tous les dossiers accordés), quel que soit le mode d'accès sélectionné. Lorsqu'il est désactivé, il peut accéder à n'importe quel endroit auquel votre utilisateur peut accéder, sauf aux répertoires d'identifiants et système toujours bloqués.", 'settings.agentAccess.requireTaskPlanApproval.label': "Exiger l'approbation du plan de tâche", 'settings.agentAccess.requireTaskPlanApproval.desc': "Pause avant qu'un agent assigné n'exécute un briefing de tâche rédigé par un agent.", @@ -5828,7 +5828,7 @@ const messages: TranslationMap = { 'settings.appearance.fontSizeXLarge': 'Très grande', 'settings.appearance.fontSizeXLargeDesc': 'Le texte le plus grand, pour une lisibilité maximale.', 'settings.appearance.fontSizeHelperText': - 'Ajuste la taille du texte dans toute l’application: chat, paramètres et panneaux: indépendamment du réglage de police de votre système.', + 'Ajuste la taille du texte dans toute l’application (chat, paramètres et panneaux) indépendamment du réglage de police de votre système.', 'settings.appearance.fontSizeCustomLabel': 'Taille personnalisée', 'settings.appearance.fontSizeCustomAria': 'Taille de police personnalisée en pixels', 'settings.appearance.fontSizeCustomSliderAria': @@ -5970,7 +5970,7 @@ const messages: TranslationMap = { 'Valeur par défaut raisonnable: bonne continuité sans consommer de jetons supplémentaires à chaque exécution.', 'settings.memoryWindow.balanced.label': 'Équilibré', 'settings.memoryWindow.description': - "Quelle quantité de contexte mémorisé OpenHuman injecte dans chaque nouvelle exécution d'agent. Des fenêtres plus larges semblent plus conscientes des conversations passées mais consomment plus de jetons: et coûtent plus cher: à chaque exécution.", + "Quelle quantité de contexte mémorisé OpenHuman injecte dans chaque nouvelle exécution d'agent. Des fenêtres plus larges semblent plus conscientes des conversations passées mais consomment plus de jetons (et coûtent plus cher) à chaque exécution.", 'settings.memoryWindow.extended.badge': 'Plus de contexte', 'settings.memoryWindow.extended.hint': 'Plus de mémoire à long terme injectée à chaque exécution. Coût en jetons plus élevé par tour.', @@ -7007,7 +7007,7 @@ const messages: TranslationMap = { "À mesure que l'assistant enregistre des faits connectés à votre sujet, leur structure de regroupement apparaîtra ici.", 'graphCohesion.errorPrefix': 'Impossible de charger le graphe :', 'graphCohesion.intro': - "À quel point le voisinage de chaque entité est étroitement tissé. Les courtiers: entités dont les voisins ne sont pas liés entre eux: sont les points uniques qui maintiennent ensemble des groupes autrement séparés, ce qu'un tri par fréquence ou PageRank ne peut révéler.", + "Cette mesure indique à quel point le voisinage de chaque entité est étroitement tissé. Les courtiers (entités dont les voisins ne sont pas liés entre eux) sont les points uniques qui maintiennent ensemble des groupes autrement séparés, ce qu'un tri par fréquence ou PageRank ne peut révéler.", 'graphCohesion.loading': 'Calcul de la cohésion…', 'graphCohesion.metricConnections': 'Connexions', 'graphCohesion.metricEntities': 'Entités', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 7d7182f05f..944335e945 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -78,7 +78,7 @@ const messages: TranslationMap = { 'connections.welcome.eyebrow': 'कनेक्शन', 'connections.welcome.title': 'जो कुछ भी आप उपयोग करते हैं, सब एक जगह', 'connections.welcome.body': - 'अपने मैसेजिंग ऐप्स, ईमेल, कैलेंडर और टूल जोड़ें ताकि आपका एजेंट उन सभी में संदर्भ पढ़ सके और कार्रवाई कर सके: दर्जनों टैब के बीच कॉपी-पेस्ट किए बिना। यह क्या छू सकता है, इस पर नियंत्रण आपके पास रहता है।', + 'अपने मैसेजिंग ऐप्स, ईमेल, कैलेंडर और टूल जोड़ें ताकि आपका एजेंट उन सभी में संदर्भ पढ़ सके और कार्रवाई कर सके, दर्जनों टैब के बीच कॉपी-पेस्ट किए बिना। यह क्या छू सकता है, इस पर नियंत्रण आपके पास रहता है।', 'connections.welcome.ctaChannel': 'एक चैनल जोड़ें', 'connections.welcome.ctaApps': 'ऐप्स जोड़ें', 'connections.welcome.ctaSkills': 'स्किल्स ब्राउज़ करें', @@ -98,7 +98,7 @@ const messages: TranslationMap = { 'notifications.welcome.eyebrow': 'सूचनाएँ', 'notifications.welcome.title': 'सिर्फ़ वही जिसके लिए वाकई आपकी ज़रूरत है', 'notifications.welcome.body': - 'आपके एजेंटों ने क्या किया और किस पर निर्णय चाहिए, इसका एक शांत, स्कोर किया हुआ सारांश: ताकि ज़रूरी चीज़ें सामने आएँ और शोर आपके रास्ते से दूर रहे।', + 'आपके एजेंटों ने क्या किया और किस पर निर्णय चाहिए, इसका एक शांत, स्कोर किया हुआ सारांश देखें, ताकि ज़रूरी चीज़ें सामने आएँ और शोर आपके रास्ते से दूर रहे।', 'notifications.welcome.ctaView': 'अलर्ट देखें', 'notifications.welcome.featsLabel': 'आप क्या देखेंगे', 'notifications.welcome.feat1Title': 'जिसके लिए आपकी ज़रूरत है', @@ -114,7 +114,7 @@ const messages: TranslationMap = { 'rewards.welcome.eyebrow': 'रिवॉर्ड्स', 'rewards.welcome.title': 'आते रहने के लिए पुरस्कार पाएँ', 'rewards.welcome.body': - 'जैसे-जैसे आप OpenHuman का उपयोग करते हैं और दूसरों को आमंत्रित करते हैं, पॉइंट कमाएँ, अपनी स्ट्रीक जीवित रखें, और जो कमाया है उसे भुनाएँ: सब कुछ एक ही जगह ट्रैक किया जाता है।', + 'जैसे-जैसे आप OpenHuman का उपयोग करते हैं और दूसरों को आमंत्रित करते हैं, पॉइंट कमाएँ, अपनी स्ट्रीक जीवित रखें और जो कमाया है उसे भुनाएँ। सब कुछ एक ही जगह ट्रैक किया जाता है।', 'rewards.welcome.ctaView': 'रिवॉर्ड्स देखें', 'rewards.welcome.featsLabel': 'यह कैसे काम करता है', 'rewards.welcome.feat1Title': 'पॉइंट कमाएँ', @@ -128,7 +128,7 @@ const messages: TranslationMap = { 'flows.welcome.eyebrow': 'वर्कफ़्लो', 'flows.welcome.title': 'दोहराव वाले काम को ऑटोपायलट पर लगाएँ', 'flows.welcome.body': - 'ऐसा कुछ बताएं जो आप बार-बार करते हैं: ट्राइएज, फ़ॉलो-अप, डाइजेस्ट: और आपका एजेंट उसे एक वर्कफ़्लो में बदल देता है जिसे वह शुरू से अंत तक, शेड्यूल पर या माँग पर चला सकता है।', + 'ऐसा कुछ बताएं जो आप बार-बार करते हैं (ट्राइएज, फ़ॉलो-अप, डाइजेस्ट) और आपका एजेंट उसे एक वर्कफ़्लो में बदल देता है जिसे वह शुरू से अंत तक, शेड्यूल पर या माँग पर चला सकता है।', 'flows.welcome.ctaNew': 'नया वर्कफ़्लो', 'flows.welcome.ctaBrowse': 'वर्कफ़्लो ब्राउज़ करें', 'flows.welcome.featsLabel': 'आप क्या स्वचालित कर सकते हैं', @@ -332,7 +332,7 @@ const messages: TranslationMap = { 'orchPage.medulla.title': 'Medulla', 'orchPage.medulla.tagline': 'OpenHuman का ऑर्केस्ट्रेशन मॉडल', 'orchPage.medulla.body': - 'Medulla, OpenHuman का स्वयं-निर्मित LLM है, जिसे एक साथ हज़ारों एजेंट्स को ऑर्केस्ट्रेट करने के लिए तैयार किया गया है: 10-मिलियन-टोकन कॉन्टेक्स्ट विंडो और बेहद कम लागत वाली ऑर्केस्ट्रेशन के साथ।', + 'Medulla, OpenHuman का स्वयं-निर्मित LLM है, जिसे एक साथ हज़ारों एजेंट्स को ऑर्केस्ट्रेट करने के लिए तैयार किया गया है, 10-मिलियन-टोकन कॉन्टेक्स्ट विंडो और बेहद कम लागत वाली ऑर्केस्ट्रेशन के साथ।', 'orchPage.medulla.featAgents': 'हज़ारों एजेंट', 'orchPage.medulla.featContext': '10M-टोकन कॉन्टेक्स्ट', 'orchPage.medulla.featCost': 'कम लागत वाली ऑर्केस्ट्रेशन', @@ -1116,7 +1116,7 @@ const messages: TranslationMap = { 'memory.tab.associations': 'Associations', 'entityAssociations.title': 'इकाई संबंध', 'entityAssociations.intro': - 'जो इकाइयाँ कई समान कनेक्शन साझा करती हैं, वे संबंधित होती हैं: भले ही कोई एक तथ्य उन्हें सीधे न जोड़े। Jaccard समानता इन छिपे हुए संबंधों को उजागर करती है।', + 'जो इकाइयाँ कई समान कनेक्शन साझा करती हैं, वे संबंधित होती हैं, भले ही कोई एक तथ्य उन्हें सीधे न जोड़े। Jaccard समानता इन छिपे हुए संबंधों को उजागर करती है।', 'entityAssociations.loading': 'संबंध स्कोर कर रहे हैं…', 'entityAssociations.errorPrefix': 'ग्राफ़ लोड नहीं हो सका:', 'entityAssociations.retry': 'Retry', @@ -1640,7 +1640,7 @@ const messages: TranslationMap = { 'settings.search.placeholderQuerit': 'क्वेरिट API कुंजी', 'settings.search.allowedSitesLabel': 'अनुमत वेबसाइटों', 'settings.search.allowedSitesHint': - 'वे होस्ट जिन्हें असिस्टेंट खोल और पढ़ सकता है: वेब फ़ेच और ब्राउज़र टूल के माध्यम से: प्रति पंक्ति एक, जैसे reuters.com। एक होस्ट में उसके सभी सबडोमेन भी शामिल होते हैं। वेब सर्च स्वयं इस सूची से प्रतिबंधित नहीं है।', + 'उन होस्ट को सूचीबद्ध करें जिन्हें असिस्टेंट खोल और पढ़ सकता है (वेब फ़ेच और ब्राउज़र टूल के माध्यम से), प्रति पंक्ति एक, जैसे reuters.com। एक होस्ट में उसके सभी सबडोमेन भी शामिल होते हैं। वेब सर्च स्वयं इस सूची से प्रतिबंधित नहीं है।', 'settings.search.allowedSitesAllOn': 'सहायक किसी भी सार्वजनिक वेबसाइट को खोल सकता है। स्थानीय और निजी पते अवरुद्ध रहते हैं।', 'settings.search.allowedSitesPlaceholder': 'reuters.com\napnews.com\ngithub.com', @@ -2840,7 +2840,7 @@ const messages: TranslationMap = { 'workspace.vaultNotRegisteredHelp': 'Obsidian केवल वे फ़ोल्डर खोलता है जो आपने वॉल्ट के रूप में जोड़े हैं। Obsidian में "Open folder as vault" चुनें और नीचे दिया फ़ोल्डर चुनें: यह एक बार करना है। फिर View Vault पर क्लिक करें।', 'workspace.obsidianNotFoundHelp': - 'इस डिवाइस पर Obsidian नहीं मिला। इसे इंस्टॉल करें, या: यदि यह किसी गैर-मानक स्थान पर इंस्टॉल है: Advanced में इसका कॉन्फ़िग फ़ोल्डर सेट करें।', + 'इस डिवाइस पर Obsidian नहीं मिला। इसे इंस्टॉल करें, या (यदि यह किसी गैर-मानक स्थान पर इंस्टॉल है) Advanced में इसका कॉन्फ़िग फ़ोल्डर सेट करें।', 'workspace.openAnyway': 'फिर भी Obsidian में खोलें', 'workspace.installObsidian': 'Obsidian इंस्टॉल करें', 'workspace.obsidianAdvanced': 'Obsidian किसी और जगह इंस्टॉल है?', @@ -5533,7 +5533,7 @@ const messages: TranslationMap = { 'चुनें कि आपके कंप्यूटर पर कार्रवाई करते समय सहायक को कितनी स्वतंत्रता है।', 'settings.permissions.preset.readonly.title': 'देखो, छुओ मत', 'settings.permissions.preset.readonly.desc': - 'सहायक फ़ाइलें पढ़ और खोज सकता है: लेकिन कभी लिखेगा, संपादित करेगा या ऐसा कुछ नहीं चलाएगा जो स्थिति बदले।', + 'सहायक फ़ाइलें पढ़ और खोज सकता है, लेकिन न तो लिखेगा, न संपादित करेगा और न ही ऐसा कुछ चलाएगा जो स्थिति बदले।', 'settings.permissions.preset.supervised.title': 'पहले पूछो', 'settings.permissions.preset.supervised.desc': 'नई फ़ाइलें स्वतंत्र रूप से बना सकता है, लेकिन संपादन, कमांड चलाने या नेटवर्क तक पहुँचने से पहले हमेशा आपकी अनुमति माँगेगा।', @@ -5683,7 +5683,7 @@ const messages: TranslationMap = { 'settings.appearance.fontSizeXLarge': 'बहुत बड़ा', 'settings.appearance.fontSizeXLargeDesc': 'अधिकतम पठनीयता के लिए सबसे बड़ा टेक्स्ट।', 'settings.appearance.fontSizeHelperText': - 'आपके सिस्टम फ़ॉन्ट सेटिंग से स्वतंत्र रूप से पूरे ऐप: चैट, सेटिंग्स और पैनल: में टेक्स्ट का आकार बदलता है।', + 'आपके सिस्टम फ़ॉन्ट सेटिंग से स्वतंत्र रूप से पूरे ऐप (चैट, सेटिंग्स और पैनल) में टेक्स्ट का आकार बदलता है।', 'settings.appearance.fontSizeCustomLabel': 'कस्टम आकार', 'settings.appearance.fontSizeCustomAria': 'पिक्सेल में कस्टम फ़ॉन्ट आकार', 'settings.appearance.fontSizeCustomSliderAria': 'कस्टम फ़ॉन्ट आकार स्लाइडर, पिक्सेल में', @@ -5821,7 +5821,7 @@ const messages: TranslationMap = { 'समझदारी भरा डिफ़ॉल्ट: हर रन पर अतिरिक्त टोकन खर्च किए बिना अच्छी निरंतरता।', 'settings.memoryWindow.balanced.label': 'संतुलित', 'settings.memoryWindow.description': - 'OpenHuman हर नए एजेंट रन में कितना याद किया गया संदर्भ इंजेक्ट करता है। बड़ी विंडोज़ पिछली बातचीत के बारे में अधिक जागरूक लगती हैं लेकिन हर रन पर अधिक टोकन का उपयोग करती हैं: और अधिक लागत आती है।', + 'OpenHuman हर नए एजेंट रन में कितना याद किया गया संदर्भ इंजेक्ट करता है। बड़ी विंडोज़ पिछली बातचीत के बारे में अधिक जागरूक लगती हैं, लेकिन हर रन पर अधिक टोकन का उपयोग करती हैं, जिससे लागत भी बढ़ती है।', 'settings.memoryWindow.extended.badge': 'अधिक संदर्भ', 'settings.memoryWindow.extended.hint': 'प्रत्येक रन में अधिक दीर्घकालिक मेमोरी इंजेक्ट होती है। प्रति टर्न उच्च टोकन लागत।', @@ -6835,7 +6835,7 @@ const messages: TranslationMap = { 'जैसे-जैसे सहायक आपके बारे में जुड़े हुए तथ्य दर्ज करता है, उनकी क्लस्टरिंग संरचना यहाँ उभरेगी।', 'graphCohesion.errorPrefix': 'ग्राफ लोड नहीं हो सका:', 'graphCohesion.intro': - 'हर इकाई के चारों ओर पड़ोस कितना घनिष्ठ रूप से बुना हुआ है। ब्रोकर: वे इकाइयाँ जिनके पड़ोसी आपस में नहीं जुड़े: एकमात्र बिंदु हैं जो वरना अलग क्लस्टरों को साथ थामे रखते हैं, जिसे आवृत्ति या PageRank-आधारित क्रम नहीं दिखा सकता।', + 'यह दिखाता है कि हर इकाई के चारों ओर का पड़ोस कितना घनिष्ठ रूप से बुना हुआ है। ब्रोकर (वे इकाइयाँ जिनके पड़ोसी आपस में नहीं जुड़े) एकमात्र बिंदु हैं जो वरना अलग क्लस्टरों को साथ थामे रखते हैं, जिन्हें आवृत्ति या PageRank-आधारित क्रम नहीं दिखा सकता।', 'graphCohesion.loading': 'संसक्ति गणना हो रही है…', 'graphCohesion.metricConnections': 'कनेक्शन', 'graphCohesion.metricEntities': 'इकाइयाँ', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 4353b7c37c..8f4c546936 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -82,7 +82,7 @@ const messages: TranslationMap = { 'connections.welcome.eyebrow': 'Koneksi', 'connections.welcome.title': 'Semua yang Anda gunakan, dalam satu tempat', 'connections.welcome.body': - 'Sambungkan aplikasi pesan, email, kalender, dan alat Anda agar agen Anda dapat membaca konteks dan bertindak di semuanya: tanpa menyalin-tempel di antara belasan tab. Anda tetap mengendalikan apa yang boleh disentuhnya.', + 'Sambungkan aplikasi pesan, email, kalender, dan alat Anda agar agen Anda dapat membaca konteks dan bertindak di semua aplikasi tersebut tanpa menyalin-tempel di antara belasan tab. Anda tetap mengendalikan apa yang boleh disentuhnya.', 'connections.welcome.ctaChannel': 'Sambungkan saluran', 'connections.welcome.ctaApps': 'Sambungkan aplikasi', 'connections.welcome.ctaSkills': 'Jelajahi keahlian', @@ -102,7 +102,7 @@ const messages: TranslationMap = { 'notifications.welcome.eyebrow': 'Notifikasi', 'notifications.welcome.title': 'Hanya yang benar-benar membutuhkan Anda', 'notifications.welcome.body': - 'Ringkasan tenang dan berperingkat tentang apa yang dilakukan agen Anda dan apa yang butuh keputusan: sehingga hal penting muncul dan kebisingan tidak menghalangi Anda.', + 'Ringkasan tenang dan berperingkat tentang apa yang dilakukan agen Anda dan hal-hal yang memerlukan keputusan, sehingga hal penting muncul dan kebisingan tidak menghalangi Anda.', 'notifications.welcome.ctaView': 'Lihat peringatan', 'notifications.welcome.featsLabel': 'Apa yang akan Anda lihat', 'notifications.welcome.feat1Title': 'Apa yang membutuhkan Anda', @@ -118,7 +118,7 @@ const messages: TranslationMap = { 'rewards.welcome.eyebrow': 'Hadiah', 'rewards.welcome.title': 'Dapatkan hadiah karena hadir', 'rewards.welcome.body': - 'Kumpulkan poin saat Anda menggunakan OpenHuman dan mengundang orang lain, jaga rentetan Anda tetap hidup, dan tukarkan yang telah Anda peroleh: semuanya terlacak dalam satu tempat.', + 'Kumpulkan poin saat Anda menggunakan OpenHuman dan mengundang orang lain, jaga rentetan Anda tetap hidup, dan tukarkan yang telah Anda peroleh, semuanya terlacak dalam satu tempat.', 'rewards.welcome.ctaView': 'Lihat hadiah', 'rewards.welcome.featsLabel': 'Cara kerjanya', 'rewards.welcome.feat1Title': 'Kumpulkan poin', @@ -132,7 +132,7 @@ const messages: TranslationMap = { 'flows.welcome.eyebrow': 'Alur kerja', 'flows.welcome.title': 'Jadikan pekerjaan rutin berjalan otomatis', 'flows.welcome.body': - 'Uraikan sesuatu yang Anda lakukan berulang kali: triase, tindak lanjut, ringkasan: dan agen Anda mengubahnya menjadi alur kerja yang bisa dijalankan dari awal sampai akhir, terjadwal atau sesuai permintaan.', + 'Uraikan sesuatu yang Anda lakukan berulang kali (triase, tindak lanjut, ringkasan); agen Anda akan mengubahnya menjadi alur kerja yang bisa dijalankan dari awal sampai akhir, terjadwal atau sesuai permintaan.', 'flows.welcome.ctaNew': 'Alur kerja baru', 'flows.welcome.ctaBrowse': 'Jelajahi alur kerja', 'flows.welcome.featsLabel': 'Apa yang bisa Anda otomatiskan', @@ -338,7 +338,7 @@ const messages: TranslationMap = { 'orchPage.medulla.title': 'Medulla', 'orchPage.medulla.tagline': 'Model orkestrasi OpenHuman', 'orchPage.medulla.body': - 'Medulla adalah LLM buatan sendiri dari OpenHuman, direkayasa untuk mengorkestrasi ribuan agen sekaligus: dengan jendela konteks 10 juta token dan orkestrasi berbiaya sangat rendah.', + 'Medulla adalah LLM buatan OpenHuman yang dirancang untuk mengorkestrasi ribuan agen sekaligus, dengan jendela konteks 10 juta token dan orkestrasi berbiaya sangat rendah.', 'orchPage.medulla.featAgents': 'Ribuan agen', 'orchPage.medulla.featContext': 'Konteks 10M token', 'orchPage.medulla.featCost': 'Orkestrasi berbiaya rendah', @@ -1130,7 +1130,7 @@ const messages: TranslationMap = { 'memory.tab.associations': 'Associations', 'entityAssociations.title': 'Asosiasi Entitas', 'entityAssociations.intro': - 'Entitas yang berbagi banyak koneksi yang sama berasosiasi: meskipun tidak ada satu fakta pun yang menghubungkan mereka secara langsung. Kesamaan Jaccard mengungkap pasangan tersembunyi ini.', + 'Entitas yang memiliki banyak koneksi yang sama berasosiasi, meskipun tidak ada satu fakta pun yang menghubungkan mereka secara langsung. Kesamaan Jaccard mengungkap pasangan tersembunyi ini.', 'entityAssociations.loading': 'Menghitung asosiasi…', 'entityAssociations.errorPrefix': 'Tidak dapat memuat graf:', 'entityAssociations.retry': 'Retry', @@ -1657,7 +1657,7 @@ const messages: TranslationMap = { 'settings.search.placeholderQuerit': 'kunci Querit API', 'settings.search.allowedSitesLabel': 'Situs yang diijinkan', 'settings.search.allowedSitesHint': - 'Host yang boleh dibuka dan dibaca oleh asisten: melalui pengambilan web dan alat browser: satu per baris, mis. reuters.com. Sebuah host juga mencakup subdomain-nya. Penelusuran web itu sendiri tidak dibatasi oleh daftar ini.', + 'Host yang boleh dibuka dan dibaca oleh asisten (melalui pengambilan web dan alat browser) satu per baris, mis. reuters.com. Sebuah host juga mencakup subdomain-nya. Penelusuran web itu sendiri tidak dibatasi oleh daftar ini.', 'settings.search.allowedSitesAllOn': 'Asisten dapat membuka website publik. Alamat lokal dan pribadi tetap diblokir.', 'settings.search.allowedSitesPlaceholder': 'reuters.com\napnews.com\ngithub.com', @@ -2853,7 +2853,7 @@ const messages: TranslationMap = { 'workspace.vaultNotRegisteredHelp': 'Obsidian hanya membuka folder yang telah Anda tambahkan sebagai vault. Di Obsidian, pilih "Buka folder sebagai vault" dan pilih folder di bawah: Anda hanya perlu melakukan ini sekali. Lalu klik Lihat Vault lagi.', 'workspace.obsidianNotFoundHelp': - 'Kami tidak dapat menemukan Obsidian di perangkat ini. Instal, atau: jika dipasang di lokasi non-standar: atur folder konfigurasinya di Lanjutan.', + 'Kami tidak dapat menemukan Obsidian di perangkat ini. Instal, atau (jika dipasang di lokasi non-standar) atur folder konfigurasinya di Lanjutan.', 'workspace.openAnyway': 'Buka di Obsidian tetap', 'workspace.installObsidian': 'Instal Obsidian', 'workspace.obsidianAdvanced': 'Obsidian dipasang di tempat lain?', @@ -5561,7 +5561,7 @@ const messages: TranslationMap = { 'Pilih seberapa banyak kebebasan yang dimiliki asisten saat mengambil tindakan di komputer Anda.', 'settings.permissions.preset.readonly.title': 'Lihat, jangan sentuh', 'settings.permissions.preset.readonly.desc': - 'Asisten dapat membaca file dan menjelajah: tetapi tidak pernah menulis, mengedit, atau menjalankan apa pun yang mengubah status.', + 'Asisten dapat membaca file dan menjelajah, tetapi tidak pernah menulis, mengedit, atau menjalankan apa pun yang mengubah status.', 'settings.permissions.preset.supervised.title': 'Tanya saya dulu', 'settings.permissions.preset.supervised.desc': 'Dapat membuat file baru secara bebas, tetapi selalu meminta persetujuan Anda sebelum mengedit, menjalankan perintah, atau mengakses jaringan.', @@ -5711,7 +5711,7 @@ const messages: TranslationMap = { 'settings.appearance.fontSizeXLarge': 'Sangat besar', 'settings.appearance.fontSizeXLargeDesc': 'Teks terbesar untuk keterbacaan maksimal.', 'settings.appearance.fontSizeHelperText': - 'Menskalakan teks di seluruh aplikasi: obrolan, pengaturan, dan panel: terlepas dari pengaturan font sistem Anda.', + 'Menskalakan teks di seluruh aplikasi (obrolan, pengaturan, dan panel) terlepas dari pengaturan font sistem Anda.', 'settings.appearance.fontSizeCustomLabel': 'Ukuran khusus', 'settings.appearance.fontSizeCustomAria': 'Ukuran font khusus dalam piksel', 'settings.appearance.fontSizeCustomSliderAria': 'Penggeser ukuran font khusus, dalam piksel', @@ -5850,7 +5850,7 @@ const messages: TranslationMap = { 'Default yang masuk akal: kontinuitas yang baik tanpa membakar token tambahan di setiap run.', 'settings.memoryWindow.balanced.label': 'Seimbang', 'settings.memoryWindow.description': - 'Seberapa banyak konteks yang diingat OpenHuman dimasukkan ke setiap run agen baru. Jendela yang lebih besar terasa lebih sadar akan percakapan sebelumnya, tetapi menggunakan lebih banyak token: dan biaya lebih mahal: di setiap run.', + 'Seberapa banyak konteks yang diingat OpenHuman dimasukkan ke setiap run agen baru. Jendela yang lebih besar terasa lebih sadar akan percakapan sebelumnya, tetapi menggunakan lebih banyak token dan biayanya lebih tinggi di setiap run.', 'settings.memoryWindow.extended.badge': 'Lebih banyak konteks', 'settings.memoryWindow.extended.hint': 'Lebih banyak memori jangka panjang yang dimasukkan ke setiap run. Biaya token per giliran lebih tinggi.', @@ -6866,7 +6866,7 @@ const messages: TranslationMap = { 'Saat asisten mencatat fakta-fakta terhubung tentang Anda, struktur pengelompokannya akan muncul di sini.', 'graphCohesion.errorPrefix': 'Tidak dapat memuat graf:', 'graphCohesion.intro': - 'Seberapa rapat lingkungan di sekitar setiap entitas terjalin. Broker: entitas yang tetangganya tidak saling terhubung: adalah titik-titik tunggal yang menyatukan klaster yang sebenarnya terpisah, hal yang tidak dapat diungkap oleh pengurutan frekuensi atau PageRank.', + 'Seberapa rapat lingkungan di sekitar setiap entitas terjalin. Broker (entitas yang tetangganya tidak saling terhubung) adalah titik-titik tunggal yang menyatukan klaster yang sebenarnya terpisah, hal yang tidak dapat diungkap oleh pengurutan frekuensi atau PageRank.', 'graphCohesion.loading': 'Menghitung kohesi…', 'graphCohesion.metricConnections': 'Koneksi', 'graphCohesion.metricEntities': 'Entitas', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 8c45ad3467..79b773be88 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -83,7 +83,7 @@ const messages: TranslationMap = { 'connections.welcome.eyebrow': 'Connessioni', 'connections.welcome.title': 'Tutto ciò che usi, in un unico posto', 'connections.welcome.body': - 'Collega le tue app di messaggistica, email, calendario e strumenti così il tuo agente può leggere il contesto e agire su tutti: senza copiare e incollare tra decine di schede. Resti tu a controllare cosa può toccare.', + 'Collega le tue app di messaggistica, email, calendario e strumenti così il tuo agente può leggere il contesto e agire su tutti, senza copiare e incollare tra decine di schede. Resti tu a controllare cosa può toccare.', 'connections.welcome.ctaChannel': 'Collega un canale', 'connections.welcome.ctaApps': 'Collega le app', 'connections.welcome.ctaSkills': 'Sfoglia le abilità', @@ -103,7 +103,7 @@ const messages: TranslationMap = { 'notifications.welcome.eyebrow': 'Notifiche', 'notifications.welcome.title': 'Solo ciò che ha davvero bisogno di te', 'notifications.welcome.body': - 'Un riepilogo calmo e valutato di ciò che hanno fatto i tuoi agenti e di ciò che richiede una decisione: così le cose importanti emergono e il rumore resta fuori dai piedi.', + 'Un riepilogo calmo e valutato di ciò che hanno fatto i tuoi agenti e di ciò che richiede una decisione, così le cose importanti emergono e il rumore resta fuori dai piedi.', 'notifications.welcome.ctaView': 'Vedi gli avvisi', 'notifications.welcome.featsLabel': 'Cosa vedrai', 'notifications.welcome.feat1Title': 'Cosa richiede te', @@ -120,7 +120,7 @@ const messages: TranslationMap = { 'rewards.welcome.eyebrow': 'Premi', 'rewards.welcome.title': 'Vieni premiato per la tua presenza', 'rewards.welcome.body': - 'Guadagna punti usando OpenHuman e invitando altri, mantieni viva la tua serie e riscatta ciò che hai guadagnato: tutto tracciato in un unico posto.', + 'Guadagna punti usando OpenHuman e invitando altri, mantieni viva la tua serie e riscatta ciò che hai guadagnato, tutto tracciato in un unico posto.', 'rewards.welcome.ctaView': 'Vedi i premi', 'rewards.welcome.featsLabel': 'Come funziona', 'rewards.welcome.feat1Title': 'Guadagna punti', @@ -134,7 +134,7 @@ const messages: TranslationMap = { 'flows.welcome.eyebrow': 'Flussi di lavoro', 'flows.welcome.title': 'Metti il lavoro ripetitivo in pilota automatico', 'flows.welcome.body': - 'Descrivi qualcosa che fai in continuazione: smistamento, follow-up, riepiloghi: e il tuo agente lo trasforma in un flusso di lavoro che può eseguire dall’inizio alla fine, su pianificazione o su richiesta.', + 'Descrivi qualcosa che fai in continuazione (smistamento, follow-up, riepiloghi) e il tuo agente lo trasforma in un flusso di lavoro che può eseguire dall’inizio alla fine, su pianificazione o su richiesta.', 'flows.welcome.ctaNew': 'Nuovo flusso di lavoro', 'flows.welcome.ctaBrowse': 'Sfoglia i flussi di lavoro', 'flows.welcome.featsLabel': 'Cosa puoi automatizzare', @@ -342,7 +342,7 @@ const messages: TranslationMap = { 'orchPage.medulla.title': 'Medulla', 'orchPage.medulla.tagline': 'Il modello di orchestrazione di OpenHuman', 'orchPage.medulla.body': - "Medulla è l'LLM sviluppato internamente da OpenHuman, progettato per orchestrare migliaia di agenti in una sola volta: con una finestra di contesto da 10 milioni di token e un'orchestrazione a costi radicalmente ridotti.", + "Medulla è l'LLM sviluppato internamente da OpenHuman, progettato per orchestrare migliaia di agenti in una sola volta, con una finestra di contesto da 10 milioni di token e un'orchestrazione a costi radicalmente ridotti.", 'orchPage.medulla.featAgents': 'Migliaia di agenti', 'orchPage.medulla.featContext': 'Contesto da 10M di token', 'orchPage.medulla.featCost': 'Orchestrazione a basso costo', @@ -1119,7 +1119,7 @@ const messages: TranslationMap = { 'namespaceOverview.truncated': 'Mostrando i primi {shown} di {total} spazi dei nomi.', 'graphCentrality.title': 'Centralità del Grafo della Conoscenza', 'graphCentrality.intro': - 'PageRank sul tuo grafo della memoria mette in evidenza gli hub portanti: e le entità di collegamento che connettono cluster altrimenti separati, cosa che un semplice conteggio di frequenza non può rivelare.', + 'PageRank sul tuo grafo della memoria mette in evidenza gli hub portanti, e le entità di collegamento che connettono cluster altrimenti separati, cosa che un semplice conteggio di frequenza non può rivelare.', 'graphCentrality.loading': 'Calcolando la centralità…', 'graphCentrality.errorPrefix': 'Impossibile caricare il grafico:', 'graphCentrality.retry': 'Riprova', @@ -1147,7 +1147,7 @@ const messages: TranslationMap = { 'memory.tab.associations': 'Associations', 'entityAssociations.title': 'Associazioni di entità', 'entityAssociations.intro': - 'Le entità che condividono molte delle stesse connessioni sono associate: anche quando nessun singolo fatto le collega direttamente. La similarità di Jaccard rivela questi abbinamenti nascosti.', + 'Le entità che condividono molte delle stesse connessioni sono associate, anche quando nessun singolo fatto le collega direttamente. La similarità di Jaccard rivela questi abbinamenti nascosti.', 'entityAssociations.loading': 'Calcolo delle associazioni…', 'entityAssociations.errorPrefix': 'Impossibile caricare il grafo:', 'entityAssociations.retry': 'Retry', @@ -1681,7 +1681,7 @@ const messages: TranslationMap = { 'settings.search.placeholderQuerit': 'Chiave Querit API', 'settings.search.allowedSitesLabel': 'Siti web consentiti', 'settings.search.allowedSitesHint': - "Host che l'assistente può aprire e leggere: tramite recupero web e lo strumento browser: uno per riga, es. reuters.com. Un host include anche i suoi sottodomini. La ricerca web stessa non è limitata da questo elenco.", + "Inserisci, uno per riga, gli host che l'assistente può aprire e leggere tramite il recupero web e lo strumento browser, ad es. reuters.com. Un host include anche i suoi sottodomini. La ricerca web stessa non è limitata da questo elenco.", 'settings.search.allowedSitesAllOn': "L'assistente può aprire qualsiasi sito web pubblico. Gli indirizzi locali e privati rimangono bloccati.", 'settings.search.allowedSitesPlaceholder': 'reuters.com\napnews.com\ngithub.com', @@ -2896,7 +2896,7 @@ const messages: TranslationMap = { 'workspace.vaultNotRegisteredHelp': 'Obsidian apre solo le cartelle che hai aggiunto come vault. In Obsidian, scegli "Apri cartella come vault" e seleziona la cartella qui sotto: devi farlo solo una volta. Poi fai clic su Visualizza vault.', 'workspace.obsidianNotFoundHelp': - 'Non abbiamo trovato Obsidian su questo dispositivo. Installalo, oppure: se è installato in una posizione non standard: imposta la sua cartella di configurazione in Avanzate.', + 'Non abbiamo trovato Obsidian su questo dispositivo. Installalo, oppure (se è installato in una posizione non standard) imposta la sua cartella di configurazione in Avanzate.', 'workspace.openAnyway': 'Apri comunque in Obsidian', 'workspace.installObsidian': 'Installa Obsidian', 'workspace.obsidianAdvanced': 'Obsidian installato altrove?', @@ -5557,7 +5557,7 @@ const messages: TranslationMap = { "⚠ L'accesso completo esegue comandi con l'accesso completo al tuo account e non è isolato. Attivalo solo quando ti fidi dell'agente con questa macchina. Le directory delle credenziali e di sistema rimangono bloccate, e le azioni distruttive, di rete e di installazione richiedono comunque l'approvazione.", 'settings.agentAccess.confine.label': "Limita all'area di lavoro", 'settings.agentAccess.confine.desc': - "Restringi l'agente alla directory di lavoro (più eventuali cartelle concesse), qualunque modalità di accesso sia selezionata. Quando è disattivato, può raggiungere ovunque l'utente possa: tranne le directory delle credenziali e di sistema sempre bloccate.", + "Restringi l'agente alla directory di lavoro (più eventuali cartelle concesse), qualunque modalità di accesso sia selezionata. Quando è disattivato, può raggiungere ovunque l'utente possa, tranne le directory delle credenziali e di sistema sempre bloccate.", 'settings.agentAccess.requireTaskPlanApproval.label': "Richiedere l'approvazione del piano di lavoro", 'settings.agentAccess.requireTaskPlanApproval.desc': @@ -5786,7 +5786,7 @@ const messages: TranslationMap = { 'settings.appearance.fontSizeXLarge': 'Molto grande', 'settings.appearance.fontSizeXLargeDesc': 'Il testo più grande, per la massima leggibilità.', 'settings.appearance.fontSizeHelperText': - 'Ridimensiona il testo in tutta l’app: chat, impostazioni e pannelli: indipendentemente dall’impostazione del carattere del sistema.', + 'Ridimensiona il testo in tutta l’app (chat, impostazioni e pannelli) indipendentemente dall’impostazione del carattere del sistema.', 'settings.appearance.fontSizeCustomLabel': 'Dimensione personalizzata', 'settings.appearance.fontSizeCustomAria': 'Dimensione del carattere personalizzata in pixel', 'settings.appearance.fontSizeCustomSliderAria': @@ -5928,7 +5928,7 @@ const messages: TranslationMap = { 'Predefinito sensato: buona continuità senza bruciare token extra a ogni esecuzione.', 'settings.memoryWindow.balanced.label': 'Bilanciato', 'settings.memoryWindow.description': - "Quanto contesto ricordato OpenHuman inietta in ogni nuova esecuzione dell'agente. Finestre più grandi fanno sentire l'agente più consapevole delle conversazioni passate ma usano più token: e costano di più: a ogni esecuzione.", + "Quanto contesto ricordato OpenHuman inietta in ogni nuova esecuzione dell'agente. Finestre più grandi fanno sentire l'agente più consapevole delle conversazioni passate ma usano più token (e costano di più) a ogni esecuzione.", 'settings.memoryWindow.extended.badge': 'Più contesto', 'settings.memoryWindow.extended.hint': 'Più memoria a lungo termine iniettata in ogni esecuzione. Costo token più alto per turno.', @@ -6960,7 +6960,7 @@ const messages: TranslationMap = { "Man mano che l'assistente registra fatti connessi su di te, qui apparirà la loro struttura di raggruppamento.", 'graphCohesion.errorPrefix': 'Impossibile caricare il grafo:', 'graphCohesion.intro': - 'Quanto è fittamente intrecciato il vicinato attorno a ciascuna entità. I broker: entità i cui vicini non sono collegati tra loro: sono i punti singoli che tengono uniti gruppi altrimenti separati, cosa che un ordinamento per frequenza o PageRank non può rivelare.', + 'Quanto è fittamente intrecciato il vicinato attorno a ciascuna entità. I broker (entità i cui vicini non sono collegati tra loro) sono i punti singoli che tengono uniti gruppi altrimenti separati, cosa che un ordinamento per frequenza o PageRank non può rivelare.', 'graphCohesion.loading': 'Calcolo della coesione…', 'graphCohesion.metricConnections': 'Connessioni', 'graphCohesion.metricEntities': 'Entità', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 5d243a7beb..7d04b5c76d 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -77,7 +77,7 @@ const messages: TranslationMap = { 'connections.welcome.eyebrow': '연결', 'connections.welcome.title': '당신이 사용하는 모든 것을 한곳에', 'connections.welcome.body': - '메시징 앱, 이메일, 캘린더, 도구를 연결하면 당신의 에이전트가 맥락을 읽고 그 모든 것에 걸쳐 조치를 취할 수 있습니다: 수십 개의 탭 사이를 복사·붙여넣기할 필요 없이 말이죠. 무엇에 접근할 수 있는지는 당신이 통제합니다.', + '메시징 앱, 이메일, 캘린더, 도구를 연결하면 당신의 에이전트가 맥락을 읽고 그 모든 것에 걸쳐 조치를 취할 수 있습니다. 수십 개의 탭 사이를 복사·붙여넣기할 필요가 없습니다. 무엇에 접근할 수 있는지는 당신이 통제합니다.', 'connections.welcome.ctaChannel': '채널 연결', 'connections.welcome.ctaApps': '앱 연결', 'connections.welcome.ctaSkills': '스킬 둘러보기', @@ -97,7 +97,7 @@ const messages: TranslationMap = { 'notifications.welcome.eyebrow': '알림', 'notifications.welcome.title': '정말로 당신이 필요한 것만', 'notifications.welcome.body': - '당신의 에이전트가 한 일과 결정이 필요한 일을 차분하게 점수화한 요약: 중요한 것은 드러나고 소음은 방해되지 않도록.', + '당신의 에이전트가 한 일과 결정이 필요한 일을 차분하게 점수화해 요약합니다. 중요한 것은 드러내고 소음은 방해되지 않게 합니다.', 'notifications.welcome.ctaView': '알림 보기', 'notifications.welcome.featsLabel': '보게 될 것', 'notifications.welcome.feat1Title': '당신이 필요한 것', @@ -112,7 +112,7 @@ const messages: TranslationMap = { 'rewards.welcome.eyebrow': '리워드', 'rewards.welcome.title': '함께해 준 것에 대한 보상', 'rewards.welcome.body': - 'OpenHuman을 사용하고 다른 사람을 초대하며 포인트를 모으고, 연속 기록을 이어가고, 모은 것을 사용하세요: 모두 한곳에서 추적됩니다.', + 'OpenHuman을 사용하고 다른 사람을 초대하며 포인트를 모으고, 연속 기록을 이어가고, 모은 것을 사용하세요. 모두 한곳에서 추적됩니다.', 'rewards.welcome.ctaView': '리워드 보기', 'rewards.welcome.featsLabel': '작동 방식', 'rewards.welcome.feat1Title': '포인트 적립', @@ -126,7 +126,7 @@ const messages: TranslationMap = { 'flows.welcome.eyebrow': '워크플로', 'flows.welcome.title': '반복 작업을 자동으로', 'flows.welcome.body': - '분류, 후속 조치, 요약처럼 당신이 반복해서 하는 일을 설명하면, 에이전트가 그것을 처음부터 끝까지 실행할 수 있는 워크플로로 만들어: 정해진 일정에 맞춰 또는 필요할 때 실행합니다.', + '분류, 후속 조치, 요약처럼 당신이 반복해서 하는 일을 설명하면, 에이전트가 그것을 처음부터 끝까지 실행할 수 있는 워크플로로 만들어, 정해진 일정에 맞춰 또는 필요할 때 실행합니다.', 'flows.welcome.ctaNew': '새 워크플로', 'flows.welcome.ctaBrowse': '워크플로 둘러보기', 'flows.welcome.featsLabel': '자동화할 수 있는 것', @@ -1108,7 +1108,7 @@ const messages: TranslationMap = { 'memory.tab.associations': 'Associations', 'entityAssociations.title': '엔티티 연관', 'entityAssociations.intro': - '많은 동일한 연결을 공유하는 엔티티들은 연관됩니다: 단일 사실이 직접 연결하지 않더라도. Jaccard 유사도가 이러한 숨겨진 연관을 드러냅니다.', + '많은 동일한 연결을 공유하는 엔티티들은 서로 연관됩니다. 단일 사실이 직접 연결하지 않더라도 Jaccard 유사도가 이러한 숨겨진 연관을 드러냅니다.', 'entityAssociations.loading': '연관 계산 중…', 'entityAssociations.errorPrefix': '그래프를 로드할 수 없습니다:', 'entityAssociations.retry': 'Retry', @@ -5601,7 +5601,7 @@ const messages: TranslationMap = { 'settings.appearance.modeSystem': '시스템과 일치', 'settings.appearance.modeSystemDesc': 'OS 외관 설정을 따릅니다.', 'settings.appearance.helperText': - '다크 모드는 전체 앱: 채팅, 설정, 패널: 을 어두운 팔레트로 전환합니다. "시스템과 일치"는 OS 외관을 따르며 실시간으로 업데이트됩니다.', + '다크 모드는 전체 앱(채팅, 설정, 패널)을 어두운 팔레트로 전환합니다. "시스템과 일치"는 OS 외관을 따르며 실시간으로 업데이트됩니다.', 'settings.appearance.fontSizeHeading': '글꼴 크기', 'settings.appearance.fontSizeAria': '글꼴 크기', 'settings.appearance.fontSizeSmall': '작게', @@ -5613,7 +5613,7 @@ const messages: TranslationMap = { 'settings.appearance.fontSizeXLarge': '아주 크게', 'settings.appearance.fontSizeXLargeDesc': '최대 가독성을 위한 가장 큰 텍스트.', 'settings.appearance.fontSizeHelperText': - '시스템 글꼴 설정과 관계없이 앱 전체: 채팅, 설정, 패널: 의 텍스트 크기를 조정합니다.', + '시스템 글꼴 설정과 관계없이 앱 전체(채팅, 설정, 패널)의 텍스트 크기를 조정합니다.', 'settings.appearance.fontSizeCustomLabel': '사용자 지정 크기', 'settings.appearance.fontSizeCustomAria': '픽셀 단위 사용자 지정 글꼴 크기', 'settings.appearance.fontSizeCustomSliderAria': '사용자 지정 글꼴 크기 슬라이더, 픽셀 단위', @@ -6753,7 +6753,7 @@ const messages: TranslationMap = { '어시스턴트가 당신에 관한 연결된 사실들을 기록함에 따라, 그 군집화 구조가 여기에 드러납니다.', 'graphCohesion.errorPrefix': '그래프를 불러올 수 없습니다:', 'graphCohesion.intro': - '각 엔티티 주변 이웃이 얼마나 촘촘히 엮여 있는지. 브로커: 이웃들이 서로 연결되지 않은 엔티티: 는 그렇지 않으면 분리되었을 클러스터를 묶어주는 단일 지점이며, 빈도나 PageRank 정렬로는 드러낼 수 없는 것입니다.', + '각 엔티티 주변 이웃이 얼마나 촘촘히 엮여 있는지 보여 줍니다. 브로커(이웃들이 서로 연결되지 않은 엔티티)는 서로 분리된 클러스터를 묶는 단일 지점이며, 빈도나 PageRank 정렬만으로는 드러나지 않습니다.', 'graphCohesion.loading': '응집도 계산 중…', 'graphCohesion.metricConnections': '연결', 'graphCohesion.metricEntities': '엔티티', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 8aa5c40ec6..06db2ea10f 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -7,7 +7,7 @@ const messages: TranslationMap = { 'agentWorld.welcome.eyebrow': 'TinyPlace', 'agentWorld.welcome.title': 'Świat, w którym spotykają się Twoi agenci', 'agentWorld.welcome.body': - 'TinyPlace to warstwa społecznościowa dla agentów AI: Twoi mogą odkrywać innych agentów, pisać do nich, podejmować zlecenia i handlować, wszystko w Twoim imieniu. Wejdź do tego świata i zobacz, co tam wyprawiają.', + 'TinyPlace to warstwa społecznościowa dla agentów AI: Twoi agenci mogą odkrywać innych agentów, pisać do nich, podejmować zlecenia i handlować w Twoim imieniu. Wejdź do tego świata i zobacz, co tam wyprawiają.', 'agentWorld.welcome.ctaWorld': 'Wejdź do świata', 'agentWorld.welcome.ctaFeed': 'Przeglądaj kanał', 'agentWorld.welcome.ctaDirectory': 'Znajdź agentów', @@ -81,7 +81,7 @@ const messages: TranslationMap = { 'connections.welcome.eyebrow': 'Połączenia', 'connections.welcome.title': 'Wszystko, czego używasz, w jednym miejscu', 'connections.welcome.body': - 'Połącz swoje aplikacje do wiadomości, pocztę, kalendarz i narzędzia, aby Twój agent mógł czytać kontekst i podejmować działania we wszystkich z nich: bez kopiowania i wklejania między kilkunastoma kartami. To Ty decydujesz, czego może dotknąć.', + 'Połącz swoje aplikacje do wiadomości, pocztę, kalendarz i narzędzia, aby Twój agent mógł czytać kontekst i podejmować działania we wszystkich z nich, bez kopiowania i wklejania między kilkunastoma kartami. To Ty decydujesz, czego może dotknąć.', 'connections.welcome.ctaChannel': 'Połącz kanał', 'connections.welcome.ctaApps': 'Połącz aplikacje', 'connections.welcome.ctaSkills': 'Przeglądaj umiejętności', @@ -101,7 +101,7 @@ const messages: TranslationMap = { 'notifications.welcome.eyebrow': 'Powiadomienia', 'notifications.welcome.title': 'Tylko to, co naprawdę Cię potrzebuje', 'notifications.welcome.body': - 'Spokojne, ocenione podsumowanie tego, co zrobili Twoi agenci i co wymaga decyzji: aby ważne rzeczy wypływały na wierzch, a szum nie wchodził Ci w drogę.', + 'Spokojne, ocenione podsumowanie tego, co zrobili Twoi agenci i co wymaga decyzji, aby ważne rzeczy wypływały na wierzch, a szum nie wchodził Ci w drogę.', 'notifications.welcome.ctaView': 'Zobacz powiadomienia', 'notifications.welcome.featsLabel': 'Co zobaczysz', 'notifications.welcome.feat1Title': 'Co Cię potrzebuje', @@ -119,7 +119,7 @@ const messages: TranslationMap = { 'rewards.welcome.eyebrow': 'Nagrody', 'rewards.welcome.title': 'Otrzymuj nagrody za obecność', 'rewards.welcome.body': - 'Zdobywaj punkty, korzystając z OpenHuman i zapraszając innych, utrzymuj swoją serię i wymieniaj to, co zdobyłeś: wszystko śledzone w jednym miejscu.', + 'Zdobywaj punkty, korzystając z OpenHuman i zapraszając innych, utrzymuj swoją serię i wymieniaj to, co zdobyłeś, a wszystko śledź w jednym miejscu.', 'rewards.welcome.ctaView': 'Zobacz nagrody', 'rewards.welcome.featsLabel': 'Jak to działa', 'rewards.welcome.feat1Title': 'Zdobywaj punkty', @@ -133,7 +133,7 @@ const messages: TranslationMap = { 'flows.welcome.eyebrow': 'Przepływy pracy', 'flows.welcome.title': 'Ustaw żmudną pracę na autopilocie', 'flows.welcome.body': - 'Opisz coś, co robisz w kółko: segregowanie, działania następcze, podsumowania: a Twój agent zamieni to w przepływ pracy, który może uruchomić od początku do końca, według harmonogramu lub na żądanie.', + 'Opisz coś, co robisz w kółko (segregowanie, działania następcze, podsumowania), a Twój agent zamieni to w przepływ pracy, który może uruchomić od początku do końca, według harmonogramu lub na żądanie.', 'flows.welcome.ctaNew': 'Nowy przepływ pracy', 'flows.welcome.ctaBrowse': 'Przeglądaj przepływy pracy', 'flows.welcome.featsLabel': 'Co możesz zautomatyzować', @@ -342,7 +342,7 @@ const messages: TranslationMap = { 'orchPage.medulla.title': 'Medulla', 'orchPage.medulla.tagline': 'Model orkiestracji OpenHuman', 'orchPage.medulla.body': - 'Medulla to autorski model LLM OpenHuman, zaprojektowany do orkiestracji tysięcy agentów jednocześnie: z oknem kontekstu o wielkości 10 milionów tokenów i radykalnie tanią orkiestracją.', + 'Medulla to autorski model LLM OpenHuman, zaprojektowany do orkiestracji tysięcy agentów jednocześnie, z oknem kontekstu o wielkości 10 milionów tokenów i radykalnie tanią orkiestracją.', 'orchPage.medulla.featAgents': 'Tysiące agentów', 'orchPage.medulla.featContext': 'Kontekst 10M tokenów', 'orchPage.medulla.featCost': 'Tania orkiestracja', @@ -1135,7 +1135,7 @@ const messages: TranslationMap = { 'memory.tab.associations': 'Associations', 'entityAssociations.title': 'Powiązania encji', 'entityAssociations.intro': - 'Encje dzielące wiele tych samych połączeń są powiązane: nawet gdy żaden pojedynczy fakt nie łączy ich bezpośrednio. Podobieństwo Jaccarda ujawnia te ukryte powiązania.', + 'Encje dzielące wiele tych samych połączeń są powiązane, nawet gdy żaden pojedynczy fakt nie łączy ich bezpośrednio. Podobieństwo Jaccarda ujawnia te ukryte powiązania.', 'entityAssociations.loading': 'Obliczanie powiązań…', 'entityAssociations.errorPrefix': 'Nie udało się załadować grafu:', 'entityAssociations.retry': 'Retry', @@ -1671,7 +1671,7 @@ const messages: TranslationMap = { 'settings.search.placeholderQuerit': 'Klucz API Querit', 'settings.search.allowedSitesLabel': 'Dozwolone witryny', 'settings.search.allowedSitesHint': - 'Hosty, które asystent może otwierać i odczytywać: poprzez pobieranie stron i narzędzie przeglądarki: jeden na linię, np. reuters.com. Host obejmuje również swoje subdomeny. Samo wyszukiwanie w internecie nie jest ograniczone przez tę listę.', + 'Hosty, które asystent może otwierać i odczytywać (poprzez pobieranie stron i narzędzie przeglądarki) jeden na linię, np. reuters.com. Host obejmuje również swoje subdomeny. Samo wyszukiwanie w internecie nie jest ograniczone przez tę listę.', 'settings.search.allowedSitesAllOn': 'Asystent może otworzyć dowolną publiczną witrynę. Adresy lokalne i prywatne pozostają zablokowane.', 'settings.search.allowedSitesPlaceholder': 'reuters.com\napnews.com\ngithub.com', @@ -2876,7 +2876,7 @@ const messages: TranslationMap = { 'workspace.vaultNotRegisteredHelp': 'Obsidian otwiera tylko foldery dodane jako sejf. W Obsidianie wybierz „Otwórz folder jako sejf” i wskaż folder poniżej: wystarczy to zrobić raz. Następnie ponownie kliknij Pokaż sejf.', 'workspace.obsidianNotFoundHelp': - 'Nie znaleziono Obsidiana na tym urządzeniu. Zainstaluj go lub: jeśli jest zainstalowany w niestandardowym miejscu: ustaw jego folder konfiguracyjny w sekcji Zaawansowane.', + 'Nie znaleziono Obsidiana na tym urządzeniu. Zainstaluj go lub (jeśli jest zainstalowany w niestandardowym miejscu) ustaw jego folder konfiguracyjny w sekcji Zaawansowane.', 'workspace.openAnyway': 'Otwórz w Obsidianie mimo to', 'workspace.installObsidian': 'Zainstaluj Obsidiana', 'workspace.obsidianAdvanced': 'Obsidian zainstalowany gdzie indziej?', @@ -5537,7 +5537,7 @@ const messages: TranslationMap = { 'settings.agentAccess.accessMode': 'Tryb dostępu', 'settings.agentAccess.tier.readonly.title': 'Tylko do odczytu', 'settings.agentAccess.tier.readonly.desc': - 'Czyta pliki i uruchamia polecenia tylko do odczytu, aby eksplorować: ale nigdy nie zapisuje, nie edytuje ani nie uruchamia niczego, co zmienia stan.', + 'Czyta pliki i uruchamia polecenia tylko do odczytu, aby eksplorować, ale nigdy nie zapisuje, nie edytuje ani nie uruchamia niczego, co zmienia stan.', 'settings.agentAccess.tier.supervised.title': 'Pytaj przed edycją', 'settings.agentAccess.tier.supervised.desc': 'Tworzy nowe pliki swobodnie, ale pyta o Twoją zgodę przed edycją istniejącego pliku, uruchomieniem polecenia, dostępem do sieci lub instalacją czegokolwiek.', @@ -5549,7 +5549,7 @@ const messages: TranslationMap = { '⚠ Pełny dostęp uruchamia polecenia z pełnymi uprawnieniami Twojego konta i nie jest sandboxowany. Włącz to tylko wtedy, gdy ufasz agentowi na tym komputerze. Katalogi poświadczeń i systemowe pozostają zablokowane, a akcje destrukcyjne, sieciowe i instalacyjne nadal proszą o zgodę.', 'settings.agentAccess.confine.label': 'Ogranicz do przestrzeni roboczej', 'settings.agentAccess.confine.desc': - 'Ogranicz agenta do katalogu przestrzeni roboczej (oraz dodanych folderów), niezależnie od wybranego trybu dostępu. Wyłączone: może sięgać wszędzie, gdzie Twój użytkownik, oprócz zawsze zablokowanych katalogów poświadczeń i systemowych.', + 'Ogranicz agenta do katalogu przestrzeni roboczej (oraz dodanych folderów), niezależnie od wybranego trybu dostępu. Po wyłączeniu tej opcji agent może sięgać wszędzie tam, gdzie ma dostęp Twój użytkownik, oprócz zawsze zablokowanych katalogów poświadczeń i systemowych.', 'settings.agentAccess.requireTaskPlanApproval.label': 'Wymagaj zatwierdzenia planu zadania', 'settings.agentAccess.requireTaskPlanApproval.desc': 'Wstrzymaj, zanim przypisany agent wykona opis zadania utworzony przez agenta.', @@ -5619,7 +5619,7 @@ const messages: TranslationMap = { 'Wybierz, ile swobody ma asystent podczas wykonywania działań na Twoim komputerze.', 'settings.permissions.preset.readonly.title': 'Patrzeć, nie dotykać', 'settings.permissions.preset.readonly.desc': - 'Asystent może czytać pliki i eksplorować: ale nigdy nie pisze, edytuje ani nie uruchamia niczego zmieniającego stan.', + 'Asystent może czytać pliki i eksplorować, ale nigdy nie pisze, edytuje ani nie uruchamia niczego zmieniającego stan.', 'settings.permissions.preset.supervised.title': 'Najpierw zapytaj', 'settings.permissions.preset.supervised.desc': 'Może swobodnie tworzyć nowe pliki, ale zawsze prosi o Twoją zgodę przed edycją, uruchamianiem poleceń lub dostępem do sieci.', @@ -5761,7 +5761,7 @@ const messages: TranslationMap = { 'settings.appearance.modeSystemDesc': 'Postępuj zgodnie z ustawieniem wyglądu Twojego systemu operacyjnego.', 'settings.appearance.helperText': - 'Tryb ciemny przełącza całą aplikację: czat, ustawienia, panele: na przyciemnioną paletę. „Dopasuj do systemu” podąża za wyglądem systemu i aktualizuje się na żywo.', + 'Tryb ciemny przełącza całą aplikację (czat, ustawienia, panele) na przyciemnioną paletę. „Dopasuj do systemu” podąża za wyglądem systemu i aktualizuje się na żywo.', 'settings.appearance.fontSizeHeading': 'Rozmiar czcionki', 'settings.appearance.fontSizeAria': 'Rozmiar czcionki', 'settings.appearance.fontSizeSmall': 'Mały', @@ -5773,7 +5773,7 @@ const messages: TranslationMap = { 'settings.appearance.fontSizeXLarge': 'Bardzo duży', 'settings.appearance.fontSizeXLargeDesc': 'Największy tekst dla maksymalnej czytelności.', 'settings.appearance.fontSizeHelperText': - 'Skaluje tekst w całej aplikacji: czat, ustawienia i panele: niezależnie od ustawienia czcionki w systemie.', + 'Skaluje tekst w całej aplikacji (czat, ustawienia i panele) niezależnie od ustawienia czcionki w systemie.', 'settings.appearance.fontSizeCustomLabel': 'Rozmiar niestandardowy', 'settings.appearance.fontSizeCustomAria': 'Niestandardowy rozmiar czcionki w pikselach', 'settings.appearance.fontSizeCustomSliderAria': @@ -5912,7 +5912,7 @@ const messages: TranslationMap = { 'Rozsądna wartość domyślna: dobra ciągłość bez nadmiernych tokenów na każde uruchomienie.', 'settings.memoryWindow.balanced.label': 'Zrównoważone', 'settings.memoryWindow.description': - 'Ile zapamiętanego kontekstu OpenHuman wstrzykuje do każdego nowego uruchomienia agenta. Większe okno daje większą świadomość poprzednich rozmów, ale używa więcej tokenów: i kosztuje więcej: przy każdym uruchomieniu.', + 'Ile zapamiętanego kontekstu OpenHuman wstrzykuje do każdego nowego uruchomienia agenta. Większe okno daje większą świadomość poprzednich rozmów, ale używa więcej tokenów (i kosztuje więcej) przy każdym uruchomieniu.', 'settings.memoryWindow.extended.badge': 'Więcej kontekstu', 'settings.memoryWindow.extended.hint': 'Więcej długoterminowej pamięci wstrzykiwanej do każdego uruchomienia. Wyższy koszt tokenów na turę.', @@ -6905,7 +6905,7 @@ const messages: TranslationMap = { 'W miarę jak asystent zapisuje powiązane fakty o Tobie, ich struktura klasteryzacji pojawi się tutaj.', 'graphCohesion.errorPrefix': 'Nie udało się załadować grafu:', 'graphCohesion.intro': - 'Jak ściśle spleciona jest okolica wokół każdej encji. Brokerzy: encje, których sąsiedzi nie są ze sobą połączeni: to pojedyncze punkty trzymające razem klastry, które inaczej byłyby oddzielne, czego sortowanie po częstotliwości ani PageRank nie ujawni.', + 'Ten widok pokazuje, jak ściśle splecione jest otoczenie każdej encji. Brokerzy (encje, których sąsiedzi nie są ze sobą połączeni) to pojedyncze punkty trzymające razem klastry, które inaczej byłyby oddzielne, czego sortowanie po częstotliwości ani PageRank nie ujawni.', 'graphCohesion.loading': 'Obliczanie spójności…', 'graphCohesion.metricConnections': 'Połączenia', 'graphCohesion.metricEntities': 'Encje', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 1e03f552c5..9aff9b3a57 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -80,7 +80,7 @@ const messages: TranslationMap = { 'connections.welcome.eyebrow': 'Ligações', 'connections.welcome.title': 'Tudo o que usa, num só lugar', 'connections.welcome.body': - 'Ligue as suas aplicações de mensagens, email, calendário e ferramentas para que o seu agente possa ler o contexto e agir em todas elas: sem copiar e colar entre uma dúzia de separadores. Você mantém o controlo daquilo em que ele pode tocar.', + 'Ligue as suas aplicações de mensagens, email, calendário e ferramentas para que o seu agente possa ler o contexto e agir em todas elas, sem copiar e colar entre uma dúzia de separadores. Você mantém o controlo daquilo em que ele pode tocar.', 'connections.welcome.ctaChannel': 'Ligar um canal', 'connections.welcome.ctaApps': 'Ligar aplicações', 'connections.welcome.ctaSkills': 'Explorar competências', @@ -100,7 +100,7 @@ const messages: TranslationMap = { 'notifications.welcome.eyebrow': 'Notificações', 'notifications.welcome.title': 'Apenas o que realmente precisa de si', 'notifications.welcome.body': - 'Um resumo calmo e classificado do que os seus agentes fizeram e do que precisa de uma decisão: para que o importante venha à tona e o ruído fique fora do seu caminho.', + 'Um resumo calmo e classificado do que os seus agentes fizeram e do que precisa de uma decisão, para que o importante venha à tona e o ruído fique fora do seu caminho.', 'notifications.welcome.ctaView': 'Ver alertas', 'notifications.welcome.featsLabel': 'O que vai ver', 'notifications.welcome.feat1Title': 'O que precisa de si', @@ -116,7 +116,7 @@ const messages: TranslationMap = { 'rewards.welcome.eyebrow': 'Recompensas', 'rewards.welcome.title': 'Seja recompensado por aparecer', 'rewards.welcome.body': - 'Ganhe pontos à medida que usa o OpenHuman e convida outros, mantenha a sua sequência viva e resgate o que ganhou: tudo registado num só lugar.', + 'Ganhe pontos à medida que usa o OpenHuman e convida outros, mantenha a sua sequência viva e resgate o que ganhou, tudo registado num só lugar.', 'rewards.welcome.ctaView': 'Ver recompensas', 'rewards.welcome.featsLabel': 'Como funciona', 'rewards.welcome.feat1Title': 'Ganhe pontos', @@ -130,7 +130,7 @@ const messages: TranslationMap = { 'flows.welcome.eyebrow': 'Fluxos de trabalho', 'flows.welcome.title': 'Coloque o trabalho repetitivo em piloto automático', 'flows.welcome.body': - 'Descreva algo que faz uma e outra vez: triagem, acompanhamentos, resumos: e o seu agente transforma-o num fluxo de trabalho que pode executar de ponta a ponta, num horário ou a pedido.', + 'Descreva algo que faz uma e outra vez (triagem, acompanhamentos, resumos) e o seu agente transforma-o num fluxo de trabalho que pode executar de ponta a ponta, de acordo com um horário ou a pedido.', 'flows.welcome.ctaNew': 'Novo fluxo de trabalho', 'flows.welcome.ctaBrowse': 'Explorar fluxos de trabalho', 'flows.welcome.featsLabel': 'O que pode automatizar', @@ -335,7 +335,7 @@ const messages: TranslationMap = { 'orchPage.medulla.title': 'Medulla', 'orchPage.medulla.tagline': 'O modelo de orquestração da OpenHuman', 'orchPage.medulla.body': - 'O Medulla é o LLM próprio da OpenHuman, projetado para orquestrar milhares de agentes de uma só vez: com uma janela de contexto de 10 milhões de tokens e uma orquestração de custo radicalmente baixo.', + 'O Medulla é o LLM próprio da OpenHuman, projetado para orquestrar milhares de agentes de uma só vez, com uma janela de contexto de 10 milhões de tokens e uma orquestração de custo radicalmente baixo.', 'orchPage.medulla.featAgents': 'Milhares de agentes', 'orchPage.medulla.featContext': 'Contexto de 10M de tokens', 'orchPage.medulla.featCost': 'Orquestração de baixo custo', @@ -1113,7 +1113,7 @@ const messages: TranslationMap = { 'namespaceOverview.truncated': 'Mostrando os {shown} principais de {total} namespaces.', 'graphCentrality.title': 'Centralidade do Grafo de Conhecimento', 'graphCentrality.intro': - 'O PageRank sobre seu grafo de memória revela os hubs que suportam carga: e as entidades conectoras que ligam clusters que, de outra forma, seriam separados, algo que uma contagem de frequência bruta não consegue revelar.', + 'O PageRank sobre seu grafo de memória revela os hubs que suportam carga e as entidades conectoras que ligam clusters que, de outra forma, seriam separados, algo que uma contagem de frequência bruta não consegue revelar.', 'graphCentrality.loading': 'Calculando centralidade…', 'graphCentrality.errorPrefix': 'Não foi possível carregar o gráfico:', 'graphCentrality.retry': 'Tentar novamente', @@ -1140,7 +1140,7 @@ const messages: TranslationMap = { 'memory.tab.associations': 'Associations', 'entityAssociations.title': 'Associações de Entidades', 'entityAssociations.intro': - 'Entidades que compartilham muitas conexões estão associadas: mesmo quando nenhum fato as liga diretamente. A similaridade de Jaccard revela esses pares ocultos.', + 'Entidades que compartilham muitas conexões estão associadas, mesmo quando nenhum fato as liga diretamente. A similaridade de Jaccard revela esses pares ocultos.', 'entityAssociations.loading': 'Calculando associações…', 'entityAssociations.errorPrefix': 'Não foi possível carregar o grafo:', 'entityAssociations.retry': 'Retry', @@ -1679,7 +1679,7 @@ const messages: TranslationMap = { 'settings.search.placeholderQuerit': 'Chave Querit API', 'settings.search.allowedSitesLabel': 'Sites permitidos', 'settings.search.allowedSitesHint': - 'Hosts que o assistente pode abrir e ler: via busca na web e ferramenta de navegador: um por linha, ex.: reuters.com. Um host também cobre seus subdomínios. A busca na web em si não é restringida por esta lista.', + 'Liste, um por linha, os hosts que o assistente pode abrir e ler (via busca na web e ferramenta de navegador), ex.: reuters.com. Um host também cobre seus subdomínios. A busca na web em si não é restringida por esta lista.', 'settings.search.allowedSitesAllOn': 'O assistente pode abrir qualquer site público. Endereços locais e privados permanecem bloqueados.', 'settings.search.allowedSitesPlaceholder': 'reuters.com\napnews.com\ngithub.com', @@ -2892,7 +2892,7 @@ const messages: TranslationMap = { 'workspace.vaultNotRegisteredHelp': 'O Obsidian só abre pastas adicionadas como vault. No Obsidian, escolha "Abrir pasta como vault" e selecione a pasta abaixo: você só precisa fazer isso uma vez. Depois clique em Ver Vault novamente.', 'workspace.obsidianNotFoundHelp': - 'Não encontramos o Obsidian neste dispositivo. Instale-o, ou: se estiver instalado em um local não padrão: defina sua pasta de configuração em Avançado.', + 'Não encontramos o Obsidian neste dispositivo. Instale-o, ou (se estiver instalado em um local não padrão) defina sua pasta de configuração em Avançado.', 'workspace.openAnyway': 'Abrir no Obsidian mesmo assim', 'workspace.installObsidian': 'Instalar Obsidian', 'workspace.obsidianAdvanced': 'Obsidian instalado em outro local?', @@ -5535,7 +5535,7 @@ const messages: TranslationMap = { 'settings.agentAccess.accessMode': 'Modo de acesso', 'settings.agentAccess.tier.readonly.title': 'Somente leitura', 'settings.agentAccess.tier.readonly.desc': - 'Lê arquivos e executa comandos apenas de leitura para explorar: mas nunca escreve, edita ou executa nada que altere o estado.', + 'Lê arquivos e executa comandos apenas de leitura para explorar, mas nunca escreve, edita ou executa nada que altere o estado.', 'settings.agentAccess.tier.supervised.title': 'Pergunte antes de editar', 'settings.agentAccess.tier.supervised.desc': 'Cria novos arquivos livremente, mas pede sua aprovação antes de editar um arquivo existente, executar um comando, acessar a rede ou instalar qualquer coisa.', @@ -5547,7 +5547,7 @@ const messages: TranslationMap = { '⚠ O acesso total executa comandos com acesso completo à sua conta e não é isolado. Só o habilite quando você confiar no agente com esta máquina. Diretórios de credenciais e do sistema continuam bloqueados, e ações destrutivas, de rede e de instalação ainda solicitam aprovação.', 'settings.agentAccess.confine.label': 'Confinar ao espaço de trabalho', 'settings.agentAccess.confine.desc': - 'Restringir o agente ao diretório de trabalho (mais quaisquer pastas concedidas), qualquer que seja o modo de acesso selecionado. Quando desligado, ele pode acessar qualquer lugar que seu usuário possa: exceto os diretórios de credenciais e do sistema que sempre são bloqueados.', + 'Restrinja o agente ao diretório de trabalho (mais quaisquer pastas concedidas), qualquer que seja o modo de acesso selecionado. Quando desligado, ele pode acessar qualquer lugar que seu usuário possa, exceto os diretórios de credenciais e do sistema que sempre são bloqueados.', 'settings.agentAccess.requireTaskPlanApproval.label': 'Exigir aprovação do plano de tarefas', 'settings.agentAccess.requireTaskPlanApproval.desc': 'Pausa antes que um agente designado execute um briefing de tarefa elaborado pelo agente.', @@ -5619,7 +5619,7 @@ const messages: TranslationMap = { 'Escolha quanta liberdade o assistente tem ao realizar ações no seu computador.', 'settings.permissions.preset.readonly.title': 'Olhar, não tocar', 'settings.permissions.preset.readonly.desc': - 'O assistente pode ler ficheiros e explorar: mas nunca escrever, editar ou executar qualquer coisa que mude o estado.', + 'O assistente pode ler ficheiros e explorar, mas nunca escrever, editar ou executar qualquer coisa que mude o estado.', 'settings.permissions.preset.supervised.title': 'Perguntar primeiro', 'settings.permissions.preset.supervised.desc': 'Pode criar novos ficheiros livremente, mas pede sempre a sua aprovação antes de editar, executar comandos ou aceder à rede.', @@ -5778,7 +5778,7 @@ const messages: TranslationMap = { 'settings.appearance.fontSizeXLarge': 'Extra grande', 'settings.appearance.fontSizeXLargeDesc': 'O maior texto, para máxima legibilidade.', 'settings.appearance.fontSizeHelperText': - 'Dimensiona o texto em todo o app: chat, configurações e painéis: independentemente da configuração de fonte do seu sistema.', + 'Dimensiona o texto em todo o app (chat, configurações e painéis) independentemente da configuração de fonte do seu sistema.', 'settings.appearance.fontSizeCustomLabel': 'Tamanho personalizado', 'settings.appearance.fontSizeCustomAria': 'Tamanho da fonte personalizado em pixels', 'settings.appearance.fontSizeCustomSliderAria': @@ -5919,7 +5919,7 @@ const messages: TranslationMap = { 'Padrão sensato: boa continuidade sem queimar tokens extras em cada execução.', 'settings.memoryWindow.balanced.label': 'Balanceado', 'settings.memoryWindow.description': - 'Quanto contexto lembrado o OpenHuman injeta em cada nova execução do agente. Janelas maiores parecem mais cientes de conversas passadas, mas usam mais tokens: e custam mais: a cada execução.', + 'A quantidade de contexto que o OpenHuman injeta em cada nova execução do agente. Janelas maiores parecem mais cientes de conversas passadas, mas usam mais tokens e custam mais a cada execução.', 'settings.memoryWindow.extended.badge': 'Mais contexto', 'settings.memoryWindow.extended.hint': 'Mais memória de longo prazo injetada em cada execução. Custo maior por turno.', @@ -6944,7 +6944,7 @@ const messages: TranslationMap = { 'À medida que o assistente registra fatos conectados sobre você, a estrutura de agrupamento aparecerá aqui.', 'graphCohesion.errorPrefix': 'Não foi possível carregar o grafo:', 'graphCohesion.intro': - 'Quão fortemente entrelaçada é a vizinhança ao redor de cada entidade. Intermediadores: entidades cujos vizinhos não estão ligados entre si: são os pontos únicos que mantêm grupos separados unidos, algo que uma ordenação por frequência ou PageRank não pode revelar.', + 'Esta métrica mede o grau de interligação da vizinhança em torno de cada entidade. Intermediadores (entidades cujos vizinhos não estão ligados entre si) são os pontos únicos que mantêm grupos separados unidos, algo que uma ordenação por frequência ou PageRank não pode revelar.', 'graphCohesion.loading': 'Calculando coesão…', 'graphCohesion.metricConnections': 'Conexões', 'graphCohesion.metricEntities': 'Entidades', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index b0f648bfda..980bc5c830 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -78,7 +78,7 @@ const messages: TranslationMap = { 'connections.welcome.eyebrow': 'Подключения', 'connections.welcome.title': 'Всё, чем вы пользуетесь, в одном месте', 'connections.welcome.body': - 'Подключите свои мессенджеры, почту, календарь и инструменты, чтобы ваш агент мог читать контекст и действовать во всех них: без копирования между десятком вкладок. Вы контролируете, к чему у него есть доступ.', + 'Подключите свои мессенджеры, почту, календарь и инструменты, чтобы ваш агент мог читать контекст и действовать во всех них без копирования между десятком вкладок. Вы контролируете, к чему у него есть доступ.', 'connections.welcome.ctaChannel': 'Подключить канал', 'connections.welcome.ctaApps': 'Подключить приложения', 'connections.welcome.ctaSkills': 'Смотреть навыки', @@ -98,7 +98,7 @@ const messages: TranslationMap = { 'notifications.welcome.eyebrow': 'Уведомления', 'notifications.welcome.title': 'Только то, что действительно требует вас', 'notifications.welcome.body': - 'Спокойная, оценённая по важности сводка того, что сделали ваши агенты и что требует решения: чтобы важное всплывало, а шум не мешал.', + 'Спокойная, оценённая по важности сводка того, что сделали ваши агенты и что требует решения, чтобы важное всплывало, а шум не мешал.', 'notifications.welcome.ctaView': 'Смотреть уведомления', 'notifications.welcome.featsLabel': 'Что вы увидите', 'notifications.welcome.feat1Title': 'Что требует вас', @@ -114,7 +114,7 @@ const messages: TranslationMap = { 'rewards.welcome.eyebrow': 'Награды', 'rewards.welcome.title': 'Получайте награды за активность', 'rewards.welcome.body': - 'Зарабатывайте баллы, пользуясь OpenHuman и приглашая других, поддерживайте свою серию и обменивайте заработанное: всё отслеживается в одном месте.', + 'Зарабатывайте баллы, пользуясь OpenHuman и приглашая других, поддерживайте свою серию и обменивайте заработанное. Всё отслеживается в одном месте.', 'rewards.welcome.ctaView': 'Смотреть награды', 'rewards.welcome.featsLabel': 'Как это работает', 'rewards.welcome.feat1Title': 'Зарабатывайте баллы', @@ -128,7 +128,7 @@ const messages: TranslationMap = { 'flows.welcome.eyebrow': 'Рабочие процессы', 'flows.welcome.title': 'Переведите рутину на автопилот', 'flows.welcome.body': - 'Опишите то, что вы делаете снова и снова: сортировку, напоминания, сводки: и ваш агент превратит это в рабочий процесс, который сможет выполнять от начала до конца, по расписанию или по запросу.', + 'Опишите то, что вы делаете снова и снова (сортировку, напоминания, сводки), и ваш агент превратит это в рабочий процесс, который сможет выполнять от начала до конца, по расписанию или по запросу.', 'flows.welcome.ctaNew': 'Новый рабочий процесс', 'flows.welcome.ctaBrowse': 'Смотреть рабочие процессы', 'flows.welcome.featsLabel': 'Что можно автоматизировать', @@ -337,7 +337,7 @@ const messages: TranslationMap = { 'orchPage.medulla.title': 'Medulla', 'orchPage.medulla.tagline': 'Модель оркестрации OpenHuman', 'orchPage.medulla.body': - 'Medulla: это собственная большая языковая модель OpenHuman, спроектированная для оркестрации тысяч агентов одновременно: с окном контекста в 10 миллионов токенов и радикально низкой стоимостью оркестрации.', + 'Medulla представляет собой собственную большую языковую модель OpenHuman, спроектированную для оркестрации тысяч агентов одновременно, с окном контекста в 10 миллионов токенов и радикально низкой стоимостью оркестрации.', 'orchPage.medulla.featAgents': 'Тысячи агентов', 'orchPage.medulla.featContext': 'Контекст на 10M токенов', 'orchPage.medulla.featCost': 'Недорогая оркестрация', @@ -1102,7 +1102,7 @@ const messages: TranslationMap = { 'namespaceOverview.truncated': 'Показаны топ-{shown} из {total} пространств имён.', 'graphCentrality.title': 'Централизованность графа знаний', 'graphCentrality.intro': - 'PageRank по вашему графику памяти отображает несущие нагрузку концентраторы: и объекты-соединители, которые связывают отдельные кластеры, которые не может выявить необработанный подсчет частоты.', + 'PageRank по вашему графику памяти отображает несущие нагрузку концентраторы и объекты-соединители, которые связывают отдельные кластеры, которые не может выявить необработанный подсчет частоты.', 'graphCentrality.loading': 'Вычислительная центральность…', 'graphCentrality.errorPrefix': 'Не удалось загрузить график:', 'graphCentrality.retry': 'Повторить попытку', @@ -1129,7 +1129,7 @@ const messages: TranslationMap = { 'memory.tab.associations': 'Associations', 'entityAssociations.title': 'Ассоциации сущностей', 'entityAssociations.intro': - 'Сущности, разделяющие множество одинаковых связей, ассоциированы: даже если ни один факт не связывает их напрямую. Сходство Жаккара выявляет эти скрытые связи.', + 'Сущности, разделяющие множество одинаковых связей, ассоциированы, даже если ни один факт не связывает их напрямую. Сходство Жаккара выявляет эти скрытые связи.', 'entityAssociations.loading': 'Вычисление ассоциаций…', 'entityAssociations.errorPrefix': 'Не удалось загрузить граф:', 'entityAssociations.retry': 'Retry', @@ -1662,7 +1662,7 @@ const messages: TranslationMap = { 'settings.search.placeholderQuerit': 'Запросить ключ API', 'settings.search.allowedSitesLabel': 'Разрешенные веб-сайты', 'settings.search.allowedSitesHint': - 'Хосты, которые ассистент может открывать и читать: через веб-запросы и браузерный инструмент: по одному на строку, например reuters.com. Хост также охватывает все его поддомены. Веб-поиск не ограничивается этим списком.', + 'Хосты, которые ассистент может открывать и читать (через веб-запросы и браузерный инструмент) по одному на строку, например reuters.com. Хост также охватывает все его поддомены. Веб-поиск не ограничивается этим списком.', 'settings.search.allowedSitesAllOn': 'Помощник может открыть любой общедоступный веб-сайт. Локальные и частные адреса остаются заблокированными.', 'settings.search.allowedSitesPlaceholder': 'reuters.com\napnews.com\ngithub.com', @@ -2865,7 +2865,7 @@ const messages: TranslationMap = { 'workspace.vaultNotRegisteredHelp': 'Obsidian открывает только папки, добавленные как хранилище. В Obsidian выберите «Открыть папку как хранилище» и укажите папку ниже: это нужно сделать только один раз. Затем нажмите «Просмотр хранилища» снова.', 'workspace.obsidianNotFoundHelp': - 'Obsidian не найден на этом устройстве. Установите его или: если он установлен в нестандартном месте: укажите папку конфигурации в разделе «Дополнительно».', + 'Obsidian не найден на этом устройстве. Установите его или (если он установлен в нестандартном месте) укажите папку конфигурации в разделе «Дополнительно».', 'workspace.openAnyway': 'Всё равно открыть в Obsidian', 'workspace.installObsidian': 'Установить Obsidian', 'workspace.obsidianAdvanced': 'Obsidian установлен в другом месте?', @@ -5592,7 +5592,7 @@ const messages: TranslationMap = { 'Выберите, насколько свободен помощник при выполнении действий на вашем компьютере.', 'settings.permissions.preset.readonly.title': 'Смотреть, не трогать', 'settings.permissions.preset.readonly.desc': - 'Помощник может читать файлы и исследовать систему: но никогда не пишет, не редактирует и не запускает ничего, что изменяет состояние.', + 'Помощник может читать файлы и исследовать систему, но никогда не пишет, не редактирует и не запускает ничего, что изменяет состояние.', 'settings.permissions.preset.supervised.title': 'Сначала спросить', 'settings.permissions.preset.supervised.desc': 'Может свободно создавать новые файлы, но всегда запрашивает ваше одобрение перед редактированием, выполнением команд или доступом к сети.', @@ -5729,10 +5729,10 @@ const messages: TranslationMap = { 'settings.appearance.modeDark': 'Темный', 'settings.appearance.modeDarkDesc': 'Тусклые поверхности, меньше раздражают глаза после наступления сумерек.', - 'settings.appearance.modeSystem': 'Система соответствия', + 'settings.appearance.modeSystem': 'Системный режим', 'settings.appearance.modeSystemDesc': 'Следуйте настройкам внешнего вида вашей ОС.', 'settings.appearance.helperText': - 'Темный режим переключает все приложение: чат, настройки, панели: на тусклую палитру. «Система сопоставления» следит за внешним видом вашей ОС и обновляет ее в реальном времени.', + 'Темный режим переключает все приложение (чат, настройки, панели) на тусклую палитру. «Системный режим» следит за внешним видом вашей ОС и обновляет ее в реальном времени.', 'settings.appearance.fontSizeHeading': 'Размер шрифта', 'settings.appearance.fontSizeAria': 'Размер шрифта', 'settings.appearance.fontSizeSmall': 'Маленький', @@ -5744,7 +5744,7 @@ const messages: TranslationMap = { 'settings.appearance.fontSizeXLarge': 'Очень большой', 'settings.appearance.fontSizeXLargeDesc': 'Самый крупный текст для максимальной читаемости.', 'settings.appearance.fontSizeHelperText': - 'Масштабирует текст во всём приложении: чат, настройки и панели: независимо от системных настроек шрифта.', + 'Масштабирует текст во всём приложении (чат, настройки и панели) независимо от системных настроек шрифта.', 'settings.appearance.fontSizeCustomLabel': 'Пользовательский размер', 'settings.appearance.fontSizeCustomAria': 'Пользовательский размер шрифта в пикселях', 'settings.appearance.fontSizeCustomSliderAria': @@ -5886,7 +5886,7 @@ const messages: TranslationMap = { 'Разумное значение по умолчанию: хорошая непрерывность без лишних трат токенов на каждом запуске.', 'settings.memoryWindow.balanced.label': 'Сбалансированный', 'settings.memoryWindow.description': - 'Сколько запомненного контекста OpenHuman добавляет в каждый новый запуск агента. Более широкие окна дают ощущение лучшей памяти о прошлых разговорах, но используют больше токенов: и стоят дороже: на каждом запуске.', + 'Сколько запомненного контекста OpenHuman добавляет в каждый новый запуск агента. Более широкие окна дают ощущение лучшей памяти о прошлых разговорах, но используют больше токенов (и стоят дороже) на каждом запуске.', 'settings.memoryWindow.extended.badge': 'Больше контекста', 'settings.memoryWindow.extended.hint': 'Больше долгосрочной памяти на каждый запуск. Выше расход токенов за ход.', @@ -6910,7 +6910,7 @@ const messages: TranslationMap = { 'По мере того как ассистент фиксирует связанные факты о вас, здесь появится их кластерная структура.', 'graphCohesion.errorPrefix': 'Не удалось загрузить граф:', 'graphCohesion.intro': - 'Насколько плотно сплетено окружение каждой сущности. Брокеры: сущности, чьи соседи не связаны друг с другом,: это единичные точки, удерживающие вместе иначе разделённые кластеры, чего сортировка по частоте или PageRank не вскроет.', + 'Этот показатель отражает, насколько плотно сплетено окружение каждой сущности. Брокеры (сущности, чьи соседи не связаны друг с другом) представляют собой единичные точки, удерживающие вместе иначе разделённые кластеры, чего сортировка по частоте или PageRank не вскроет.', 'graphCohesion.loading': 'Вычисление связности…', 'graphCohesion.metricConnections': 'Связи', 'graphCohesion.metricEntities': 'Сущности', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 393b99c3c2..b799817bd3 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -119,7 +119,7 @@ const messages: TranslationMap = { 'flows.welcome.eyebrow': '工作流', 'flows.welcome.title': '让繁琐工作自动运行', 'flows.welcome.body': - '描述一件你反复要做的事情:分类处理、跟进、摘要:你的智能体就会把它变成一个可以端到端运行的工作流,可按计划或按需执行。', + '描述一件你反复要做的事情(例如分类处理、跟进或生成摘要),你的智能体就会把它变成一个可以端到端运行的工作流,可按计划或按需执行。', 'flows.welcome.ctaNew': '新建工作流', 'flows.welcome.ctaBrowse': '浏览工作流', 'flows.welcome.featsLabel': '你可以自动化什么', @@ -5368,7 +5368,7 @@ const messages: TranslationMap = { 'settings.appearance.modeSystem': '跟随系统', 'settings.appearance.modeSystemDesc': '跟随操作系统外观设置。', 'settings.appearance.helperText': - '深色模式会将整个应用:聊天、设置、面板:切换为暗色调。"跟随系统"会同步你的操作系统外观并实时更新。', + '深色模式会将整个应用(包括聊天、设置和面板)切换为暗色调。"跟随系统"会同步你的操作系统外观并实时更新。', 'settings.appearance.fontSizeHeading': '字体大小', 'settings.appearance.fontSizeAria': '字体大小', 'settings.appearance.fontSizeSmall': '小', @@ -5380,7 +5380,7 @@ const messages: TranslationMap = { 'settings.appearance.fontSizeXLarge': '超大', 'settings.appearance.fontSizeXLargeDesc': '最大的文字,可读性最佳。', 'settings.appearance.fontSizeHelperText': - '在整个应用中缩放文字:聊天、设置和面板:与系统字体设置无关。', + '在整个应用(包括聊天、设置和面板)中缩放文字,不受系统字体设置影响。', 'settings.appearance.fontSizeCustomLabel': '自定义大小', 'settings.appearance.fontSizeCustomAria': '自定义字体大小(像素)', 'settings.appearance.fontSizeCustomSliderAria': '自定义字体大小滑块(像素)', @@ -6460,7 +6460,7 @@ const messages: TranslationMap = { 'graphCohesion.emptyHint': '随着助手记录有关你的相互关联的事实,它们的聚类结构将在此呈现。', 'graphCohesion.errorPrefix': '无法加载图:', 'graphCohesion.intro': - '每个实体周围的邻域织得有多紧。经纪者:其邻居彼此之间没有连接的实体:是把本应分离的聚类绑在一起的单点,这是频率或 PageRank 排序无法揭示的。', + '每个实体周围的邻域织得有多紧。经纪者(其邻居彼此之间没有连接的实体)是把本应分离的聚类连接起来的关键节点,这是频率或 PageRank 排序无法揭示的。', 'graphCohesion.loading': '正在计算凝聚度…', 'graphCohesion.metricConnections': '连接', 'graphCohesion.metricEntities': '实体', From f7f56da68476ed788cc14860688301d4c3cc2817 Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:41:41 +0530 Subject: [PATCH 38/86] feat(web3): compile-time web3 feature gate for wallet/web3/x402 (#4802) (#4855) Co-authored-by: M3gA-Mind Co-authored-by: Steven Enamakel --- AGENTS.md | 3 + Cargo.toml | 20 +- app/src-tauri/Cargo.toml | 3 + src/core/all_tests.rs | 47 +++ .../tools/impl/network/http_request.rs | 11 +- .../tools/impl/network/polymarket.rs | 52 ++- src/openhuman/tools/mod.rs | 1 + src/openhuman/tools/ops.rs | 12 +- src/openhuman/wallet/mod.rs | 47 ++- src/openhuman/wallet/stub.rs | 350 ++++++++++++++++++ src/openhuman/web3/mod.rs | 42 ++- src/openhuman/web3/stub.rs | 52 +++ src/openhuman/x402/mod.rs | 33 +- src/openhuman/x402/stub.rs | 58 +++ 14 files changed, 722 insertions(+), 9 deletions(-) create mode 100644 src/openhuman/wallet/stub.rs create mode 100644 src/openhuman/web3/stub.rs create mode 100644 src/openhuman/x402/stub.rs diff --git a/AGENTS.md b/AGENTS.md index b279ed219f..731e96b7f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -233,12 +233,15 @@ GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml \ | Feature | Default | Gates | Drops deps | | ------- | ------- | ----- | ---------- | | `voice` | ON | `openhuman::voice` + `openhuman::audio_toolkit` domains — STT/TTS providers, dictation server, always-on listening, podcast audio + email | `hound`, `lettre` | +| `web3` | ON | `openhuman::wallet` + `openhuman::web3` + `openhuman::x402` domains — crypto wallet (multi-chain sign/broadcast), swaps/bridges/dapp calls, x402 machine payments | `bitcoin`, `curve25519-dalek` | | `media` | ON | `openhuman::media_generation` (the `media_generate_*` agent tools) + `openhuman::image` scaffold | none (surface-only) | **Facade pattern (pathfinder for the other gates).** `pub mod voice;` is **always compiled** as a facade: the real submodules are `#[cfg(feature = "voice")]`, and a `#[cfg(not(feature = "voice"))] mod stub;` (`src/openhuman/voice/stub.rs`) re-exposes the same public surface that always-on / other-gated callers use (`server`, `dictation_listener`, `streaming`, `reply_speech`, `cloud_transcribe`, `cli`, `create_stt_provider`, `effective_stt_provider`, `publish_ptt_transcript_committed`) with no-op / `None` / disabled-error bodies. Callers therefore do **not** need per-call `#[cfg]`. When voice is off: the voice/audio controllers are unregistered (unknown-method over `/rpc`, absent from `/schema`), the `audio_generate_podcast` agent tools are absent, and `openhuman voice` returns a "voice disabled" error. Stub signatures must match the real ones exactly — the disabled build (`--no-default-features --features tokenjuice-treesitter`) is the **only** thing that catches drift, so run it before pushing any change to the voice surface. **Scope note:** the `voice` gate does **not** drop `whisper-rs` / `llama` / `cpal`. Those live in the inference domain (`src/openhuman/inference/local/service/whisper_engine.rs`; `cpal` is shared with accessibility) and await a separate future `inference` gate. The issue-level DoD line claiming whisper is dropped is superseded by this scope correction. +**`web3` gate — first gate that sheds real crypto deps.** Same facade pattern: `pub mod wallet;` / `pub mod web3;` / `pub mod x402;` stay always-compiled, real submodules are `#[cfg(feature = "web3")]`, and each domain's `stub.rs` re-exposes the always-on caller surface with disabled-error / empty bodies. When off, the wallet/web3/x402 controllers are unregistered, the web3 swap/bridge/dapp agent tools are absent (via `all_web3_agent_tools()` → empty), and the exclusive `bitcoin` (BTC P2WPKH PSBT) + `curve25519-dalek` (Solana off-curve ATA) deps are dropped. **tinyplace on-chain payments + Polymarket writes degrade to graceful "wallet disabled" errors** (the tinyplace comms path and the core itself are unaffected — `tinyplace::signer` still works via ed25519). The stubs cover `WALLET_NOT_CONFIGURED_MESSAGE`, `status`, `secret_material`, `WalletChain`, `prepare_transfer`/`execute_prepared` (+ param/result types), `solana_cluster`/`SolanaCluster`/`tinyplace_solana_rpc_endpoints`, `tinyplace_signer_seed`, `wallet::rpc::{redact_rpc_url, with_tinyplace_solana_endpoints}`, and the `all_*_registered_controllers`/`all_*_controller_schemas`/`all_web3_agent_tools` entry points. Two caller families still need per-call `#[cfg(feature = "web3")]` because they name concrete gated types rather than a stubbable aggregator: the six `Wallet*Tool` + `X402RequestTool` registrations in `tools/ops.rs`, the `wallet::tools::*` glob in `tools/mod.rs`, and the x402 402-retry path in `tools/impl/network/http_request.rs` (with the feature off a 402 returns to the caller unpaid). **Does NOT drop `ethers-core` / `ethers-signers` / `coins-bip39` / `bs58` / `ed25519-dalek` / `ripemd`** — those are shared with the Polymarket tools (`tools/impl/network/polymarket*`, `clob_auth`) + tinyplace + orchestration and stay always-on. Run the disabled build (`--no-default-features --features tokenjuice-treesitter`) before pushing any change to the wallet/web3/x402 surface — it is the only drift catcher. + **Leaf-gate variant (`media`, #4804).** Unlike `voice`, the `media` gate needs **no** stub facade: `media_generation` has a single caller (the `build_media_tools` call in `src/openhuman/tools/ops.rs`, itself `#[cfg(feature = "media")]`) and `openhuman::image` is unwired scaffold (#2997), so both modules are simply `#[cfg(feature = "media")] pub mod …`. It is a **surface-only** gate: media generation is backend-proxied (`reqwest`, shared) and the `image` crate is shared with channel upload, so no exclusive deps are shed — the issue's "sheds media processing dependencies" / "controllers unregistered" DoD lines are superseded (Media is agent-tools-only; no controller/store/subscriber is tagged `Media`). When a gated domain is a true leaf, prefer this over the facade+stub. ### Event bus (`src/core/event_bus/`) diff --git a/Cargo.toml b/Cargo.toml index 3312907407..c5e73ffce2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -250,7 +250,7 @@ ethers-signers = { version = "2.0.14", default-features = false } # - 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"] } +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" @@ -260,7 +260,7 @@ ripemd = "0.1" # off the recovery phrase without going through the EVM signer wrapper. coins-bip39 = "0.8" # Solana off-curve check for ATA derivation (find_program_address). -curve25519-dalek = { version = "4", default-features = false, features = ["alloc"] } +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" @@ -341,7 +341,7 @@ tokio = { version = "1", features = ["test-util"] } proptest = "1" [features] -default = ["tokenjuice-treesitter", "voice", "media"] +default = ["tokenjuice-treesitter", "voice", "web3", "media"] # AST-aware code compression (tree-sitter Rust/TS/Python grammars; C build). # On by default; disable to fall back to the brace-depth heuristic. tokenjuice-treesitter = [ @@ -358,6 +358,20 @@ tokenjuice-treesitter = [ # inference domain (shared with accessibility for cpal) and await a separate # `inference` gate. voice = ["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 +# always ships with the wallet. Slim / headless builds opt out via +# `--no-default-features --features ""`, which also +# drops the exclusive `bitcoin` (P2WPKH PSBT) + `curve25519-dalek` (Solana +# off-curve ATA) dependencies. Composes with the runtime `DomainSet::Web3` flag +# (#4796): the feature narrows the compile-time surface, `DomainSet` gates it at +# runtime. NOTE: this gate does NOT drop ethers-core / ethers-signers / +# coins-bip39 / bs58 / ed25519-dalek / ripemd — those are shared with the +# Polymarket tools + tinyplace on-chain payments + orchestration and stay +# always-on. When off, tinyplace payments + Polymarket writes degrade to +# graceful "wallet disabled" errors via the wallet/web3/x402 stub facades. +web3 = ["dep:bitcoin", "dep:curve25519-dalek"] # Media-generation + image domains: the `media_generate_*` agent tools # (image/video via GMI through the backend) and the `openhuman::image` tool # contracts scaffold. Default-ON. Slim builds opt out via diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index cda89b62c1..4fbdab7e8b 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -150,12 +150,15 @@ cef = { version = "=146.4.1", default-features = false } # - `media` — re-registers the `media_generate_*` agent tools that #4804 moved # behind `#[cfg(feature = "media")]`; it sheds no deps, so this only restores # the pre-gate desktop tool surface. +# - `web3` — keeps the wallet/web3/x402 domains and their agent tools in the +# desktop build while allowing slim builds to omit the crypto-only deps. # # `tokenjuice-treesitter` is the remaining un-forwarded default — tracked in # #4918, deliberately not bundled here because dropping it may be intentional. openhuman_core = { path = "../..", package = "openhuman", default-features = false, features = [ "media", "voice", + "web3", ] } tinyjuice = { version = "0.2.1", default-features = false } diff --git a/src/core/all_tests.rs b/src/core/all_tests.rs index 08412d4d26..b8980b2f33 100644 --- a/src/core/all_tests.rs +++ b/src/core/all_tests.rs @@ -234,6 +234,52 @@ fn voice_and_audio_controllers_absent_when_feature_off() { ); } +/// With the `web3` feature on (the default), the wallet + web3 + x402 +/// controllers are compiled in and registered, and the high-level web3 agent +/// tools (swap/bridge/dapp) are present — the desktop build is byte-identical. +#[test] +#[cfg(feature = "web3")] +fn wallet_web3_x402_controllers_registered_when_feature_on() { + let schemas = all_controller_schemas(); + assert!( + schemas.iter().any(|s| s.namespace == "wallet"), + "wallet controllers must be registered when the `web3` feature is on" + ); + assert!( + schemas.iter().any(|s| s.namespace.starts_with("web3_")), + "web3 (swap/bridge/dapp) controllers must be registered when the `web3` feature is on" + ); + assert!( + schemas.iter().any(|s| s.namespace == "x402"), + "x402 controllers must be registered when the `web3` feature is on" + ); + assert!( + !crate::openhuman::web3::all_web3_agent_tools().is_empty(), + "web3 agent tools must be present when the `web3` feature is on" + ); +} + +/// With the `web3` feature off, all three domains are compiled out: their +/// controllers never enter the registry (wallet/web3/x402 RPC methods are +/// unknown-method and absent from `/schema`) and the web3 agent tools are +/// gone. This is the compile-time stub-facade correctness gate (see +/// `openhuman::{wallet,web3,x402}::stub`). +#[test] +#[cfg(not(feature = "web3"))] +fn wallet_web3_x402_controllers_absent_when_feature_off() { + let schemas = all_controller_schemas(); + assert!( + !schemas.iter().any(|s| s.namespace == "wallet" + || s.namespace.starts_with("web3_") + || s.namespace == "x402"), + "wallet/web3/x402 controllers must be compiled out when the `web3` feature is off" + ); + assert!( + crate::openhuman::web3::all_web3_agent_tools().is_empty(), + "web3 agent tools must be gone when the `web3` feature is off" + ); +} + #[test] fn schema_for_rpc_method_finds_known_method() { let schema = schema_for_rpc_method("openhuman.health_snapshot"); @@ -861,6 +907,7 @@ fn group_mapping_smoke() { assert_eq!(group_for_namespace("flows"), Some(DomainGroup::Flows)); assert_eq!(group_for_namespace("skills"), Some(DomainGroup::Skills)); assert_eq!(group_for_namespace("voice"), Some(DomainGroup::Voice)); + #[cfg(feature = "web3")] assert_eq!(group_for_namespace("wallet"), Some(DomainGroup::Web3)); assert_eq!(group_for_namespace("meet"), Some(DomainGroup::Meet)); // Internal-only registry is grouped too (mcp_audit → Mcp). diff --git a/src/openhuman/tools/impl/network/http_request.rs b/src/openhuman/tools/impl/network/http_request.rs index 8684bb58c3..329525b960 100644 --- a/src/openhuman/tools/impl/network/http_request.rs +++ b/src/openhuman/tools/impl/network/http_request.rs @@ -3,6 +3,8 @@ use crate::openhuman::config::HttpRequestConfig; use crate::openhuman::security::{CommandClass, GateDecision, SecurityPolicy}; use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; use async_trait::async_trait; +// Only used by the `web3`-gated x402 402-retry path below. +#[cfg(feature = "web3")] use base64::engine::Engine as _; use serde_json::json; use std::sync::Arc; @@ -161,6 +163,11 @@ impl HttpRequestTool { Ok(request.send().await?) } + // x402 machine-payment retry — gated with the `web3` feature (the x402 + // domain, its ledger, and the `SettlementResponse`/`PaymentRecord` types + // are compiled out when web3 is disabled). With the feature off, a 402 is + // returned to the caller unpaid (see the call site). + #[cfg(feature = "web3")] async fn handle_x402_payment( &self, _initial_response: reqwest::Response, @@ -433,7 +440,9 @@ impl Tool for HttpRequestTool { }; // x402: if the server returns 402 with a PAYMENT-REQUIRED header, - // attempt to pay using the wallet's Solana key and retry. + // attempt to pay using the wallet's Solana key and retry. Compiled out + // when the `web3` feature is off — a 402 then passes through unpaid. + #[cfg(feature = "web3")] let response = if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED && (response.headers().get("PAYMENT-REQUIRED").is_some() || response.headers().get("X-PAYMENT-REQUIRED").is_some()) diff --git a/src/openhuman/tools/impl/network/polymarket.rs b/src/openhuman/tools/impl/network/polymarket.rs index 0da511fc46..535e2ceaa2 100644 --- a/src/openhuman/tools/impl/network/polymarket.rs +++ b/src/openhuman/tools/impl/network/polymarket.rs @@ -1419,6 +1419,56 @@ fn parse_nonce_value(value: &Value) -> Option { } } -#[cfg(test)] +// These tests seed a real wallet (`wallet::setup` + `WalletAccount` with +// `derivation_path`) to exercise wallet-signed Polymarket CLOB writes, so they +// require the `web3` feature. With web3 off the wallet is compiled out and the +// Polymarket write path degrades to a "wallet disabled" error (via the wallet +// stub), so there is nothing here to test. The tool itself still compiles in +// both configs against the stub — only these signing tests are gated. +#[cfg(all(test, feature = "web3"))] #[path = "polymarket_tests.rs"] mod tests; + +// Feature-off counterpart: with `web3` compiled out the wallet stub's +// `secret_material` returns the disabled error, so any wallet-signed Polymarket +// write must surface that instead of silently succeeding. The signing-heavy +// happy-path tests above cannot run (no wallet), so this narrow test locks in +// the degraded contract for the slim build. +#[cfg(all(test, not(feature = "web3")))] +mod tests_web3_disabled { + use super::*; + use crate::openhuman::config::PolymarketConfig; + use crate::openhuman::security::{AutonomyLevel, SecurityPolicy}; + use std::sync::Arc; + + fn write_tool_with_eoa(user: &str) -> PolymarketTool { + let config = PolymarketConfig { + enabled: true, + eoa_address: Some(user.to_string()), + ..PolymarketConfig::default() + }; + let security = Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Supervised, + ..SecurityPolicy::default() + }); + PolymarketTool::new(&config, security) + } + + #[tokio::test] + async fn signer_resolution_reports_wallet_disabled() { + // A configured EOA lets user-address resolution short-circuit, so the + // failure comes from `secret_material` (the wallet stub), proving the + // write path degrades to the disabled error rather than panicking or + // succeeding without a signer. + let tool = write_tool_with_eoa("0x000000000000000000000000000000000000dEaD"); + let err = tool + .resolve_signer_and_user(None) + .await + .expect_err("wallet-signed writes must fail when web3 is compiled out"); + let msg = format!("{err:#}"); + assert!( + msg.contains("web3/wallet feature disabled at compile time"), + "expected the stable compile-time-disabled marker, got: {msg}" + ); + } +} diff --git a/src/openhuman/tools/mod.rs b/src/openhuman/tools/mod.rs index 58aef6e4a6..1b18ff8b4b 100644 --- a/src/openhuman/tools/mod.rs +++ b/src/openhuman/tools/mod.rs @@ -54,6 +54,7 @@ pub use crate::openhuman::team::tools::*; pub use crate::openhuman::threads::tools::*; pub use crate::openhuman::tinyplace::tools::*; pub use crate::openhuman::todos::tools::*; +#[cfg(feature = "web3")] pub use crate::openhuman::wallet::tools::*; pub use crate::openhuman::whatsapp_data::tools::*; pub use crate::openhuman::workspace::tools::*; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 7c12a9ae46..adb7b52617 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -359,11 +359,19 @@ pub fn all_tools_with_runtime( Box::new(SuggestWorkflowsTool::new(config.clone())), // Wallet tools — expose wallet operations to the agent tool-call pipeline // so the crypto sub-agent can prepare transfers, check status, etc. + // Gated with the `web3` feature (the wallet domain is compiled out when + // web3 is disabled; the concrete tool types live under `wallet::tools`). + #[cfg(feature = "web3")] Box::new(WalletStatusTool::new()), + #[cfg(feature = "web3")] Box::new(WalletChainStatusTool::new()), + #[cfg(feature = "web3")] Box::new(WalletPrepareTransferTool::new()), + #[cfg(feature = "web3")] Box::new(WalletTxStatusTool::new()), + #[cfg(feature = "web3")] Box::new(WalletTxReceiptTool::new()), + #[cfg(feature = "web3")] Box::new(WalletLookupTxTool::new()), Box::new(MemoryStoreTool::new(memory.clone(), security.clone())), Box::new(MemoryRecallTool::new(memory.clone())), @@ -787,7 +795,9 @@ pub fn all_tools_with_runtime( // x402 — dedicated tool for making paid HTTP requests to x402-enabled // APIs (Base USDC / Solana USDC). Handles the 402 challenge, EIP-3009 - // or SPL payment signing, and ledger recording. + // or SPL payment signing, and ledger recording. Gated with the `web3` + // feature (the x402 domain is compiled out when web3 is disabled). + #[cfg(feature = "web3")] tools.push(Box::new( crate::openhuman::x402::tools::X402RequestTool::new(), )); diff --git a/src/openhuman/wallet/mod.rs b/src/openhuman/wallet/mod.rs index e144ac831c..0cbb25c699 100644 --- a/src/openhuman/wallet/mod.rs +++ b/src/openhuman/wallet/mod.rs @@ -2,29 +2,60 @@ //! the agent-facing execution surface (balances, transfers, swaps, //! contract calls). See [`execution`] for the prepare/confirm/execute flow //! and [`chains`] for the per-chain signing/broadcast implementations. +//! +//! ## Compile-time gate (`web3` feature) +//! +//! `pub mod wallet;` is ALWAYS compiled — it is a facade. The real +//! implementation (the submodules below and their re-exports) is gated behind +//! the default-ON `web3` Cargo feature (shared with `openhuman::web3` + +//! `openhuman::x402`). When the feature is off, [`stub`] takes its place and +//! exposes the same public surface that always-on / other-gated callers depend +//! on (`WALLET_NOT_CONFIGURED_MESSAGE`, `status`, `secret_material`, +//! `WalletChain`, `prepare_transfer`, `execute_prepared`, the prepare/execute +//! param + result types, `solana_cluster` / `SolanaCluster` / +//! `tinyplace_solana_rpc_endpoints`, `tinyplace_signer_seed`, the `rpc` +//! submodule, `prepared_quotes_for_test`, and the controller-registration +//! entry points) with no-op / disabled-error bodies — so tinyplace on-chain +//! payments + the Polymarket tools degrade to graceful "wallet disabled" +//! errors rather than failing to compile. Signatures MUST match the real ones; +//! the disabled build +//! (`cargo check --no-default-features --features tokenjuice-treesitter`) is +//! the only thing that catches drift. +#[cfg(feature = "web3")] mod abi; +#[cfg(feature = "web3")] mod chains; +#[cfg(feature = "web3")] mod defaults; +#[cfg(feature = "web3")] mod execution; +#[cfg(feature = "web3")] mod ops; +#[cfg(feature = "web3")] pub(crate) mod rpc; +#[cfg(feature = "web3")] mod schemas; +#[cfg(feature = "web3")] pub mod tools; -#[cfg(test)] +#[cfg(all(test, feature = "web3"))] pub(crate) mod test_support; +#[cfg(feature = "web3")] pub use abi::encode_erc20_transfer; /// 32-byte Ed25519 seed for the tiny.place LocalSigner. Derived from the user's /// primary Solana wallet key via SLIP-0010; consumed in-process and never exposed. +#[cfg(feature = "web3")] pub(crate) use chains::solana::tinyplace_signer_seed; +#[cfg(feature = "web3")] pub use defaults::{ asset_catalog, default_rpc_url, env_var_for_chain, evm_asset_catalog, explorer_tx_url, find_asset, find_asset_for_network, network_defaults, rpc_source_for_chain, rpc_url_for_chain, rpc_url_for_evm_network, solana_cluster, tinyplace_solana_rpc_endpoints, EvmNetwork, RpcSource, SolanaCluster, WalletAssetDefinition, WalletNetworkDefaults, }; +#[cfg(feature = "web3")] pub use execution::{ balances, chain_status, execute_prepared, lookup_tx, network_defaults as wallet_network_defaults, prepare_transfer, prepared_quotes_for_test, @@ -34,16 +65,30 @@ pub use execution::{ }; /// Crate-internal signing primitives the `web3` layer builds on. Not part of /// the agent / RPC surface. +#[cfg(feature = "web3")] pub(crate) use execution::{sign_and_broadcast_evm, sign_and_broadcast_solana}; +#[cfg(feature = "web3")] pub(crate) use ops::secret_material; +#[cfg(feature = "web3")] pub use ops::{ reveal_recovery_phrase, setup, status, RevealRecoveryPhraseResult, WalletAccount, WalletChain, WalletSetupParams, WalletSetupSource, WalletStatus, WALLET_NOT_CONFIGURED_MESSAGE, }; /// Reduce an RPC URL to `scheme://host` for logging so private provider tokens /// embedded in the path/query never reach the logs. +#[cfg(feature = "web3")] pub(crate) use rpc::redact_rpc_url; +#[cfg(feature = "web3")] pub use schemas::{ all_controller_schemas, all_registered_controllers, all_wallet_controller_schemas, all_wallet_registered_controllers, schemas, wallet_schemas, }; + +// --------------------------------------------------------------------------- +// Disabled facade — compiled only when the `web3` feature is OFF. +// --------------------------------------------------------------------------- + +#[cfg(not(feature = "web3"))] +mod stub; +#[cfg(not(feature = "web3"))] +pub use stub::*; diff --git a/src/openhuman/wallet/stub.rs b/src/openhuman/wallet/stub.rs new file mode 100644 index 0000000000..f576002372 --- /dev/null +++ b/src/openhuman/wallet/stub.rs @@ -0,0 +1,350 @@ +//! Disabled-wallet facade. +//! +//! Compiled only when the `web3` Cargo feature is OFF (see the gate in +//! [`super`]). It mirrors the subset of the real `wallet` public surface that +//! always-on / other-gated callers depend on, with no-op / `None` / +//! disabled-error bodies so the crate still compiles, boots, and serves `/rpc` +//! without the wallet + web3 + x402 domains. +//! +//! The signatures here MUST match the real ones exactly (return types +//! included). The disabled build +//! (`cargo check --no-default-features --features tokenjuice-treesitter`) is +//! the only thing that catches drift — if a real signature changes, update the +//! mirror below until that build is green again. +//! +//! Consumers covered here (all outside `wallet`, so all must keep compiling): +//! - `core/jsonrpc.rs` — `WALLET_NOT_CONFIGURED_MESSAGE` +//! - `tools/impl/network/polymarket.rs` — `secret_material`, `status`, +//! `WalletChain` +//! - `tinyplace/payment.rs` — `prepare_transfer`, `execute_prepared`, the +//! param/result types, `SolanaCluster`, `solana_cluster`, +//! `tinyplace_solana_rpc_endpoints`, `rpc::with_tinyplace_solana_endpoints` +//! - `tinyplace/manifest.rs` — `solana_cluster`, `tinyplace_solana_rpc_endpoints`, +//! `redact_rpc_url`, `SolanaCluster::usdc_mint` +//! - `tinyplace/signal_store.rs`, `tinyplace/state.rs` — `tinyplace_signer_seed` +//! - `test_support/introspect.rs` — `prepared_quotes_for_test`, +//! `PreparedTransaction` +//! - `core/all.rs` — `all_wallet_registered_controllers` + +use serde::Serialize; + +use crate::core::all::RegisteredController; +use crate::core::ControllerSchema; +use crate::rpc::RpcOutcome; + +/// Error text returned by every disabled-path operation that must yield a +/// `Result`. Shared so callers/log-greps see one stable string. +const DISABLED_MSG: &str = "web3/wallet feature disabled at compile time"; + +/// Mirrors the real `ops::WALLET_NOT_CONFIGURED_MESSAGE` verbatim. `jsonrpc.rs` +/// compares Sentry-noise errors against this exact string, so it must not drift. +pub const WALLET_NOT_CONFIGURED_MESSAGE: &str = "wallet is not configured; run wallet setup first"; + +// --------------------------------------------------------------------------- +// Chain / status surface (mirrors `ops::{WalletChain, WalletAccount, +// WalletStatus, status, secret_material}`) +// --------------------------------------------------------------------------- + +/// The four wallet chains. Mirrors [`super::ops::WalletChain`] (real build). +/// Callers pattern-match on `Evm` / `Solana`; the full set is kept for parity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WalletChain { + Evm, + Btc, + Solana, + Tron, +} + +/// A derived per-chain account. Mirrors the fields Polymarket reads +/// (`chain`, `address`). +#[derive(Debug, Clone, Serialize)] +pub struct WalletAccount { + pub chain: WalletChain, + pub address: String, +} + +/// Wallet status snapshot. Only `accounts` is read by out-of-module callers +/// (Polymarket EOA resolution); with the wallet disabled it is always empty. +#[derive(Debug, Clone, Default, Serialize)] +pub struct WalletStatus { + pub accounts: Vec, +} + +/// Decrypted secret handle. Polymarket reads `encrypted_mnemonic` + +/// `derivation_path` — but `secret_material` never returns `Ok` here, so these +/// are never actually produced. Kept nameable for the return type. +pub(crate) struct WalletSecretMaterial { + pub encrypted_mnemonic: String, + pub derivation_path: String, +} + +/// Disabled: no wallet is configured, so the status carries no accounts. Kept +/// `Ok` (not `Err`) so Polymarket degrades to the clean "run wallet setup" +/// message instead of a decrypt-context error. +pub async fn status() -> Result, String> { + log::debug!("[wallet-stub] status requested (web3 disabled) — no accounts"); + Ok(RpcOutcome::new( + WalletStatus::default(), + vec!["wallet disabled at compile time".to_string()], + )) +} + +/// Always errors: secret material cannot be produced with the wallet compiled +/// out. Callers `?`-propagate (Polymarket writes surface the disabled error). +pub(crate) async fn secret_material(_chain: WalletChain) -> Result { + log::debug!( + "[wallet-stub] secret_material requested (web3 disabled) — returning disabled error" + ); + Err(DISABLED_MSG.to_string()) +} + +// --------------------------------------------------------------------------- +// Prepare / execute surface (mirrors `execution::{prepare_transfer, +// execute_prepared, PrepareTransferParams, ExecutePreparedParams, +// PreparedTransaction, ExecutionResult, prepared_quotes_for_test}`) +// --------------------------------------------------------------------------- + +/// Inputs to `prepare_transfer`. Mirrors the fields `tinyplace/payment.rs` +/// sets. `evm_network` is `Option<()>` (the real `Option` cannot be +/// named with `defaults` compiled out); the only external constructor passes +/// `None`, so the placeholder is behaviourally identical. +#[derive(Debug, Clone)] +pub struct PrepareTransferParams { + pub chain: WalletChain, + pub to_address: String, + pub amount_raw: String, + pub asset_symbol: Option, + pub evm_network: Option<()>, +} + +/// Inputs to `execute_prepared`. Mirrors the real type. +#[derive(Debug, Default, Clone)] +pub struct ExecutePreparedParams { + pub quote_id: String, + pub confirmed: bool, +} + +/// A prepared quote. Out-of-module callers read `quote_id` (`tinyplace`) and +/// serialize the collection (`test_support`); `Serialize` + that field are the +/// only requirements. The real type is far richer. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PreparedTransaction { + pub quote_id: String, +} + +/// Result of an execute. Out-of-module callers read `transaction_hash` +/// (`tinyplace/payment.rs`); `execute_prepared` never returns `Ok`, so it is +/// never actually produced. +#[derive(Debug, Clone)] +pub struct ExecutionResult { + pub transaction_hash: String, +} + +/// Disabled: no transfer can be prepared with the wallet compiled out. +pub async fn prepare_transfer( + _params: PrepareTransferParams, +) -> Result, String> { + log::debug!( + "[wallet-stub] prepare_transfer requested (web3 disabled) — returning disabled error" + ); + Err(DISABLED_MSG.to_string()) +} + +/// Disabled: no prepared transfer can be executed with the wallet compiled out. +pub async fn execute_prepared( + _params: ExecutePreparedParams, +) -> Result, String> { + log::debug!( + "[wallet-stub] execute_prepared requested (web3 disabled) — returning disabled error" + ); + Err(DISABLED_MSG.to_string()) +} + +/// Always empty: there is no quote store when the wallet is compiled out. +pub fn prepared_quotes_for_test() -> Vec { + Vec::new() +} + +/// Disabled: the tiny.place signer seed derives from the Solana wallet key, +/// which does not exist. Callers `?`-propagate → "unlock wallet" prompt. +pub(crate) async fn tinyplace_signer_seed() -> Result<[u8; 32], String> { + log::debug!( + "[wallet-stub] tinyplace_signer_seed requested (web3 disabled) — returning disabled error" + ); + Err(DISABLED_MSG.to_string()) +} + +// --------------------------------------------------------------------------- +// Solana cluster metadata (mirrors `defaults::{SolanaCluster, solana_cluster, +// tinyplace_solana_rpc_endpoints}`) +// --------------------------------------------------------------------------- + +/// Public Solana clusters. Mirrors [`super::defaults::SolanaCluster`] including +/// the `usdc_mint` accessor `tinyplace/{payment,manifest}.rs` call. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SolanaCluster { + Mainnet, + Devnet, +} + +impl SolanaCluster { + /// USDC SPL-token mint address for the cluster. Same literals as the real + /// `defaults` module so any residual log/compare paths see stable values. + pub fn usdc_mint(self) -> &'static str { + match self { + Self::Mainnet => "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + Self::Devnet => "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + } + } +} + +/// Resolve the configured Solana cluster. With the wallet disabled nothing +/// settles on-chain, so the default (Mainnet) is returned unconditionally. +pub fn solana_cluster() -> SolanaCluster { + SolanaCluster::Mainnet +} + +/// Always empty: the wallet is compiled out, so there are no settlement +/// endpoints. `tinyplace/manifest.rs`'s balance loop therefore no-ops and the +/// balance shows as unknown — the correct degraded state. +pub fn tinyplace_solana_rpc_endpoints() -> Vec { + Vec::new() +} + +// --------------------------------------------------------------------------- +// rpc submodule (mirrors `rpc::{redact_rpc_url, with_tinyplace_solana_endpoints}`) +// --------------------------------------------------------------------------- + +pub(crate) mod rpc { + /// Redact an RPC URL for logging. With the wallet disabled we never build + /// real endpoints, but `tinyplace/manifest.rs` still calls this on any URL + /// it iterates; return a constant so no token can ever leak. + pub(crate) fn redact_rpc_url(_raw: &str) -> String { + "".to_string() + } + + /// Run `fut` unchanged — there is no tiny.place endpoint scope to install + /// when the wallet is compiled out. Matches the real generic signature so + /// `tinyplace/payment.rs` type-checks; the future itself resolves to a + /// disabled error from `prepare_transfer`. + pub(crate) async fn with_tinyplace_solana_endpoints(_endpoints: Vec, fut: F) -> T + where + F: std::future::Future, + { + fut.await + } +} + +/// Re-export mirrors the real `pub(crate) use rpc::redact_rpc_url;` so +/// `wallet::redact_rpc_url` resolves at the module root for `tinyplace`. +pub(crate) use rpc::redact_rpc_url; + +// --------------------------------------------------------------------------- +// Agent-tool facade (mirrors `pub mod tools`, re-exported via tools/mod.rs) +// --------------------------------------------------------------------------- + +/// Empty tools module. `tools/mod.rs` glob-re-exports `wallet::tools::*`; the +/// concrete wallet tool constructors it names are `#[cfg(feature = "web3")]` +/// at their registration sites, so nothing is referenced here when off. +pub mod tools {} + +// --------------------------------------------------------------------------- +// Controller registration (mirrors `schemas::{all_wallet_registered_controllers, +// all_wallet_controller_schemas}`) +// --------------------------------------------------------------------------- + +/// No wallet controllers are registered when the wallet is compiled out — the +/// `openhuman.wallet_*` RPCs become unknown-method. +pub fn all_wallet_registered_controllers() -> Vec { + Vec::new() +} + +/// No wallet controller schemas when the wallet is compiled out. +pub fn all_wallet_controller_schemas() -> Vec { + Vec::new() +} + +// This module is only compiled when the `web3` feature is OFF (see the +// `#[cfg(not(feature = "web3"))] mod stub;` gate in `super`), so a plain +// `#[cfg(test)]` here already runs only in the disabled build — it locks in the +// degraded contract that always-on callers (tinyplace payments, Polymarket +// writes) depend on. +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn status_reports_no_accounts() { + let outcome = status().await.expect("stub status is always Ok"); + assert!( + outcome.value.accounts.is_empty(), + "disabled wallet must expose no accounts" + ); + } + + #[tokio::test] + async fn secret_material_is_disabled_error() { + // `WalletSecretMaterial` intentionally omits `Debug` (mirrors the real + // type, which never logs a mnemonic), so match rather than `expect_err`. + match secret_material(WalletChain::Evm).await { + Ok(_) => panic!("secret material must be unavailable when the wallet is compiled out"), + Err(msg) => assert_eq!(msg, DISABLED_MSG), + } + } + + #[tokio::test] + async fn prepare_transfer_is_disabled_error() { + let err = prepare_transfer(PrepareTransferParams { + chain: WalletChain::Solana, + to_address: "recipient".to_string(), + amount_raw: "1".to_string(), + asset_symbol: None, + evm_network: None, + }) + .await + .expect_err("no transfer can be prepared when the wallet is compiled out"); + assert_eq!(err, DISABLED_MSG); + } + + #[tokio::test] + async fn execute_prepared_is_disabled_error() { + let err = execute_prepared(ExecutePreparedParams { + quote_id: "quote".to_string(), + confirmed: true, + }) + .await + .expect_err("no prepared transfer can execute when the wallet is compiled out"); + assert_eq!(err, DISABLED_MSG); + } + + #[tokio::test] + async fn tinyplace_signer_seed_is_disabled_error() { + let err = tinyplace_signer_seed() + .await + .expect_err("no signer seed derives when the wallet is compiled out"); + assert_eq!(err, DISABLED_MSG); + } + + #[test] + fn prepared_quotes_and_rpc_endpoints_are_empty() { + assert!(prepared_quotes_for_test().is_empty()); + assert!(tinyplace_solana_rpc_endpoints().is_empty()); + } + + #[test] + fn solana_cluster_defaults_to_mainnet_with_stable_usdc_mint() { + assert_eq!(solana_cluster(), SolanaCluster::Mainnet); + assert_eq!( + SolanaCluster::Mainnet.usdc_mint(), + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + ); + } + + #[test] + fn registration_entry_points_are_empty() { + assert!(all_wallet_registered_controllers().is_empty()); + assert!(all_wallet_controller_schemas().is_empty()); + } +} diff --git a/src/openhuman/web3/mod.rs b/src/openhuman/web3/mod.rs index b5dc3312f0..06b82df7be 100644 --- a/src/openhuman/web3/mod.rs +++ b/src/openhuman/web3/mod.rs @@ -11,26 +11,58 @@ //! - [`bridge`] (`web3_bridge`) — cross-chain DLN bridges. //! - [`dapp`] (`web3_dapp`) — generic EVM contract calls. +//! ## Compile-time gate (`web3` feature) +//! +//! `pub mod web3;` is ALWAYS compiled — it is a facade. The real swap/bridge/ +//! dapp implementation is gated behind the default-ON `web3` Cargo feature +//! (shared with `openhuman::wallet` + `openhuman::x402`). When the feature is +//! off, [`stub`] takes its place and exposes the controller/agent-tool +//! registration entry points (`all_web3_registered_controllers`, +//! `all_web3_controller_schemas`, `all_web3_agent_tools`) returning empty +//! collections, so `core/all.rs` + `tools/ops.rs` need no per-call `#[cfg]`. + +#[cfg(feature = "web3")] pub mod bridge; +#[cfg(feature = "web3")] pub mod client; +#[cfg(feature = "web3")] pub mod dapp; +#[cfg(feature = "web3")] pub mod ops; +#[cfg(feature = "web3")] pub mod store; +#[cfg(feature = "web3")] pub mod swap; +#[cfg(feature = "web3")] pub mod types; -#[cfg(test)] +#[cfg(all(test, feature = "web3"))] #[path = "web3_tests.rs"] mod tests; +#[cfg(feature = "web3")] use serde::Serialize; +#[cfg(feature = "web3")] use crate::core::all::RegisteredController; +#[cfg(feature = "web3")] use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; +#[cfg(feature = "web3")] use crate::openhuman::tools::traits::{Tool, ToolResult}; +#[cfg(feature = "web3")] use crate::rpc::RpcOutcome; +// --------------------------------------------------------------------------- +// Disabled facade — compiled only when the `web3` feature is OFF. +// --------------------------------------------------------------------------- + +#[cfg(not(feature = "web3"))] +mod stub; +#[cfg(not(feature = "web3"))] +pub use stub::*; + /// A required JSON-typed controller input. +#[cfg(feature = "web3")] pub(crate) fn req_json(name: &'static str, comment: &'static str) -> FieldSchema { FieldSchema { name, @@ -41,6 +73,7 @@ pub(crate) fn req_json(name: &'static str, comment: &'static str) -> FieldSchema } /// An optional string controller input. +#[cfg(feature = "web3")] pub(crate) fn opt_str(name: &'static str, comment: &'static str) -> FieldSchema { FieldSchema { name, @@ -51,6 +84,7 @@ pub(crate) fn opt_str(name: &'static str, comment: &'static str) -> FieldSchema } /// Standard `result` output field. +#[cfg(feature = "web3")] pub(crate) fn json_result(comment: &'static str) -> FieldSchema { FieldSchema { name: "result", @@ -61,6 +95,7 @@ pub(crate) fn json_result(comment: &'static str) -> FieldSchema { } /// Shared `quoteId` + `confirmed` inputs for the execute controllers. +#[cfg(feature = "web3")] pub(crate) fn execute_inputs() -> Vec { vec![ req_json("quoteId", "quoteId returned by a prior web3 quote/call."), @@ -72,6 +107,7 @@ pub(crate) fn execute_inputs() -> Vec { } /// Shared agent-tool JSON Schema for the execute tools. +#[cfg(feature = "web3")] pub(crate) fn execute_tool_schema() -> serde_json::Value { serde_json::json!({ "type": "object", @@ -85,6 +121,7 @@ pub(crate) fn execute_tool_schema() -> serde_json::Value { } /// Convert an op result into a `ToolResult` with pretty-printed JSON on success. +#[cfg(feature = "web3")] pub(crate) fn to_tool_result(result: Result, String>) -> ToolResult { match result { Ok(outcome) => match serde_json::to_string_pretty(&outcome.value) { @@ -96,6 +133,7 @@ pub(crate) fn to_tool_result(result: Result, String> } /// All web3 controller schemas across the swap/bridge/dapp namespaces. +#[cfg(feature = "web3")] pub fn all_web3_controller_schemas() -> Vec { let mut out = swap::schemas::schemas(); out.extend(bridge::schemas::schemas()); @@ -104,6 +142,7 @@ pub fn all_web3_controller_schemas() -> Vec { } /// All web3 registered controllers across the swap/bridge/dapp namespaces. +#[cfg(feature = "web3")] pub fn all_web3_registered_controllers() -> Vec { let mut out = swap::schemas::controllers(); out.extend(bridge::schemas::controllers()); @@ -113,6 +152,7 @@ pub fn all_web3_registered_controllers() -> Vec { /// All web3 agent tools. These call the backend per-invocation, so they error /// gracefully (rather than being hidden) when the user is not signed in. +#[cfg(feature = "web3")] pub fn all_web3_agent_tools() -> Vec> { vec![ Box::new(swap::Web3SwapQuoteTool::new()), diff --git a/src/openhuman/web3/stub.rs b/src/openhuman/web3/stub.rs new file mode 100644 index 0000000000..8e3201fde7 --- /dev/null +++ b/src/openhuman/web3/stub.rs @@ -0,0 +1,52 @@ +//! Disabled-web3 facade. +//! +//! Compiled only when the `web3` Cargo feature is OFF (see the gate in +//! [`super`]). The high-level swap/bridge/dapp surface has no always-on +//! callers other than the central registration points in `core/all.rs` +//! (controllers) and `tools/ops.rs` (agent tools), so the stub only needs to +//! return empty collections from those three entry points — no per-call +//! `#[cfg]` at the call sites. +//! +//! Signatures MUST match the real ones exactly; the disabled build +//! (`cargo check --no-default-features --features tokenjuice-treesitter`) is +//! the only thing that catches drift. + +use crate::core::all::RegisteredController; +use crate::core::ControllerSchema; +use crate::openhuman::tools::traits::Tool; + +/// No web3 controller schemas when the domain is compiled out. +pub fn all_web3_controller_schemas() -> Vec { + Vec::new() +} + +/// No web3 controllers are registered when the domain is compiled out — the +/// `openhuman.web3_*` RPCs become unknown-method. +pub fn all_web3_registered_controllers() -> Vec { + Vec::new() +} + +/// No web3 agent tools (swap/bridge/dapp) when the domain is compiled out. +pub fn all_web3_agent_tools() -> Vec> { + Vec::new() +} + +// Compiled only in the disabled build (`#[cfg(not(feature = "web3"))] mod stub;` +// in `super`), so a plain `#[cfg(test)]` runs only when web3 is compiled out — +// it pins the empty controller/tool surface that `core/all.rs` + `tools/ops.rs` +// consume without per-call `#[cfg]`. +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registration_entry_points_are_empty() { + assert!(all_web3_registered_controllers().is_empty()); + assert!(all_web3_controller_schemas().is_empty()); + } + + #[test] + fn agent_tools_are_absent() { + assert!(all_web3_agent_tools().is_empty()); + } +} diff --git a/src/openhuman/x402/mod.rs b/src/openhuman/x402/mod.rs index 3735ae31a8..345a1e792e 100644 --- a/src/openhuman/x402/mod.rs +++ b/src/openhuman/x402/mod.rs @@ -8,22 +8,53 @@ //! //! Protocol spec: / coinbase/x402 (v2). +//! ## Compile-time gate (`web3` feature) +//! +//! `pub mod x402;` is ALWAYS compiled — it is a facade. The real payment +//! machinery is gated behind the default-ON `web3` Cargo feature (shared with +//! `openhuman::wallet` + `openhuman::web3`). When the feature is off, [`stub`] +//! takes its place and exposes the always-on entry points (`init_ledger`, +//! `all_x402_registered_controllers`, `all_x402_controller_schemas`) with +//! no-op / empty bodies. The `X402RequestTool` and the http_request 402-retry +//! path are `#[cfg(feature = "web3")]` at their call sites, so the rest of the +//! payment surface (`PaymentRecord`, `store`, `SettlementResponse`, …) is not +//! referenced when off and need not be stubbed. + +#[cfg(feature = "web3")] mod ops; +#[cfg(feature = "web3")] mod schemas; +#[cfg(feature = "web3")] pub(crate) mod store; +#[cfg(feature = "web3")] pub mod tools; +#[cfg(feature = "web3")] mod types; -#[cfg(test)] +#[cfg(all(test, feature = "web3"))] mod x402_tests; +#[cfg(feature = "web3")] pub use ops::{ handle_402, handle_402_and_pay, try_paid_request, X402Client, X402Error, X402PaymentResult, }; +#[cfg(feature = "web3")] pub use schemas::all_controller_schemas as all_x402_controller_schemas; +#[cfg(feature = "web3")] pub use schemas::all_registered_controllers as all_x402_registered_controllers; +#[cfg(feature = "web3")] pub use store::{init_global as init_ledger, PaymentRecord, PaymentStatus, SpendingBudget}; +#[cfg(feature = "web3")] pub use types::{ EvmAuthorization, EvmPaymentProof, PaymentChain, PaymentPayload, PaymentProof, PaymentRequired, PaymentRequirements, ResourceInfo, SettlementResponse, SolanaPaymentProof, }; + +// --------------------------------------------------------------------------- +// Disabled facade — compiled only when the `web3` feature is OFF. +// --------------------------------------------------------------------------- + +#[cfg(not(feature = "web3"))] +mod stub; +#[cfg(not(feature = "web3"))] +pub use stub::*; diff --git a/src/openhuman/x402/stub.rs b/src/openhuman/x402/stub.rs new file mode 100644 index 0000000000..3026c07d18 --- /dev/null +++ b/src/openhuman/x402/stub.rs @@ -0,0 +1,58 @@ +//! Disabled-x402 facade. +//! +//! Compiled only when the `web3` Cargo feature is OFF (see the gate in +//! [`super`]). Only three entry points have always-on callers: `init_ledger` +//! (`core/jsonrpc.rs` boot, itself runtime-gated on `DomainGroup::Web3`) and +//! the controller-registration pair (`core/all.rs`). The `X402RequestTool` +//! registration and the http_request 402-retry path are `#[cfg(feature = +//! "web3")]` at their call sites, so no other x402 surface is referenced when +//! off. +//! +//! Signatures MUST match the real ones exactly; the disabled build +//! (`cargo check --no-default-features --features tokenjuice-treesitter`) is +//! the only thing that catches drift. + +use std::path::Path; + +use crate::core::all::RegisteredController; +use crate::core::ControllerSchema; + +/// No-op: there is no spending ledger to initialise when x402 is compiled out. +/// Mirrors `store::init_global` (re-exported as `init_ledger`). The boot call +/// site is additionally runtime-gated on `DomainGroup::Web3`. +pub fn init_ledger(_workspace_dir: &Path, _session_id: &str) { + log::debug!("[x402-stub] init_ledger ignored (web3 disabled)"); +} + +/// No x402 controller schemas when the domain is compiled out. +pub fn all_x402_controller_schemas() -> Vec { + Vec::new() +} + +/// No x402 controllers are registered when the domain is compiled out — the +/// `openhuman.x402_*` RPCs become unknown-method. +pub fn all_x402_registered_controllers() -> Vec { + Vec::new() +} + +// Compiled only in the disabled build (`#[cfg(not(feature = "web3"))] mod stub;` +// in `super`), so a plain `#[cfg(test)]` here runs only when x402 is compiled +// out — it pins the disabled facade's callable no-op + empty-registration +// contract that `core/jsonrpc.rs` boot and `core/all.rs` rely on. +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn init_ledger_is_callable_noop() { + // Must not panic — the boot path calls this unconditionally (itself + // runtime-gated on `DomainGroup::Web3`) even in a slim build. + init_ledger(Path::new("/tmp/openhuman-x402-stub-test"), "session-x"); + } + + #[test] + fn registration_entry_points_are_empty() { + assert!(all_x402_registered_controllers().is_empty()); + assert!(all_x402_controller_schemas().is_empty()); + } +} From 86479a331ddd9d5b521ec8e9842dec94367bccea Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:29:20 +0530 Subject: [PATCH 39/86] fix(flows): expose platform_user_id in flows_list_connections schema + self-DM review follow-ups (#4941) --- .../agents/workflow_builder/builder_prompt.rs | 24 +++++++++++++++++ src/openhuman/flows/builder_tools.rs | 6 +++-- src/openhuman/flows/ops.rs | 4 +++ src/openhuman/flows/schemas.rs | 27 ++++++++++++++++--- 4 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs b/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs index 84b060ec42..b6ab680b8d 100644 --- a/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs +++ b/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs @@ -376,6 +376,30 @@ mod tests { "standing prompt must explicitly forbid falling back to a public \ channel (e.g. #general/#team-product) for a personal \"DM me\" request" ); + + // Positive: assert the *complete* wiring instruction, not just the + // presence of the `platform_user_id` keyword — a regression could + // drop the actual "pass it as `channel`" directive while leaving the + // word `platform_user_id` elsewhere in the prompt and still pass the + // looser check above. + assert!( + STANDING_PROMPT + .contains("that id verbatim as the `channel` arg on `SLACK_SEND_MESSAGE`"), + "standing prompt must explicitly instruct passing `platform_user_id` \ + verbatim as the `channel` arg on `SLACK_SEND_MESSAGE` — not just \ + mention the field name" + ); + + // Positive: the null-`platform_user_id` fallback (ask the user for + // their member id in one question) must survive too — this is the + // other half of the self-DM contract and must not be silently lost. + assert!( + STANDING_PROMPT.contains("Only if `platform_user_id` is null") + && STANDING_PROMPT.contains("ask the user for their member id"), + "standing prompt must preserve the null-`platform_user_id` fallback: \ + ask the user for their member id in one question rather than \ + guessing a channel" + ); } #[test] diff --git a/src/openhuman/flows/builder_tools.rs b/src/openhuman/flows/builder_tools.rs index d375a1939d..7f23a06386 100644 --- a/src/openhuman/flows/builder_tools.rs +++ b/src/openhuman/flows/builder_tools.rs @@ -1495,7 +1495,8 @@ impl Tool for GetFlowRunTool { /// `list_flow_connections`: read-only enumeration of the connection sources a /// node's `connection_ref` can attach to (Composio connected accounts + -/// named HTTP credentials) — ids / display labels / kind only, never secrets. +/// named HTTP credentials) — non-secret metadata only (ids / display labels +/// / kind / toolkit / scheme / platform_user_id), never secrets. pub struct ListFlowConnectionsTool { config: Arc, } @@ -1515,7 +1516,8 @@ impl Tool for ListFlowConnectionsTool { fn description(&self) -> &str { "List the connection sources a flow node's `connection_ref` can attach to: \ Composio connected accounts and named HTTP credentials. Read-only; \ - returns ids + display labels + kind ONLY (never any secret). Each \ + returns only non-secret metadata — ids, display labels, kind, and \ + `toolkit`/`scheme` (never any secret). Each \ Composio entry also carries `platform_user_id` — the connected \ account's own member id (e.g. Slack `U123ABC`) — use it to wire a \ self-targeted action like 'DM me' to that account instead of a \ diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index bfbec21cfc..4ee914ab64 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -2366,6 +2366,10 @@ pub async fn flows_list_connections( // connection sync. Loaded once here so `build_flow_connections` can stay // a pure, unit-testable matcher. let identities = crate::openhuman::composio::providers::profile::load_connected_identities(); + tracing::debug!( + count = identities.len(), + "[flows] flows_list_connections: identity-cache load" + ); let connections = build_flow_connections(composio_conns, http_creds, &identities); tracing::debug!( total = connections.len(), diff --git a/src/openhuman/flows/schemas.rs b/src/openhuman/flows/schemas.rs index feee1c1a7c..fef60033a9 100644 --- a/src/openhuman/flows/schemas.rs +++ b/src/openhuman/flows/schemas.rs @@ -261,6 +261,16 @@ fn flow_connection_fields() -> Vec { `bearer` | `basic` | `header`.", required: false, }, + FieldSchema { + name: "platform_user_id", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Connected account's own platform user id (kind `composio` only), \ + e.g. Slack `U123ABC`. Non-secret identity metadata — lets the \ + workflow builder wire a self-targeted action (e.g. \"DM me\") to \ + the user's own account instead of guessing a public channel. \ + `None` when no identity has synced yet.", + required: false, + }, ] } @@ -576,9 +586,11 @@ pub fn schemas(function: &str) -> ControllerSchema { function: "list_connections", description: "List the connection sources a flow node's `connection_ref` can attach \ to: Composio connected accounts (kind `composio`) and stored HTTP \ - credentials (kind `http`). Returns ids + display labels + kind ONLY — \ - never any secret material (OAuth/bearer tokens, passwords, and API \ - keys stay server-side and are injected only at execution time).", + credentials (kind `http`). Returns only non-secret metadata — ids, \ + display labels, kind, and (for Composio) the connected account's own \ + `platform_user_id` — never any secret material (OAuth/bearer tokens, \ + passwords, and API keys stay server-side and are injected only at \ + execution time).", inputs: vec![], outputs: vec![FieldSchema { name: "connections", @@ -1793,7 +1805,14 @@ mod tests { let names: Vec<_> = fields.iter().map(|f| f.name).collect(); assert_eq!( names, - vec!["connection_ref", "kind", "display", "toolkit", "scheme"] + vec![ + "connection_ref", + "kind", + "display", + "toolkit", + "scheme", + "platform_user_id" + ] ); for f in fields { let n = f.name.to_ascii_lowercase(); From ee5f570e1f1d2dadd1e21ae7412a24d0d2af194d Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:47:38 +0530 Subject: [PATCH 40/86] fix(flows): persist agentic task insights expand state across copilot turns (#4942) --- .../components/ToolTimelineBlock.tsx | 56 +++++++++-- .../__tests__/ToolTimelineBlock.test.tsx | 93 +++++++++++++++++++ 2 files changed, 141 insertions(+), 8 deletions(-) diff --git a/app/src/features/conversations/components/ToolTimelineBlock.tsx b/app/src/features/conversations/components/ToolTimelineBlock.tsx index 474b588521..d66d926f8b 100644 --- a/app/src/features/conversations/components/ToolTimelineBlock.tsx +++ b/app/src/features/conversations/components/ToolTimelineBlock.tsx @@ -1,3 +1,6 @@ +import createDebug from 'debug'; +import { useState } from 'react'; + import WorktreeActions from '../../../components/worktree/WorktreeActions'; import { useT } from '../../../lib/i18n/I18nContext'; import type { @@ -422,6 +425,8 @@ function RepeatCount({ count }: { count: number }) { ); } +const log = createDebug('app:conversations:tool-timeline'); + /** * Neutral surface tones for an expanded row's body (worker-thread card, * detail bubble, code block). Per the Figma "Agentic task insights" @@ -478,6 +483,23 @@ export function ToolTimelineBlock({ const { t } = useT(); const latestRunningEntryId = [...entries].reverse().find(entry => entry.status === 'running')?.id; + // Sticky override for the outer "Agentic task insights" group: `null` means + // the user hasn't explicitly toggled it on THIS mount yet, so the group + // falls back to the auto rule below (open while running, collapsed once + // settled). Once the user clicks the summary, this pins their choice — + // including across later turns that stream onto the SAME mounted block + // (e.g. the workflow copilot's dedicated thread, whose `ToolTimelineBlock` + // stays mounted for the life of the conversation while `entries` keeps + // growing turn over turn) — so a new turn's activity landing no longer + // involuntarily re-collapses (or re-expands) a choice the user already + // made ("Agentic task insights keeps collapsing on every new feedback"). + // Deliberately component-local state, not lifted to Redux: + // the block never remounts mid-conversation in the one place this bug was + // reported (`WorkflowCopilotPanel` renders it at a stable JSX position + // with entries accumulating in `toolTimelineByThread`, not reset per + // turn), so a plain `useState` already survives every turn it needs to. + const [userOverrideOpen, setUserOverrideOpen] = useState(null); + if (entries.length === 0) return null; const isRunning = latestRunningEntryId != null; @@ -629,20 +651,38 @@ export function ToolTimelineBlock({ // The group header is a static section label — the live "working" state is // conveyed by the pulsing agent-name rows, so it never repeats a "Working…" - // string. The group is always collapsible: while the run is in flight it is - // open so the live activity is visible; once it settles it collapses to a - // single-line opener by default so a finished run (which can be dozens of - // steps) never dominates the conversation — the rows stay one click away, and - // the whole-run side panel is still reachable via the header link. The full - // "Agent Process Source" panel forces every row open via `expandAllRows`. - const open = isRunning || expandAllRows; + // string. Absent a user override, the group is auto-driven: open while the + // run is in flight so the live activity is visible; collapsed once it + // settles so a finished run (which can be dozens of steps) never dominates + // the conversation. The full "Agent Process Source" panel forces every row + // open via `expandAllRows`. Once the user has explicitly toggled the group + // (see `userOverrideOpen` above), THAT choice wins over the auto rule — + // otherwise a new turn streaming onto an already-mounted block (settling, + // or starting a fresh run) would silently flip `open` out from under the + // user's manual choice on every turn. + const autoOpen = isRunning || expandAllRows; + const open = userOverrideOpen ?? autoOpen; + + // Fully own the disclosure via React state rather than letting the browser + // toggle the native `open` attribute itself — `preventDefault` stops the + // native toggle so there is exactly one source of truth (`open` above), + // never a race between the DOM's own uncontrolled state and the next + // render's `open` prop. + const handleSummaryClick = (event: React.MouseEvent) => { + event.preventDefault(); + const next = !open; + log('agent-task-insights: user toggled open=%s (auto would be %s)', next, autoOpen); + setUserOverrideOpen(next); + }; return (
- + {titleLabel} ▶ diff --git a/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx b/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx index 6924d3364c..d0c692f342 100644 --- a/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx +++ b/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx @@ -358,6 +358,99 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); }); + // Regression coverage for "Agentic task insights keeps collapsing on every + // new feedback": the workflow copilot keeps ONE `ToolTimelineBlock` mounted + // for the life of a thread, appending each new turn's entries onto the same + // `entries` prop (see `WorkflowCopilotPanel`/`useWorkflowBuilderChat`) — it + // never remounts the block per turn. Before the fix, the outer group's + // `open` was driven purely by `isRunning || expandAllRows`, so every time a + // turn settled (running → not running) the group snapped shut regardless of + // anything the user had done, discarding a manual expand made moments + // earlier. These tests simulate that same "new turn's entries land on an + // already-mounted block" shape via `rerender` rather than remounting. + describe('agentic task insights — sticky user expand/collapse across turns', () => { + it('keeps an explicit user expand across a new turn that starts and settles', () => { + const turn1Settled: ToolTimelineEntry[] = [ + { id: 't1', name: 'web_search', round: 1, status: 'success' }, + ]; + const { rerender } = renderInStore(); + // Default: settled and collapsed (unchanged behaviour). + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + + // The user manually expands it. + fireEvent.click(screen.getByText('Agentic task insights')); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + // A new turn/feedback starts streaming onto the SAME mounted block. + const turn2Running: ToolTimelineEntry[] = [ + ...turn1Settled, + { id: 't2', name: 'file_read', round: 2, status: 'running' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + // ...and settles. Without the fix this is exactly where it would + // involuntarily re-collapse, wiping out the user's choice. + const turn2Settled: ToolTimelineEntry[] = [ + ...turn1Settled, + { id: 't2', name: 'file_read', round: 2, status: 'success' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + }); + + it('leaves the default open-while-running/collapsed-when-settled behaviour unchanged absent any user interaction', () => { + const running: ToolTimelineEntry[] = [ + { id: 'r', name: 'web_search', round: 1, status: 'running' }, + ]; + const { rerender } = renderInStore(); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + const settled: ToolTimelineEntry[] = [ + { id: 'r', name: 'web_search', round: 1, status: 'success' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + }); + + it('also persists an explicit user collapse across a new turn (does not force it back open)', () => { + const turn1Running: ToolTimelineEntry[] = [ + { id: 't1', name: 'web_search', round: 1, status: 'running' }, + ]; + const { rerender } = renderInStore(); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + // The user collapses it while a turn is still running. + fireEvent.click(screen.getByText('Agentic task insights')); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + + // A new turn starts running — the auto rule alone would force it back + // open, but the user's explicit collapse must win. + const turn2Running: ToolTimelineEntry[] = [ + { id: 't1', name: 'web_search', round: 1, status: 'success' }, + { id: 't2', name: 'file_read', round: 2, status: 'running' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + }); + }); + it('renders the tool result output inside the expanded row', () => { const entries: ToolTimelineEntry[] = [ { From ea005c91081c433bc9ab49fe8599511985e9d322 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:50:37 +0530 Subject: [PATCH 41/86] fix(flows): copilot chat stays scrollable (auto-scroll only when pinned to bottom) (#4943) --- .../flows/WorkflowCopilotPanel.test.tsx | 128 +++++++++++++++ .../components/flows/WorkflowCopilotPanel.tsx | 34 +++- app/src/hooks/useStickToBottom.test.ts | 153 ++++++++++++++++++ app/src/hooks/useStickToBottom.ts | 62 ++++++- 4 files changed, 366 insertions(+), 11 deletions(-) create mode 100644 app/src/hooks/useStickToBottom.test.ts diff --git a/app/src/components/flows/WorkflowCopilotPanel.test.tsx b/app/src/components/flows/WorkflowCopilotPanel.test.tsx index d46ae467ef..f382f14692 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.test.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.test.tsx @@ -282,6 +282,134 @@ describe('WorkflowCopilotPanel', () => { 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); + }); + }); + it('surfaces a new proposal to the host and shows the added/removed diff', () => { const onProposal = vi.fn(); // proposed drops "b" and adds "c" vs. base [a, b]. diff --git a/app/src/components/flows/WorkflowCopilotPanel.tsx b/app/src/components/flows/WorkflowCopilotPanel.tsx index 582f3ea7cd..67902ae681 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.tsx @@ -29,6 +29,7 @@ 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 { useWorkflowBuilderChat } from '../../hooks/useWorkflowBuilderChat'; import { unwrapToolCallEnvelope } from '../../lib/flows/copilotMessageSanitizer'; import { diffGraphs } from '../../lib/flows/graphDiff'; @@ -177,7 +178,6 @@ export default function WorkflowCopilotPanel({ const textInputRef = useRef(null); const fileInputRef = useRef(null); const isComposingTextRef = useRef(false); - const scrollRef = useRef(null); // Surface each NEW proposal to the host exactly once (enter preview overlay). const lastSurfacedRef = useRef(null); @@ -312,11 +312,35 @@ export default function WorkflowCopilotPanel({ onPrefillSeedConsumed?.(); }, [prefillSeed, onPrefillSeedConsumed]); - // Keep the transcript pinned to the newest message / streamed activity. - // `scrollTo` is optional-chained: jsdom (tests) doesn't implement it. + // 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(() => { - scrollRef.current?.scrollTo?.({ top: scrollRef.current.scrollHeight }); - }, [displayMessages, sending, proposal, toolTimeline, liveResponse]); + 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]); const submit = useCallback( async (raw?: string) => { diff --git a/app/src/hooks/useStickToBottom.test.ts b/app/src/hooks/useStickToBottom.test.ts new file mode 100644 index 0000000000..db105ae017 --- /dev/null +++ b/app/src/hooks/useStickToBottom.test.ts @@ -0,0 +1,153 @@ +import { fireEvent, render } from '@testing-library/react'; +import { createElement } from 'react'; +import { describe, expect, it } from 'vitest'; + +import { isNearBottom, useStickToBottom } from './useStickToBottom'; + +/** + * jsdom performs no real layout, so `scrollHeight`/`clientHeight`/`scrollTop` + * are inert (always 0) unless we define them ourselves. This gives the + * container concrete, mutable scroll metrics so `isNearBottom` and + * `snapToBottom` (inside the hook) behave exactly as they would in a real + * browser for the geometry each test sets up. + */ +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, + }); +} + +interface HarnessProps { + messages: unknown[]; + threadKey: string | null; + resetKey: string; +} + +/** Minimal host component — the hook never touches the DOM directly, callers + * wire `containerRef` onto their own scroll container. */ +function Harness({ messages, threadKey, resetKey }: HarnessProps) { + const { containerRef } = useStickToBottom(messages, threadKey, resetKey); + return createElement( + 'div', + { ref: containerRef, 'data-testid': 'scroll-container' }, + messages.map((m, i) => createElement('div', { key: i }, String(m))) + ); +} + +describe('useStickToBottom', () => { + it('is a pure, independently-testable is-at-bottom check', () => { + // Exactly at the default 80px threshold: still "at bottom". + expect( + isNearBottom({ scrollHeight: 180, scrollTop: 100, clientHeight: 80 } as HTMLElement) + ).toBe(true); + // One pixel past the threshold (distance = 181 - 20 - 80 = 81): no + // longer "at bottom". + expect( + isNearBottom({ scrollHeight: 181, scrollTop: 20, clientHeight: 80 } as HTMLElement) + ).toBe(false); + // A custom threshold is honoured. + expect( + isNearBottom({ scrollHeight: 300, scrollTop: 100, clientHeight: 80 } as HTMLElement, 200) + ).toBe(true); + }); + + it('auto-scrolls to the newest content when the user is already pinned to the bottom', () => { + const { getByTestId, rerender } = render( + createElement(Harness, { messages: ['first'], threadKey: 't1', resetKey: 'k' }) + ); + const container = getByTestId('scroll-container'); + + // Establish "at the bottom" after the initial (always-snaps) mount. + mockScrollMetrics(container, { scrollTop: 50, scrollHeight: 100, clientHeight: 50 }); + rerender(createElement(Harness, { messages: ['first'], threadKey: 't1', resetKey: 'k' })); + + // A new message arrives — content grows (scrollHeight increases) while + // the user never moved away from the bottom. Because `stickingRef` is + // still true, the layout effect must snap to the new bottom. + mockScrollMetrics(container, { scrollTop: 50, scrollHeight: 300, clientHeight: 50 }); + rerender( + createElement(Harness, { messages: ['first', 'second'], threadKey: 't1', resetKey: 'k' }) + ); + + expect(container.scrollTop).toBe(300); + }); + + it('does NOT auto-scroll (respects the user reading history) once they scroll away from the bottom', () => { + const { getByTestId, rerender } = render( + createElement(Harness, { messages: ['first'], threadKey: 't1', resetKey: 'k' }) + ); + const container = getByTestId('scroll-container'); + + mockScrollMetrics(container, { scrollTop: 50, scrollHeight: 100, clientHeight: 50 }); + rerender(createElement(Harness, { messages: ['first'], threadKey: 't1', resetKey: 'k' })); + + // The user scrolls up, well past the stick threshold (400 - 0 - 50 = + // 350px from the bottom) — the `scroll` listener must disengage sticking. + mockScrollMetrics(container, { scrollTop: 0, scrollHeight: 400, clientHeight: 50 }); + fireEvent.scroll(container); + + // A new message arrives (content grows further) while the user is still + // reading up top. This is exactly the bug being fixed: the old + // unconditional `scrollTo` effect would yank the user back down here — + // the container must stay exactly where the reader left it. + mockScrollMetrics(container, { scrollTop: 0, scrollHeight: 700, clientHeight: 50 }); + rerender( + createElement(Harness, { messages: ['first', 'second'], threadKey: 't1', resetKey: 'k' }) + ); + + expect(container.scrollTop).toBe(0); + }); + + it('re-engages sticking once the user manually scrolls back to the bottom', () => { + const { getByTestId, rerender } = render( + createElement(Harness, { messages: ['first'], threadKey: 't1', resetKey: 'k' }) + ); + const container = getByTestId('scroll-container'); + + mockScrollMetrics(container, { scrollTop: 50, scrollHeight: 100, clientHeight: 50 }); + rerender(createElement(Harness, { messages: ['first'], threadKey: 't1', resetKey: 'k' })); + + // Scroll away (350px from bottom — well past the threshold). + mockScrollMetrics(container, { scrollTop: 0, scrollHeight: 400, clientHeight: 50 }); + fireEvent.scroll(container); + + // Scroll back within the threshold (400 - 350 - 50 = 0px from bottom). + mockScrollMetrics(container, { scrollTop: 350, scrollHeight: 400, clientHeight: 50 }); + fireEvent.scroll(container); + + // A new message now DOES pull the view down again. + mockScrollMetrics(container, { scrollTop: 350, scrollHeight: 600, clientHeight: 50 }); + rerender( + createElement(Harness, { messages: ['first', 'second'], threadKey: 't1', resetKey: 'k' }) + ); + + expect(container.scrollTop).toBe(600); + }); + + it('always snaps on a thread change, even mid-read (a fresh conversation should open at the bottom)', () => { + const { getByTestId, rerender } = render( + createElement(Harness, { messages: ['a1'], threadKey: 'thread-a', resetKey: 'k' }) + ); + const container = getByTestId('scroll-container'); + mockScrollMetrics(container, { scrollTop: 50, scrollHeight: 100, clientHeight: 50 }); + rerender(createElement(Harness, { messages: ['a1'], threadKey: 'thread-a', resetKey: 'k' })); + + // Scroll away on thread A. + mockScrollMetrics(container, { scrollTop: 0, scrollHeight: 400, clientHeight: 50 }); + fireEvent.scroll(container); + + // Switch to a different thread entirely — must snap regardless of the + // stale `stickingRef` from thread A. + mockScrollMetrics(container, { scrollTop: 0, scrollHeight: 250, clientHeight: 50 }); + rerender(createElement(Harness, { messages: ['b1'], threadKey: 'thread-b', resetKey: 'k' })); + + expect(container.scrollTop).toBe(250); + }); +}); diff --git a/app/src/hooks/useStickToBottom.ts b/app/src/hooks/useStickToBottom.ts index 9c356682ec..59280d1e7f 100644 --- a/app/src/hooks/useStickToBottom.ts +++ b/app/src/hooks/useStickToBottom.ts @@ -1,3 +1,4 @@ +import createDebug from 'debug'; import { useEffect, useLayoutEffect, useRef } from 'react'; /** @@ -17,12 +18,23 @@ import { useEffect, useLayoutEffect, useRef } from 'react'; * If the user manually scrolls up past the threshold we stop sticking, so they * can read history without being yanked down. Scrolling back to the bottom * re-engages stickiness on the next render. + * + * Root-cause note (copilot "chat gets stuck" bug): a naive `useEffect` that + * unconditionally calls `scrollTo(bottom)` on every dependency change (new + * message, streamed token, tool-timeline entry, …) fights a user who has + * scrolled up to read — it snaps them back down mid-read, which reads as the + * scroll container "locking up". This hook is the fix for that pattern: + * auto-scroll only fires while `stickingRef` is true (i.e. the user was + * already at/near the bottom), so scrolling up always disengages it. */ +const log = createDebug('app:hooks:stick-to-bottom'); + const STICK_THRESHOLD_PX = 80; -function isNearBottom(el: HTMLElement): boolean { - return el.scrollHeight - el.scrollTop - el.clientHeight <= STICK_THRESHOLD_PX; +/** Pure, independently-testable "is this container at/near the bottom?" check. */ +export function isNearBottom(el: HTMLElement, thresholdPx: number = STICK_THRESHOLD_PX): boolean { + return el.scrollHeight - el.scrollTop - el.clientHeight <= thresholdPx; } function snapToBottom(el: HTMLElement) { @@ -42,6 +54,12 @@ export function useStickToBottom( // Tracks whether we should keep auto-scrolling. Flips to false when the user // scrolls up away from the bottom; flips back when they return. const stickingRef = useRef(true); + // Mirrors `threadKey` for the mount-only resize-observer effect's log lines + // below — kept current every render so the logs stay accurate even though + // that effect itself intentionally doesn't re-run on a thread change (the + // MutationObserver already re-binds the ResizeObserver on subtree swaps). + const threadKeyRef = useRef(threadKey); + threadKeyRef.current = threadKey; // ── Snap on message / thread / route changes ───────────────────────────── useLayoutEffect(() => { @@ -51,18 +69,40 @@ export function useStickToBottom( } // Record the active thread on every render (including empty ones) so // the A → empty B → A navigation pattern is recognised as a thread - // change when A's messages re-arrive. + // change when A's messages re-arrive. Normalize `undefined` to `null` + // on BOTH sides of the comparison below — comparing the normalized + // previous value against a raw `threadKey` that happens to be + // `undefined` (rather than `null`) would make `threadChanged` true on + // every single run (`null !== undefined`), forcing an unwanted snap on + // every re-render for any caller whose "no thread yet" state is + // `undefined` instead of `null`. The param type explicitly allows + // `undefined`, so this normalization has to be symmetric. const previousThread = lastScrolledThreadRef.current; - lastScrolledThreadRef.current = threadKey ?? null; + const normalizedThreadKey = threadKey ?? null; + lastScrolledThreadRef.current = normalizedThreadKey; if (messages.length === 0) return; const container = containerRef.current; if (!container) return; - const threadChanged = previousThread !== threadKey; + const threadChanged = previousThread !== normalizedThreadKey; const firstScroll = !didInitialScrollRef.current; if (firstScroll || threadChanged || stickingRef.current) { + log( + 'layout-effect: snap fired (firstScroll=%s threadChanged=%s sticking=%s) count=%d thread=%s', + firstScroll, + threadChanged, + stickingRef.current, + messages.length, + normalizedThreadKey ?? 'null' + ); snapToBottom(container); stickingRef.current = true; + } else { + log( + 'layout-effect: snap skipped (user scrolled up) count=%d thread=%s', + messages.length, + normalizedThreadKey ?? 'null' + ); } didInitialScrollRef.current = true; }, [messages, threadKey, resetKey]); @@ -72,7 +112,11 @@ export function useStickToBottom( const container = containerRef.current; if (!container) return; const onScroll = () => { - stickingRef.current = isNearBottom(container); + const atBottom = isNearBottom(container); + if (atBottom !== stickingRef.current) { + log('scroll: sticking %s -> %s (isNearBottom=%s)', stickingRef.current, atBottom, atBottom); + } + stickingRef.current = atBottom; }; container.addEventListener('scroll', onScroll, { passive: true }); return () => container.removeEventListener('scroll', onScroll); @@ -91,7 +135,13 @@ export function useStickToBottom( const resizeObserver = new ResizeObserver(() => { if (stickingRef.current) { + log('resize-observer: snap fired (sticking) thread=%s', threadKeyRef.current ?? 'null'); snapToBottom(container); + } else { + log( + 'resize-observer: snap skipped (user scrolled up) thread=%s', + threadKeyRef.current ?? 'null' + ); } }); From 30fcb1f7e92e940c7c14eddf66f6dd645880ec74 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:33:58 +0530 Subject: [PATCH 42/86] fix(conversations): key the proactive-thread insights fallback (disclosure state leak) (#4944) --- .../features/conversations/Conversations.tsx | 28 ++++++-- .../__tests__/Conversations.render.test.tsx | 65 +++++++++++++++++++ 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index ee867f98dd..271ddbb659 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -2017,6 +2017,22 @@ const Conversations = ({ ) : 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]); @@ -2695,13 +2711,11 @@ const Conversations = ({ )} {/* 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. This fallback only fires for - the rare thread with no user message (e.g. a proactive-only - thread) so the recorded steps are never unreachable. The cancel - control + view-process-source opener now live in `agentInsights` - and the floating footer respectively (upstream relocated the - in-flow cancel button below the composer). */} - {!lastUserMessageId && agentInsights} + 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 ? ( diff --git a/app/src/pages/__tests__/Conversations.render.test.tsx b/app/src/pages/__tests__/Conversations.render.test.tsx index c1bf675ea5..c4df668e61 100644 --- a/app/src/pages/__tests__/Conversations.render.test.tsx +++ b/app/src/pages/__tests__/Conversations.render.test.tsx @@ -2379,6 +2379,71 @@ describe('Conversations — agent task insights panel anchoring (#3717 Bug 2)', }); expect(await screen.findByTestId('agent-task-insights')).toBeInTheDocument(); }); + + it('remounts the standalone insights fallback (resetting its sticky disclosure) when switching between two proactive-only threads (#4944)', async () => { + // Neither thread has a user message (e.g. a proactive-only run), so the + // panel renders through the standalone fallback slot at the bottom of the + // message list — not the per-message anchor keyed by `msg.id` — which is + // the exact slot that previously had no key of its own. + const threadA = makeThread({ id: 'proactive-a', title: 'Proactive A' }); + const threadB = makeThread({ id: 'proactive-b', title: 'Proactive B' }); + mockGetThreads.mockResolvedValue({ threads: [threadA, threadB], count: 2 }); + + let store: ReturnType | undefined; + await act(async () => { + store = await renderConversations({ + thread: { + ...emptyThreadState, + threads: [threadA, threadB], + selectedThreadId: threadA.id, + messagesByThreadId: { [threadA.id]: [], [threadB.id]: [] }, + }, + socket: socketState('connected'), + }); + }); + + // Thread A: a settled (non-running) timeline, so the group defaults to + // collapsed. + await act(async () => { + store!.dispatch( + setToolTimelineForThread({ + threadId: threadA.id, + entries: [{ id: 'a-1', name: 'web_fetch', round: 1, status: 'success' }], + }) + ); + }); + + const panelA = await screen.findByTestId('agent-task-insights'); + expect((panelA as HTMLDetailsElement).open).toBe(false); + + // The user manually expands it — this is the sticky per-instance + // `userOverrideOpen` state from #4942 that must not leak across threads. + const summaryA = panelA.querySelector('summary'); + expect(summaryA).not.toBeNull(); + await act(async () => { + fireEvent.click(summaryA!); + }); + expect((panelA as HTMLDetailsElement).open).toBe(true); + + // Switch straight to a second proactive-only thread with its own settled + // timeline. + await act(async () => { + store!.dispatch(setSelectedThread(threadB.id)); + store!.dispatch( + setToolTimelineForThread({ + threadId: threadB.id, + entries: [{ id: 'b-1', name: 'send_email', round: 1, status: 'success' }], + }) + ); + }); + + const panelB = await screen.findByTestId('agent-task-insights'); + // Keyed by `selectedThreadId ?? 'none'`: switching threads remounts the + // `ToolTimelineBlock` instead of reusing the same instance, so thread B's + // panel starts collapsed again rather than inheriting thread A's + // manually-opened disclosure state. + expect((panelB as HTMLDetailsElement).open).toBe(false); + }); }); describe('Conversations — open-session resume (View work)', () => { From ce8b7e9acf68c396930b8e616a4af94601bafe69 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:48:42 +0530 Subject: [PATCH 43/86] fix(flows): stable issue-order for tool-call timeline (monotonic seq + FIFO result pairing) (#4945) --- .../features/conversations/Conversations.tsx | 2 + .../ProcessingTranscriptView.test.tsx | 1 + .../components/ToolTimelineBlock.tsx | 17 ++- .../AgentProcessSourcePanel.test.tsx | 13 +- .../BackgroundProcessesPanel.test.tsx | 2 +- .../__tests__/ToolTimelineBlock.test.tsx | 86 ++++++++---- .../timeline/ConversationTimeline.test.tsx | 3 +- .../conversations/timeline/selectors.test.ts | 3 +- .../features/human/SubMascotLayer.test.tsx | 5 +- .../__tests__/Conversations.render.test.tsx | 17 ++- app/src/pages/dev/AgentInsightsPreview.tsx | 6 + .../chatRuntimeSlice.subagentStream.test.ts | 1 + .../store/__tests__/chatRuntimeSlice.test.ts | 122 +++++++++++++++++- app/src/store/chatRuntimeSlice.test.ts | 17 ++- app/src/store/chatRuntimeSlice.ts | 82 +++++++++++- .../__tests__/toolTimelineFormatting.test.ts | 2 +- ...lineFormatting.agentPrepareContext.test.ts | 2 +- 17 files changed, 321 insertions(+), 60 deletions(-) diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index 271ddbb659..aa3e6ef244 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -2691,6 +2691,7 @@ const Conversations = ({ id: 'active-tool', name: selectedInferenceStatus.activeTool ?? 'tool', round: selectedInferenceStatus.iteration, + seq: 0, status: 'running', } ).title @@ -2702,6 +2703,7 @@ const Conversations = ({ id: 'active-subagent', name: `subagent:${selectedInferenceStatus.activeSubagent ?? ''}`, round: selectedInferenceStatus.iteration, + seq: 0, status: 'running', } ).title diff --git a/app/src/features/conversations/components/ProcessingTranscriptView.test.tsx b/app/src/features/conversations/components/ProcessingTranscriptView.test.tsx index 94a6926b77..7ff9283815 100644 --- a/app/src/features/conversations/components/ProcessingTranscriptView.test.tsx +++ b/app/src/features/conversations/components/ProcessingTranscriptView.test.tsx @@ -16,6 +16,7 @@ function failedEntry(overrides: Partial = {}): ToolTimelineEn id: 'call-1', name: 'read_file', round: 1, + seq: 0, status: 'error', failure: { class: 'MissingPermission', diff --git a/app/src/features/conversations/components/ToolTimelineBlock.tsx b/app/src/features/conversations/components/ToolTimelineBlock.tsx index d66d926f8b..b78330de51 100644 --- a/app/src/features/conversations/components/ToolTimelineBlock.tsx +++ b/app/src/features/conversations/components/ToolTimelineBlock.tsx @@ -481,7 +481,6 @@ export function ToolTimelineBlock({ liveResponse?: string; }) { const { t } = useT(); - const latestRunningEntryId = [...entries].reverse().find(entry => entry.status === 'running')?.id; // Sticky override for the outer "Agentic task insights" group: `null` means // the user hasn't explicitly toggled it on THIS mount yet, so the group @@ -502,6 +501,18 @@ export function ToolTimelineBlock({ if (entries.length === 0) return null; + // The rows + the parent's streaming response — shared by both the collapsible + // (in-flight) and static (settled) header layouts below. + // Sort by issue order (`seq`), not arrival order: a `tool_args_delta` for a + // later parallel call can reach the store before an earlier call's own + // event, which would otherwise create rows in the wrong order. + // Sort a copy — `entries` may be a state slice other callers still rely on. + const ordered = [...entries].sort((a, b) => a.seq - b.seq); + // "Latest running" must be derived from the same seq-ordered list the rows + // render from — not raw arrival order — or a running row that arrived late + // but sorts earlier (e.g. seq [2, 0, 1]) gets treated as "latest" and the + // wrong step stays expanded/linked in compact chat mode. + const latestRunningEntryId = [...ordered].reverse().find(entry => entry.status === 'running')?.id; const isRunning = latestRunningEntryId != null; const titleLabel = ( @@ -527,11 +538,9 @@ export function ToolTimelineBlock({ ) : null; - // The rows + the parent's streaming response — shared by both the collapsible - // (in-flight) and static (settled) header layouts below. // Coalesce runs of identical, body-less rows (e.g. a retry loop that spawns // the same integrations step 25×) into single `×N` rows before rendering. - const rows = coalesceTimelineEntries(entries); + const rows = coalesceTimelineEntries(ordered); const body = ( <> diff --git a/app/src/features/conversations/components/__tests__/AgentProcessSourcePanel.test.tsx b/app/src/features/conversations/components/__tests__/AgentProcessSourcePanel.test.tsx index cfdcf18902..39a7263696 100644 --- a/app/src/features/conversations/components/__tests__/AgentProcessSourcePanel.test.tsx +++ b/app/src/features/conversations/components/__tests__/AgentProcessSourcePanel.test.tsx @@ -15,6 +15,7 @@ const fetchEntry = (id: string, url: string): ToolTimelineEntry => ({ id, name: 'web_fetch', round: 1, + seq: 0, status: 'success', argsBuffer: JSON.stringify({ url }), }); @@ -82,6 +83,7 @@ describe('AgentProcessSourcePanel', () => { id: 'sa', name: 'subagent:researcher', round: 1, + seq: 0, status: 'success', subagent: { taskId: 'sub-1', @@ -103,8 +105,8 @@ describe('AgentProcessSourcePanel', () => { { id: 'sa-1', name: 'subagent:researcher', round: 1, + seq: 0, status: 'success', subagent: { taskId: 'task-1', @@ -160,6 +163,7 @@ describe('AgentProcessSourcePanel', () => { id: 'sa-scope', name: 'subagent:researcher', round: 1, + seq: 0, status: 'success', subagent: { taskId: 'task-9', @@ -174,7 +178,7 @@ describe('AgentProcessSourcePanel', () => { entries={[ scoped, // A second, unrelated step that must NOT show in the scoped view. - { id: 'other', name: 'web_fetch', round: 1, status: 'success' }, + { id: 'other', name: 'web_fetch', round: 1, seq: 0, status: 'success' }, ]} transcript={[{ kind: 'narration', round: 1, seq: 0, text: 'whole-run narration' }]} scopedEntry={scoped} @@ -195,6 +199,7 @@ describe('AgentProcessSourcePanel', () => { id: 'tool-result-only', name: 'run_code', round: 1, + seq: 0, status: 'success', argsBuffer: '{"command":"pnpm test"}', result: 'exit 0\nAll checks passed.', @@ -211,7 +216,7 @@ describe('AgentProcessSourcePanel', () => { renderPanel( {}} /> ); diff --git a/app/src/features/conversations/components/__tests__/BackgroundProcessesPanel.test.tsx b/app/src/features/conversations/components/__tests__/BackgroundProcessesPanel.test.tsx index 3feb487ab1..6612875187 100644 --- a/app/src/features/conversations/components/__tests__/BackgroundProcessesPanel.test.tsx +++ b/app/src/features/conversations/components/__tests__/BackgroundProcessesPanel.test.tsx @@ -22,7 +22,7 @@ function entry( subagent?: SubagentActivity, name = 'subagent:researcher' ): ToolTimelineEntry { - return { id: `e-${subagent?.taskId ?? name}`, name, round: 0, status, subagent }; + return { id: `e-${subagent?.taskId ?? name}`, name, round: 0, seq: 0, status, subagent }; } describe('selectBackgroundProcesses', () => { diff --git a/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx b/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx index d0c692f342..6743380e65 100644 --- a/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx +++ b/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx @@ -299,11 +299,19 @@ describe('SubagentActivityBlock', () => { describe('ToolTimelineBlock — agentic task insights surface', () => { it('wraps rows in the "Agentic task insights" group and conveys run state on the name', () => { const entries: ToolTimelineEntry[] = [ - { id: 'r', name: 'web_search', round: 1, status: 'running', argsBuffer: '{"query":"f1"}' }, + { + id: 'r', + name: 'web_search', + round: 1, + seq: 0, + status: 'running', + argsBuffer: '{"query":"f1"}', + }, { id: 'd', name: 'file_read', round: 1, + seq: 0, status: 'success', argsBuffer: '{"path":"/a/b.txt"}', }, @@ -324,6 +332,25 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { expect(done.className).not.toContain('animate-pulse'); }); + it('renders rows in seq (issue) order, not array (arrival) order', () => { + // Simulates the out-of-order-arrival bug: a `tool_args_delta` for + // a later parallel call can land — and create its row — before an + // earlier call's own event, so the entries array ends up scrambled + // relative to the order the agent actually issued the calls. `seq` is + // the source of truth for display order; the array position is not. + const entries: ToolTimelineEntry[] = [ + { id: 'third', name: 'run_code', round: 1, seq: 2, status: 'success' }, + { id: 'first', name: 'web_search', round: 1, seq: 0, status: 'success' }, + { id: 'second', name: 'file_read', round: 1, seq: 1, status: 'success' }, + ]; + renderInStore(); + const rows = screen.getAllByTestId('agent-timeline-row'); + expect(rows).toHaveLength(3); + expect(rows[0].textContent).toContain('Searching the web'); + expect(rows[1].textContent).toContain('Reading file'); + expect(rows[2].textContent).toContain('Run Code'); + }); + it('renders nothing for an empty timeline', () => { const { container } = renderInStore(); expect(container.querySelector('[data-testid="agent-task-insights"]')).toBeNull(); @@ -331,7 +358,7 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { it('stays open while running and collapses once settled so a finished run does not dominate', () => { const running: ToolTimelineEntry[] = [ - { id: 'r', name: 'web_search', round: 1, status: 'running' }, + { id: 'r', name: 'web_search', round: 1, seq: 0, status: 'running' }, ]; const { rerender } = renderInStore(); // In flight → the group is open so the live activity is visible. @@ -340,7 +367,7 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { // Settled (no running row) → collapsed by default; the rows stay in the DOM // one click away, but no longer flood the conversation. const settled: ToolTimelineEntry[] = [ - { id: 'r', name: 'web_search', round: 1, status: 'success' }, + { id: 'r', name: 'web_search', round: 1, seq: 0, status: 'success' }, ]; rerender( @@ -371,7 +398,7 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { describe('agentic task insights — sticky user expand/collapse across turns', () => { it('keeps an explicit user expand across a new turn that starts and settles', () => { const turn1Settled: ToolTimelineEntry[] = [ - { id: 't1', name: 'web_search', round: 1, status: 'success' }, + { id: 't1', name: 'web_search', round: 1, seq: 0, status: 'success' }, ]; const { rerender } = renderInStore(); // Default: settled and collapsed (unchanged behaviour). @@ -384,7 +411,7 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { // A new turn/feedback starts streaming onto the SAME mounted block. const turn2Running: ToolTimelineEntry[] = [ ...turn1Settled, - { id: 't2', name: 'file_read', round: 2, status: 'running' }, + { id: 't2', name: 'file_read', round: 2, seq: 1, status: 'running' }, ]; rerender( @@ -397,7 +424,7 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { // involuntarily re-collapse, wiping out the user's choice. const turn2Settled: ToolTimelineEntry[] = [ ...turn1Settled, - { id: 't2', name: 'file_read', round: 2, status: 'success' }, + { id: 't2', name: 'file_read', round: 2, seq: 1, status: 'success' }, ]; rerender( @@ -409,13 +436,13 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { it('leaves the default open-while-running/collapsed-when-settled behaviour unchanged absent any user interaction', () => { const running: ToolTimelineEntry[] = [ - { id: 'r', name: 'web_search', round: 1, status: 'running' }, + { id: 'r', name: 'web_search', round: 1, seq: 0, status: 'running' }, ]; const { rerender } = renderInStore(); expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); const settled: ToolTimelineEntry[] = [ - { id: 'r', name: 'web_search', round: 1, status: 'success' }, + { id: 'r', name: 'web_search', round: 1, seq: 0, status: 'success' }, ]; rerender( @@ -427,7 +454,7 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { it('also persists an explicit user collapse across a new turn (does not force it back open)', () => { const turn1Running: ToolTimelineEntry[] = [ - { id: 't1', name: 'web_search', round: 1, status: 'running' }, + { id: 't1', name: 'web_search', round: 1, seq: 0, status: 'running' }, ]; const { rerender } = renderInStore(); expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); @@ -439,8 +466,8 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { // A new turn starts running — the auto rule alone would force it back // open, but the user's explicit collapse must win. const turn2Running: ToolTimelineEntry[] = [ - { id: 't1', name: 'web_search', round: 1, status: 'success' }, - { id: 't2', name: 'file_read', round: 2, status: 'running' }, + { id: 't1', name: 'web_search', round: 1, seq: 0, status: 'success' }, + { id: 't2', name: 'file_read', round: 2, seq: 1, status: 'running' }, ]; rerender( @@ -457,6 +484,7 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { id: 'd', name: 'web_search', round: 1, + seq: 0, status: 'success', argsBuffer: '{"query":"f1"}', result: 'Top result: https://openhuman.dev', @@ -470,8 +498,8 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { it('makes a row expandable on a result alone and omits the block without one', () => { const entries: ToolTimelineEntry[] = [ // No argsBuffer / detail / subagent — the result is the only body. - { id: 'a', name: 'run_code', round: 1, status: 'success', result: 'exit 0' }, - { id: 'b', name: 'run_code', round: 2, status: 'success' }, + { id: 'a', name: 'run_code', round: 1, seq: 0, status: 'success', result: 'exit 0' }, + { id: 'b', name: 'run_code', round: 2, seq: 0, status: 'success' }, ]; renderInStore(); const outputs = screen.getAllByTestId('tool-result-output'); @@ -481,7 +509,14 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { it('renders the parent live response inside the panel under a Response heading', () => { const entries: ToolTimelineEntry[] = [ - { id: 'r', name: 'web_search', round: 1, status: 'running', argsBuffer: '{"query":"f1"}' }, + { + id: 'r', + name: 'web_search', + round: 1, + seq: 0, + status: 'running', + argsBuffer: '{"query":"f1"}', + }, ]; renderInStore( { it('omits the Response block when there is no live response', () => { const entries: ToolTimelineEntry[] = [ - { id: 'r', name: 'web_search', round: 1, status: 'running' }, + { id: 'r', name: 'web_search', round: 1, seq: 0, status: 'running' }, ]; renderInStore(); expect(screen.queryByTestId('agent-live-response')).toBeNull(); @@ -504,7 +539,7 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { it('strips a leaked envelope from the live response', () => { const entries: ToolTimelineEntry[] = [ - { id: 'r', name: 'web_search', round: 1, status: 'running' }, + { id: 'r', name: 'web_search', round: 1, seq: 0, status: 'running' }, ]; renderInStore( { id: `dup-${i}`, name: 'integrations_agent', round: 1, + seq: 0, status: 'success' as const, })); renderInStore(); @@ -537,12 +573,12 @@ describe('ToolTimelineBlock — coalescing repeated rows', () => { it('does not merge across differing status or the live running row', () => { const entries: ToolTimelineEntry[] = [ - { id: 'a', name: 'integrations_agent', round: 1, status: 'success' }, - { id: 'b', name: 'integrations_agent', round: 1, status: 'success' }, + { id: 'a', name: 'integrations_agent', round: 1, seq: 0, status: 'success' }, + { id: 'b', name: 'integrations_agent', round: 1, seq: 0, status: 'success' }, // Different status breaks the run. - { id: 'c', name: 'integrations_agent', round: 1, status: 'error' }, + { id: 'c', name: 'integrations_agent', round: 1, seq: 0, status: 'error' }, // The live running row is never folded away. - { id: 'd', name: 'integrations_agent', round: 1, status: 'running' }, + { id: 'd', name: 'integrations_agent', round: 1, seq: 0, status: 'running' }, ]; renderInStore(); // success×2 (merged) + error (single) + running (single) = 3 rows. @@ -554,8 +590,8 @@ describe('ToolTimelineBlock — coalescing repeated rows', () => { it('never merges rows that carry a unique result body', () => { const entries: ToolTimelineEntry[] = [ - { id: 'a', name: 'run_code', round: 1, status: 'success', result: 'exit 0' }, - { id: 'b', name: 'run_code', round: 1, status: 'success', result: 'exit 1' }, + { id: 'a', name: 'run_code', round: 1, seq: 0, status: 'success', result: 'exit 0' }, + { id: 'b', name: 'run_code', round: 1, seq: 0, status: 'success', result: 'exit 1' }, ]; renderInStore(); // Both keep their own row — distinct results are never coalesced. @@ -570,6 +606,7 @@ describe('ToolTimelineBlock — subagent rendering', () => { id: 'tid:subagent:sub-1:researcher', name: 'subagent:researcher', round: 1, + seq: 0, status: 'running', subagent: { taskId: 'sub-1', @@ -593,6 +630,7 @@ describe('ToolTimelineBlock — subagent rendering', () => { id: 'plain', name: 'list_threads', round: 0, + seq: 0, status: 'success', }; renderInStore(); @@ -620,6 +658,7 @@ describe('ToolTimelineBlock — worker thread ref status propagation', () => { id: `tid:subagent:task-42:researcher:${status}`, name: 'subagent:researcher', round: 1, + seq: 0, status, detail: WORKER_REF_DETAIL, }; @@ -664,6 +703,7 @@ describe('ToolTimelineBlock — compact chat mode (onViewDetails)', () => { id: 'tl-1', name: 'agent_prepare_context', round: 1, + seq: 0, status: 'success', detail: 'fetch X', result: 'Prepared context from 3 sources.', @@ -673,6 +713,7 @@ describe('ToolTimelineBlock — compact chat mode (onViewDetails)', () => { id: 'sa-1', name: 'subagent:researcher', round: 1, + seq: 0, status: 'running', subagent: { taskId: 'task-1', @@ -713,6 +754,7 @@ describe('ToolTimelineBlock — compact chat mode (onViewDetails)', () => { id: 'sa-done', name: 'subagent:researcher', round: 1, + seq: 0, status: 'success', subagent: { taskId: 'task-2', diff --git a/app/src/features/conversations/timeline/ConversationTimeline.test.tsx b/app/src/features/conversations/timeline/ConversationTimeline.test.tsx index 96fb5a504a..6a5df28779 100644 --- a/app/src/features/conversations/timeline/ConversationTimeline.test.tsx +++ b/app/src/features/conversations/timeline/ConversationTimeline.test.tsx @@ -33,13 +33,14 @@ function agentMsg(id: string, content: string): ThreadMessage { }; } function tool(id: string, name: string, round = 0): ToolTimelineEntry { - return { id, name, round, status: 'success' }; + return { id, name, round, seq: 0, status: 'success' }; } function subagentRow(id: string, taskId: string): ToolTimelineEntry { return { id, name: 'subagent:researcher', round: 0, + seq: 0, status: 'running', subagent: { taskId, agentId: 'researcher', toolCalls: [] }, }; diff --git a/app/src/features/conversations/timeline/selectors.test.ts b/app/src/features/conversations/timeline/selectors.test.ts index 108cb2af0a..64b63b5e84 100644 --- a/app/src/features/conversations/timeline/selectors.test.ts +++ b/app/src/features/conversations/timeline/selectors.test.ts @@ -42,13 +42,14 @@ function tool( round = 0, status: ToolTimelineEntry['status'] = 'success' ): ToolTimelineEntry { - return { id, name, round, status }; + return { id, name, round, seq: 0, status }; } function subagentRow(id: string, taskId: string, round = 0): ToolTimelineEntry { return { id, name: 'subagent:researcher', round, + seq: 0, status: 'running', subagent: { taskId, agentId: 'researcher', toolCalls: [] }, }; diff --git a/app/src/features/human/SubMascotLayer.test.tsx b/app/src/features/human/SubMascotLayer.test.tsx index 9289929834..c2a0285cd0 100644 --- a/app/src/features/human/SubMascotLayer.test.tsx +++ b/app/src/features/human/SubMascotLayer.test.tsx @@ -17,6 +17,7 @@ function subagentEntry(overrides: Partial = {}): ToolTimeline id: 'thread-1:subagent:sub-1:researcher', name: 'subagent:researcher', round: 1, + seq: 0, status: 'running', detail: 'Research the relevant docs.', subagent: { @@ -33,7 +34,7 @@ function subagentEntry(overrides: Partial = {}): ToolTimeline describe('subMascotModelsFromTimeline', () => { it('builds visible models only from subagent timeline rows', () => { const models = subMascotModelsFromTimeline([ - { id: 'thread-1:tool:search', name: 'web_search', round: 1, status: 'running' }, + { id: 'thread-1:tool:search', name: 'web_search', round: 1, seq: 0, status: 'running' }, subagentEntry(), ]); @@ -190,7 +191,7 @@ describe('', () => { it('renders nothing when no subagent rows are present', () => { const { container } = render( ); diff --git a/app/src/pages/__tests__/Conversations.render.test.tsx b/app/src/pages/__tests__/Conversations.render.test.tsx index c4df668e61..060e8bb118 100644 --- a/app/src/pages/__tests__/Conversations.render.test.tsx +++ b/app/src/pages/__tests__/Conversations.render.test.tsx @@ -665,7 +665,9 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => { store!.dispatch( setTurnTimelinesForThread({ threadId: thread.id, - timelines: { 'req-1': [{ id: 'tc-1', name: 'read_file', round: 0, status: 'success' }] }, + timelines: { + 'req-1': [{ id: 'tc-1', name: 'read_file', round: 0, seq: 0, status: 'success' }], + }, }) ); }); @@ -1567,7 +1569,7 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => { store!.dispatch( setToolTimelineForThread({ threadId: thread.id, - entries: [{ id: 'tl-1', name: 'web_fetch', round: 1, status: 'running' }], + entries: [{ id: 'tl-1', name: 'web_fetch', round: 1, seq: 0, status: 'running' }], }) ); }); @@ -1703,7 +1705,7 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => { store!.dispatch( setToolTimelineForThread({ threadId: 'some-other-thread', - entries: [{ id: 'other-1', name: 'web_fetch', round: 1, status: 'running' }], + entries: [{ id: 'other-1', name: 'web_fetch', round: 1, seq: 0, status: 'running' }], }) ); }); @@ -2091,11 +2093,12 @@ describe('Conversations — agent task insights panel anchoring (#3717 Bug 2)', setToolTimelineForThread({ threadId: thread.id, entries: [ - { id: 'tl-1', name: 'web_fetch', round: 1, status: 'success' }, + { id: 'tl-1', name: 'web_fetch', round: 1, seq: 0, status: 'success' }, { id: 'sa-1', name: 'subagent:researcher', round: 1, + seq: 0, status: 'running', subagent: { taskId: 'task-1', agentId: 'researcher', toolCalls: [] }, }, @@ -2187,7 +2190,7 @@ describe('Conversations — agent task insights panel anchoring (#3717 Bug 2)', store!.dispatch( setToolTimelineForThread({ threadId: thread.id, - entries: [{ id: 'tl-1', name: 'web_fetch', round: 1, status: 'success' }], + entries: [{ id: 'tl-1', name: 'web_fetch', round: 1, seq: 0, status: 'success' }], }) ); }); @@ -2243,7 +2246,7 @@ describe('Conversations — agent task insights panel anchoring (#3717 Bug 2)', store!.dispatch( setToolTimelineForThread({ threadId: thread.id, - entries: [{ id: 'tl-1', name: 'web_fetch', round: 1, status: 'running' }], + entries: [{ id: 'tl-1', name: 'web_fetch', round: 1, seq: 0, status: 'running' }], }) ); }); @@ -2364,7 +2367,7 @@ describe('Conversations — agent task insights panel anchoring (#3717 Bug 2)', store!.dispatch( setToolTimelineForThread({ threadId: thread.id, - entries: [{ id: 'tl-1', name: 'web_fetch', round: 1, status: 'cancelled' }], + entries: [{ id: 'tl-1', name: 'web_fetch', round: 1, seq: 0, status: 'cancelled' }], }) ); }); diff --git a/app/src/pages/dev/AgentInsightsPreview.tsx b/app/src/pages/dev/AgentInsightsPreview.tsx index 9ff2cd129e..7aada9d23f 100644 --- a/app/src/pages/dev/AgentInsightsPreview.tsx +++ b/app/src/pages/dev/AgentInsightsPreview.tsx @@ -24,6 +24,7 @@ const RUNNING_ENTRIES: ToolTimelineEntry[] = [ id: 's-slack', name: 'subagent:integrations_agent', round: 1, + seq: 0, status: 'running', sourceToolName: 'slack', subagent: { @@ -44,6 +45,7 @@ const RUNNING_ENTRIES: ToolTimelineEntry[] = [ id: 'e-search', name: 'web_search', round: 1, + seq: 1, status: 'success', argsBuffer: JSON.stringify({ query: 'monaco gp 2026 results' }), }, @@ -51,6 +53,7 @@ const RUNNING_ENTRIES: ToolTimelineEntry[] = [ id: 'e-fetch1', name: 'web_fetch', round: 1, + seq: 2, status: 'success', argsBuffer: JSON.stringify({ url: 'https://news-gazette.com/sport/f1-monaco' }), }, @@ -58,6 +61,7 @@ const RUNNING_ENTRIES: ToolTimelineEntry[] = [ id: 'e-fetch2', name: 'web_fetch', round: 1, + seq: 3, status: 'success', argsBuffer: JSON.stringify({ url: 'https://example.org/standings' }), }, @@ -65,6 +69,7 @@ const RUNNING_ENTRIES: ToolTimelineEntry[] = [ id: 'e-shell', name: 'shell', round: 2, + seq: 4, status: 'success', argsBuffer: JSON.stringify({ command: 'cat report.py | head -20' }), }, @@ -72,6 +77,7 @@ const RUNNING_ENTRIES: ToolTimelineEntry[] = [ id: 'e-err', name: 'file_read', round: 2, + seq: 5, status: 'error', argsBuffer: JSON.stringify({ path: '/tmp/missing.txt' }), }, diff --git a/app/src/store/__tests__/chatRuntimeSlice.subagentStream.test.ts b/app/src/store/__tests__/chatRuntimeSlice.subagentStream.test.ts index 0dd40bde4e..01621bf925 100644 --- a/app/src/store/__tests__/chatRuntimeSlice.subagentStream.test.ts +++ b/app/src/store/__tests__/chatRuntimeSlice.subagentStream.test.ts @@ -18,6 +18,7 @@ function withSubagentRow(): ReturnType { id: ROW_ID, name: 'subagent:researcher', round: 1, + seq: 0, status: 'running', subagent: { taskId: 'sub-1', agentId: 'researcher', toolCalls: [], transcript: [] }, }; diff --git a/app/src/store/__tests__/chatRuntimeSlice.test.ts b/app/src/store/__tests__/chatRuntimeSlice.test.ts index 36cf57ee36..d647138653 100644 --- a/app/src/store/__tests__/chatRuntimeSlice.test.ts +++ b/app/src/store/__tests__/chatRuntimeSlice.test.ts @@ -118,6 +118,7 @@ describe('chatRuntimeSlice', () => { id: 'call-1', name: 'search', round: 1, + seq: 0, status: 'running', argsBuffer: '{"q":"hello"}', }, @@ -126,7 +127,14 @@ describe('chatRuntimeSlice', () => { ); expect(withTimeline.toolTimelineByThread['thread-1']).toEqual([ - { id: 'call-1', name: 'search', round: 1, status: 'running', argsBuffer: '{"q":"hello"}' }, + { + id: 'call-1', + name: 'search', + round: 1, + seq: 0, + status: 'running', + argsBuffer: '{"q":"hello"}', + }, ]); const cleared = reducer(withTimeline, clearToolTimelineForThread({ threadId: 'thread-1' })); @@ -223,6 +231,7 @@ describe('chatRuntimeSlice', () => { id: 'tc-1', name: 'shell', round: 3, + seq: 0, status: 'running', argsBuffer: '{"cmd":"ls"}', displayName: undefined, @@ -478,7 +487,7 @@ describe('chatRuntimeSlice', () => { ), setToolTimelineForThread({ threadId: 'thread-1', - entries: [{ id: 'call-1', name: 'search', round: 1, status: 'running' }], + entries: [{ id: 'call-1', name: 'search', round: 1, seq: 0, status: 'running' }], }) ); @@ -943,9 +952,73 @@ describe('toolCallReceived (Phase 3 reducer-side merge)', () => { const rows = state.toolTimelineByThread['t1']; expect(rows).toHaveLength(1); expect(rows[0].round).toBe(1); + // The row keeps its original `seq` across the upsert — issue order is set + // once, at first creation, and must not shift on a later update event. + expect(rows[0].seq).toBe(0); // Processing pointer is recorded once for the stable callId. expect(state.processingByThread['t1']).toHaveLength(1); }); + + it('assigns a monotonically increasing seq to each newly created row per thread', () => { + let state = reducer( + undefined, + toolCallReceived({ threadId: 't1', round: 0, toolName: 'search', toolCallId: 'c1' }) + ); + state = reducer( + state, + toolCallReceived({ threadId: 't1', round: 0, toolName: 'search', toolCallId: 'c2' }) + ); + state = reducer( + state, + toolCallReceived({ threadId: 't1', round: 0, toolName: 'search', toolCallId: 'c3' }) + ); + const rows = state.toolTimelineByThread['t1']; + expect(rows.map(r => r.seq)).toEqual([0, 1, 2]); + // A different thread gets its own independent counter. + state = reducer( + state, + toolCallReceived({ threadId: 't2', round: 0, toolName: 'search', toolCallId: 'd1' }) + ); + expect(state.toolTimelineByThread['t2'][0].seq).toBe(0); + }); + + it('keeps the seq from first creation when args arrive before the tool_call event', () => { + // A `tool_args_delta` for call B can race ahead of call A's `tool_call` + // event when the agent issues two parallel calls in one turn. The row + // created by whichever event lands first gets seq 0; the row's `seq` + // must not change when the (later) sibling event for the same call + // arrives and only updates the existing row in place. + let state = reducer( + undefined, + toolArgsDeltaReceived({ + threadId: 't1', + round: 0, + delta: '{"q":"b"}', + toolName: 'search', + toolCallId: 'call-b', + }) + ); + state = reducer( + state, + toolCallReceived({ threadId: 't1', round: 0, toolName: 'search', toolCallId: 'call-a' }) + ); + // call-b's row was created first (seq 0) even though call-a's + // `tool_call` event is semantically "first" in the pair — the point is + // that whichever event creates the row locks in the seq, and a later + // `toolCallReceived` for that same id does not reassign it. + state = reducer( + state, + toolCallReceived({ threadId: 't1', round: 0, toolName: 'search', toolCallId: 'call-b' }) + ); + const rows = state.toolTimelineByThread['t1']; + const rowB = rows.find(r => r.id === 'call-b'); + const rowA = rows.find(r => r.id === 'call-a'); + expect(rowB?.seq).toBe(0); + expect(rowA?.seq).toBe(1); + // Re-receiving call-b's tool_call event (args arrived first) must not + // bump its seq to a later value. + expect(rowB?.seq).toBe(0); + }); }); describe('toolResultReceived (Phase 3 reducer-side merge)', () => { @@ -954,7 +1027,7 @@ describe('toolResultReceived (Phase 3 reducer-side merge)', () => { undefined, setToolTimelineForThread({ threadId: 't1', - entries: [{ id: 'call-1', name: 'shell', round: 0, status: 'running' }], + entries: [{ id: 'call-1', name: 'shell', round: 0, seq: 0, status: 'running' }], }) ); @@ -976,7 +1049,7 @@ describe('toolResultReceived (Phase 3 reducer-side merge)', () => { }); }); - it('falls back to the newest running row of the same name+round when no id matches', () => { + it('falls back to the (only) running row of the same name+round when no id matches', () => { const state = reducer( withRunningRow(), toolResultReceived({ threadId: 't1', round: 0, toolName: 'shell', success: false }) @@ -984,6 +1057,42 @@ describe('toolResultReceived (Phase 3 reducer-side merge)', () => { expect(state.toolTimelineByThread['t1'][0].status).toBe('error'); }); + it('FIFO: settles the oldest (not newest) running row of the same name+round when no id matches', () => { + // Two parallel `get_tool_contract` calls with the same name+round and no + // toolCallId on the result (mirrors a legacy/incomplete socket payload). + // The fallback must settle the row that was issued FIRST (lowest seq), + // not the most recently created one — otherwise a result for the first + // call can incorrectly settle the second call's still-running row. + let state = reducer( + undefined, + setToolTimelineForThread({ + threadId: 't1', + entries: [ + { id: 'call-old', name: 'get_tool_contract', round: 0, seq: 0, status: 'running' }, + { id: 'call-new', name: 'get_tool_contract', round: 0, seq: 1, status: 'running' }, + ], + }) + ); + state = reducer( + state, + toolResultReceived({ + threadId: 't1', + round: 0, + toolName: 'get_tool_contract', + success: true, + output: 'first result', + }) + ); + const rows = state.toolTimelineByThread['t1']; + const oldRow = rows.find(r => r.id === 'call-old'); + const newRow = rows.find(r => r.id === 'call-new'); + expect(oldRow?.status).toBe('success'); + expect(oldRow?.result).toBe('first result'); + // The newer call's row is untouched — still running, awaiting its own result. + expect(newRow?.status).toBe('running'); + expect(newRow?.result).toBeUndefined(); + }); + it('is a no-op when nothing matches (mirrors the provider changed-guard)', () => { const before = withRunningRow(); const after = reducer( @@ -1105,6 +1214,7 @@ describe('subagent event reducers (Phase 3)', () => { id: 'spawn-1', name: 'spawn_subagent', round: 0, + seq: 0, status: 'running', detail: 'go research', }, @@ -1259,7 +1369,9 @@ describe('toolArgsDeltaReceived (Phase 3 reducer-side merge)', () => { undefined, setToolTimelineForThread({ threadId: 't1', - entries: [{ id: 'r1', name: 'search', round: 0, status: 'running', argsBuffer: '{' }], + entries: [ + { id: 'r1', name: 'search', round: 0, seq: 0, status: 'running', argsBuffer: '{' }, + ], }) ); state = reducer( diff --git a/app/src/store/chatRuntimeSlice.test.ts b/app/src/store/chatRuntimeSlice.test.ts index 769a8baaae..316c7da7fd 100644 --- a/app/src/store/chatRuntimeSlice.test.ts +++ b/app/src/store/chatRuntimeSlice.test.ts @@ -417,6 +417,7 @@ describe('chatRuntimeSlice queue status', () => { id: 't-dup:subagent:run-1:spawn_subagent', name: 'subagent:tinyplace_agent', round: 1, + seq: 0, status: 'running', subagent: { taskId: 'run-1', agentId: 'tinyplace_agent', toolCalls: [] }, }, @@ -499,6 +500,7 @@ describe('hydrateRuntimeFromSnapshot — sub-agent prose persistence', () => { id: 't9:subagent:task-x:spawn_subagent', name: 'subagent:researcher', round: 1, + seq: 0, status: 'running', subagent: { taskId: 'task-x', @@ -655,8 +657,15 @@ describe('hydrateRuntimeFromSnapshot — live-driver guard', () => { setToolTimelineForThread({ threadId: 't-live', entries: [ - { id: 'c1', name: 'web_search', round: 1, status: 'success', result: 'found 3 hits' }, - { id: 'c2', name: 'read_file', round: 2, status: 'running' }, + { + id: 'c1', + name: 'web_search', + round: 1, + seq: 0, + status: 'success', + result: 'found 3 hits', + }, + { id: 'c2', name: 'read_file', round: 2, seq: 0, status: 'running' }, ], }) ); @@ -803,7 +812,7 @@ describe('hydrateRuntimeFromSnapshot — streaming/timeline race guard', () => { store.dispatch( setToolTimelineForThread({ threadId: 't-settled', - entries: [{ id: 'c-live', name: 'read_file', round: 1, status: 'success' }], + entries: [{ id: 'c-live', name: 'read_file', round: 1, seq: 0, status: 'success' }], }) ); @@ -830,7 +839,7 @@ describe('hydrateRuntimeFromSnapshot — streaming/timeline race guard', () => { store.dispatch( setToolTimelineForThread({ threadId: 't-crashed', - entries: [{ id: 'c-stale', name: 'read_file', round: 1, status: 'running' }], + entries: [{ id: 'c-stale', name: 'read_file', round: 1, seq: 0, status: 'running' }], }) ); diff --git a/app/src/store/chatRuntimeSlice.ts b/app/src/store/chatRuntimeSlice.ts index d0cad6d808..7e01b5fbf6 100644 --- a/app/src/store/chatRuntimeSlice.ts +++ b/app/src/store/chatRuntimeSlice.ts @@ -287,6 +287,16 @@ export interface ToolTimelineEntry { id: string; name: string; round: number; + /** + * Monotonic per-thread issue-order index, assigned once when the row is + * FIRST created (by `toolCallReceived`, `toolArgsDeltaReceived`, or + * `subagentSpawned`) from {@link ChatRuntimeState.toolTimelineSeqByThread}. + * Unlike arrival order — which a `tool_args_delta` for a later parallel + * call can race ahead of — `seq` reflects the order the agent actually + * issued the calls, so the timeline can be sorted deterministically + * (see `ToolTimelineBlock`) regardless of socket delivery order. + */ + seq: number; status: ToolTimelineEntryStatus; argsBuffer?: string; displayName?: string; @@ -612,6 +622,15 @@ interface ChatRuntimeState { */ parallelRequestThreads: Record; toolTimelineByThread: Record; + /** + * Per-thread monotonic counter backing {@link ToolTimelineEntry.seq}. Bumped + * once per NEW row created in {@link toolTimelineByThread} (never on an + * update to an existing row), so `seq` always reflects issue order even + * when socket events for parallel tool calls arrive out of order. Reset + * alongside the timeline itself so a new turn/thread starts counting from + * zero. + */ + toolTimelineSeqByThread: Record; /** * Per-turn tool timelines for *past* (settled) turns of a thread, keyed * `threadId -> requestId -> entries`. Hydrated from `turn_state_history` on @@ -705,6 +724,7 @@ const initialState: ChatRuntimeState = { parallelStreamsByThread: {}, parallelRequestThreads: {}, toolTimelineByThread: {}, + toolTimelineSeqByThread: {}, turnTimelinesByThread: {}, processingByThread: {}, taskBoardByThread: {}, @@ -841,11 +861,23 @@ function subagentActivityFromPersisted(activity: PersistedSubagentActivity): Sub }; } -function toolTimelineFromPersisted(entry: PersistedToolTimelineEntry): ToolTimelineEntry { +/** + * `seq` defaults to the array index the caller maps over — persisted + * `toolTimeline` order IS issue order (the core appends rows as it issues + * calls), so the index is a faithful stand-in for the live monotonic + * counter. Callers that hydrate the *live* timeline (as opposed to a past, + * settled turn) additionally seed {@link ChatRuntimeState.toolTimelineSeqByThread} + * with the row count so subsequent live events keep counting up from there. + */ +function toolTimelineFromPersisted( + entry: PersistedToolTimelineEntry, + seq: number +): ToolTimelineEntry { return { id: entry.id, name: entry.name, round: entry.round, + seq, status: entry.status, argsBuffer: entry.argsBuffer, displayName: entry.displayName, @@ -917,7 +949,7 @@ function timelineStatusFromRun(status: AgentRun['status']): ToolTimelineEntrySta } } -function timelineEntryFromRun(run: AgentRun): ToolTimelineEntry | null { +function timelineEntryFromRun(run: AgentRun, seq: number): ToolTimelineEntry | null { if (!['subagent', 'worker_thread', 'workflow_child', 'team_member'].includes(run.kind)) { return null; } @@ -931,6 +963,7 @@ function timelineEntryFromRun(run: AgentRun): ToolTimelineEntry | null { id: `subagent:${run.id}`, name: `subagent:${agentId}`, round: 0, + seq, status: timelineStatusFromRun(run.status), displayName, detail: run.summary ?? run.error ?? undefined, @@ -1035,6 +1068,7 @@ const chatRuntimeSlice = createSlice({ }, clearToolTimelineForThread: (state, action: PayloadAction<{ threadId: string }>) => { delete state.toolTimelineByThread[action.payload.threadId]; + delete state.toolTimelineSeqByThread[action.payload.threadId]; delete state.processingByThread[action.payload.threadId]; }, /** @@ -1122,11 +1156,14 @@ const chatRuntimeSlice = createSlice({ detail: displayDetail ?? prev.detail, }); } else { + const seq = state.toolTimelineSeqByThread[threadId] ?? 0; + state.toolTimelineSeqByThread[threadId] = seq + 1; entries.push( decorateEntry({ id: rowId, name: toolName, round, + seq, status: 'running', displayName: displayLabel, detail: displayDetail, @@ -1176,7 +1213,14 @@ const chatRuntimeSlice = createSlice({ return; } } - for (let i = entries.length - 1; i >= 0; i -= 1) { + // FIFO, not LIFO: entries are appended in `seq` (issue) order and never + // reordered in place, so scanning forward from index 0 finds the OLDEST + // still-running row of this name+round — settling the call that was + // actually issued first. A backward (newest-first) scan mis-pairs a + // result with the wrong row when 2+ calls with the same name are + // in-flight in parallel (e.g. `get_tool_contract` ×2) and results land + // out of order. + for (let i = 0; i < entries.length; i += 1) { const entry = entries[i]; if (entry.status === 'running' && entry.name === toolName && entry.round === round) { entry.status = status; @@ -1272,11 +1316,14 @@ const chatRuntimeSlice = createSlice({ name: prev.name.length === 0 && toolName ? toolName : prev.name, }); } else { + const seq = state.toolTimelineSeqByThread[threadId] ?? 0; + state.toolTimelineSeqByThread[threadId] = seq + 1; entries.push( decorateEntry({ id: toolCallId ?? '', name: toolName ?? '', round, + seq, status: 'running', argsBuffer: delta, }) @@ -1326,11 +1373,14 @@ const chatRuntimeSlice = createSlice({ const spawnIdx = entries.findIndex(e => e.id === pending.spawnEntryId); if (spawnIdx >= 0) entries.splice(spawnIdx, 1); } + const seq = state.toolTimelineSeqByThread[threadId] ?? 0; + state.toolTimelineSeqByThread[threadId] = seq + 1; entries.push( decorateEntry({ id: rowId, name: `subagent:${agentId}`, round, + seq, status: 'running', detail: pending.prompt, sourceToolName: pending.sourceToolName, @@ -1818,6 +1868,7 @@ const chatRuntimeSlice = createSlice({ delete state.parallelStreamsByThread[action.payload.threadId]; } delete state.toolTimelineByThread[action.payload.threadId]; + delete state.toolTimelineSeqByThread[action.payload.threadId]; delete state.processingByThread[action.payload.threadId]; delete state.taskBoardByThread[action.payload.threadId]; delete state.inferenceTurnLifecycleByThread[action.payload.threadId]; @@ -1840,6 +1891,7 @@ const chatRuntimeSlice = createSlice({ state.parallelStreamsByThread = {}; state.parallelRequestThreads = {}; state.toolTimelineByThread = {}; + state.toolTimelineSeqByThread = {}; state.turnTimelinesByThread = {}; state.processingByThread = {}; state.taskBoardByThread = {}; @@ -2009,8 +2061,14 @@ const chatRuntimeSlice = createSlice({ // (no-op for an already-completed snapshot whose rows are terminal). state.toolTimelineByThread[threadId] = preserveLiveSubagentProse( state.toolTimelineByThread[threadId], - snapshot.toolTimeline.map(toolTimelineFromPersisted).map(settleOrphanedTimelineEntry) + snapshot.toolTimeline + .map((e, seq) => toolTimelineFromPersisted(e, seq)) + .map(settleOrphanedTimelineEntry) ); + // Persisted order is issue order — seed the live counter with the + // row count so events arriving after this hydration keep counting + // up rather than restarting at 0 and colliding with existing seqs. + state.toolTimelineSeqByThread[threadId] = snapshot.toolTimeline.length; } state.processingByThread[threadId] = snapshot.transcript ?? []; return; @@ -2040,8 +2098,11 @@ const chatRuntimeSlice = createSlice({ state.toolTimelineByThread[threadId] = preserveLiveSubagentProse( state.toolTimelineByThread[threadId], - snapshot.toolTimeline.map(toolTimelineFromPersisted) + snapshot.toolTimeline.map((e, seq) => toolTimelineFromPersisted(e, seq)) ); + // 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 ?? []; }, /** @@ -2065,8 +2126,13 @@ const chatRuntimeSlice = createSlice({ existing.map(entry => entry.subagent?.taskId).filter(Boolean) as string[] ); for (const run of runs) { - const entry = timelineEntryFromRun(run); + // Ledger rows are historical/durable, not live-issued — assign the + // next counter value like any other newly-created row so they still + // get a stable, monotonically increasing `seq` for sorting. + const seq = state.toolTimelineSeqByThread[threadId] ?? 0; + const entry = timelineEntryFromRun(run, seq); if (!entry || byId.has(entry.id) || liveTaskIds.has(run.id)) continue; + state.toolTimelineSeqByThread[threadId] = seq + 1; byId.set(entry.id, entry); } state.toolTimelineByThread[threadId] = Array.from(byId.values()); @@ -2200,7 +2266,9 @@ export const fetchAndHydrateTurnHistory = createAsyncThunk( 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(toolTimelineFromPersisted); + timelines[turn.requestId] = turn.toolTimeline.map((e, seq) => + toolTimelineFromPersisted(e, seq) + ); } turnStateLog( 'hydrated turn history thread=%s turns=%d', diff --git a/app/src/utils/__tests__/toolTimelineFormatting.test.ts b/app/src/utils/__tests__/toolTimelineFormatting.test.ts index a08f28bec4..0ccb876094 100644 --- a/app/src/utils/__tests__/toolTimelineFormatting.test.ts +++ b/app/src/utils/__tests__/toolTimelineFormatting.test.ts @@ -14,7 +14,7 @@ import { } from '../toolTimelineFormatting'; function entry(overrides: Partial): ToolTimelineEntry { - return { id: 'x', name: 'delegate_notion', round: 1, status: 'running', ...overrides }; + return { id: 'x', name: 'delegate_notion', round: 1, seq: 0, status: 'running', ...overrides }; } describe('formatTimelineEntry', () => { diff --git a/app/src/utils/toolTimelineFormatting.agentPrepareContext.test.ts b/app/src/utils/toolTimelineFormatting.agentPrepareContext.test.ts index 1c95178b5e..a892b230e4 100644 --- a/app/src/utils/toolTimelineFormatting.agentPrepareContext.test.ts +++ b/app/src/utils/toolTimelineFormatting.agentPrepareContext.test.ts @@ -4,7 +4,7 @@ import type { ToolTimelineEntry } from '../store/chatRuntimeSlice'; import { formatTimelineEntry, formatToolName } from './toolTimelineFormatting'; function entry(partial: Partial & { name: string }): ToolTimelineEntry { - return { id: 'e1', round: 0, status: 'running', ...partial }; + return { id: 'e1', round: 0, seq: 0, status: 'running', ...partial }; } describe('toolTimelineFormatting — agent_prepare_context / context_scout', () => { From 6bd9514c0967e04ddc7d24f2410d25a8e13572b2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:39:19 +0300 Subject: [PATCH 44/86] feat(agent): public accessor for last-turn usage totals (#4940) --- src/openhuman/agent/harness/mod.rs | 3 +- .../agent/harness/session/runtime.rs | 16 ++++ src/openhuman/agent/harness/session/tests.rs | 78 +++++++++++++++++++ .../agent/harness/turn_subagent_usage.rs | 22 +++--- 4 files changed, 107 insertions(+), 12 deletions(-) diff --git a/src/openhuman/agent/harness/mod.rs b/src/openhuman/agent/harness/mod.rs index 393eda4b40..4a7bb22928 100644 --- a/src/openhuman/agent/harness/mod.rs +++ b/src/openhuman/agent/harness/mod.rs @@ -44,7 +44,7 @@ pub mod task_recency_context; pub(crate) mod tool_filter; pub(crate) mod tool_result_artifacts; pub mod turn_attachments_context; -pub(crate) mod turn_subagent_usage; +pub mod turn_subagent_usage; pub use agent_graph::{AgentGraph, AgentTurnRequest, AgentTurnResult, AgentTurnUsage}; pub use definition::{ @@ -60,6 +60,7 @@ pub use sandbox_context::{current_sandbox_mode, with_current_sandbox_mode}; pub(crate) use spawn_depth_context::{current_spawn_depth, with_spawn_depth, MAX_SPAWN_DEPTH}; pub use subagent_runner::{run_subagent, SubagentRunError, SubagentRunOptions}; pub use task_recency_context::{current_task_recency_window, with_task_recency_window}; +pub use turn_subagent_usage::{LastTurnUsage, SubagentUsageEntry}; pub(crate) use graph::run_channel_turn_via_graph; pub(crate) use instructions::build_tool_instructions_filtered; diff --git a/src/openhuman/agent/harness/session/runtime.rs b/src/openhuman/agent/harness/session/runtime.rs index 805e0065cc..e75bcf4a7d 100644 --- a/src/openhuman/agent/harness/session/runtime.rs +++ b/src/openhuman/agent/harness/session/runtime.rs @@ -393,6 +393,22 @@ impl Agent { std::mem::take(&mut self.last_turn_citations) } + /// Borrow the holistic token/cost/context totals for the latest completed + /// turn (parent + sub-agents) **without consuming them**. `None` until a + /// turn has run. + /// + /// This is the public, non-draining counterpart to + /// [`take_last_turn_usage_totals`](Self::take_last_turn_usage_totals): a + /// downstream crate embedding OpenHuman as a library (e.g. the OpenCompany + /// hosting platform's cost-metering hook) can read per-turn token and USD + /// totals after [`Agent::turn`](crate::openhuman::agent::Agent) returns, + /// while leaving the value in place for the web-channel drain path. + pub fn last_turn_usage( + &self, + ) -> Option<&crate::openhuman::agent::harness::turn_subagent_usage::LastTurnUsage> { + self.last_turn_usage_totals.as_ref() + } + /// Drain and return the holistic token/cost/context totals for the latest /// completed turn (parent + sub-agents). `None` until a turn has run. /// Consumed by web-channel delivery to populate the `chat_done` usage fields. diff --git a/src/openhuman/agent/harness/session/tests.rs b/src/openhuman/agent/harness/session/tests.rs index 6f7b655c9d..992c028d67 100644 --- a/src/openhuman/agent/harness/session/tests.rs +++ b/src/openhuman/agent/harness/session/tests.rs @@ -692,6 +692,84 @@ async fn turn_without_tools_returns_text() { assert_eq!(response, "hello"); } +/// The public [`Agent::last_turn_usage`] accessor peeks the per-turn +/// token/cost totals **without draining** them, so a downstream crate +/// embedding OpenHuman as a library (e.g. the OpenCompany hosting platform's +/// cost-metering hook) can read usage after a turn while the existing +/// web-channel `take_last_turn_usage_totals` drain path still works. +#[tokio::test] +async fn last_turn_usage_is_public_and_non_draining() { + let workspace = tempfile::TempDir::new().expect("temp workspace"); + let workspace_path = workspace.path().to_path_buf(); + + let provider = Box::new(MockProvider { + responses: Mutex::new(vec![crate::openhuman::inference::provider::ChatResponse { + text: Some("hello".into()), + tool_calls: vec![], + usage: Some(crate::openhuman::inference::provider::UsageInfo { + input_tokens: 123, + output_tokens: 45, + context_window: 8000, + charged_amount_usd: 0.01, + ..Default::default() + }), + reasoning_content: None, + }]), + }); + + 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, &workspace_path).unwrap(), + ); + + let mut agent = Agent::builder() + .provider(provider) + .tools(vec![Box::new(MockTool)]) + .memory(mem) + .tool_dispatcher(Box::new(XmlToolDispatcher)) + .workspace_dir(workspace_path) + .build() + .unwrap(); + + // No turn has run yet — nothing to report. + assert!(agent.last_turn_usage().is_none()); + + let response = agent.turn("hi").await.unwrap(); + assert_eq!(response, "hello"); + + // The accessor now yields totals, and the return type's fields are all + // publicly readable (this closure would not compile if they were not). + let peeked: crate::openhuman::agent::harness::LastTurnUsage = { + let usage = agent + .last_turn_usage() + .expect("usage should be populated after a turn"); + crate::openhuman::agent::harness::LastTurnUsage { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + cached_input_tokens: usage.cached_input_tokens, + cost_usd: usage.cost_usd, + context_window: usage.context_window, + subagents: usage.subagents.clone(), + } + }; + + // Peeking must not consume: a second read returns the same snapshot. + assert_eq!(agent.last_turn_usage(), Some(&peeked)); + + // The internal web-channel drain still sees the very same value, proving + // the borrow accessor left it untouched. + let drained = agent + .take_last_turn_usage_totals() + .expect("drain should still yield the totals the borrow peeked"); + assert_eq!(drained, peeked); + + // After the drain the peek accessor reports nothing, as expected. + assert!(agent.last_turn_usage().is_none()); +} + #[tokio::test] async fn turn_with_native_dispatcher_handles_tool_results_variant() { let workspace = tempfile::TempDir::new().expect("temp workspace"); diff --git a/src/openhuman/agent/harness/turn_subagent_usage.rs b/src/openhuman/agent/harness/turn_subagent_usage.rs index c9649fc38e..91314036c7 100644 --- a/src/openhuman/agent/harness/turn_subagent_usage.rs +++ b/src/openhuman/agent/harness/turn_subagent_usage.rs @@ -30,10 +30,10 @@ use super::subagent_runner::SubagentUsage; /// One sub-agent's spend, tagged with its identity for the per-child breakdown. #[derive(Debug, Clone, PartialEq)] -pub(crate) struct SubagentUsageEntry { - pub(crate) task_id: String, - pub(crate) agent_id: String, - pub(crate) usage: SubagentUsage, +pub struct SubagentUsageEntry { + pub task_id: String, + pub agent_id: String, + pub usage: SubagentUsage, } /// Holistic token/cost accounting for a single completed turn, including any @@ -42,22 +42,22 @@ pub(crate) struct SubagentUsageEntry { /// event so the UI footer can show session tokens, context-window utilisation, /// USD cost, and a per-sub-agent hover breakdown. #[derive(Debug, Clone, Default, PartialEq)] -pub(crate) struct LastTurnUsage { +pub struct LastTurnUsage { /// Input (prompt) tokens for the turn, parent + sub-agents. - pub(crate) input_tokens: u64, + pub input_tokens: u64, /// Output (completion) tokens for the turn, parent + sub-agents. - pub(crate) output_tokens: u64, + pub output_tokens: u64, /// Cached-input tokens for the turn, parent + sub-agents. - pub(crate) cached_input_tokens: u64, + pub cached_input_tokens: u64, /// USD cost for the turn (backend-charged where available, else estimated), /// parent + sub-agents. - pub(crate) cost_usd: f64, + pub cost_usd: f64, /// The model's context window for this turn (`0` when unknown, e.g. a cloud /// model whose window the core couldn't resolve). Lets the UI show real /// context utilisation instead of a hard-coded default. - pub(crate) context_window: u64, + pub context_window: u64, /// Per-sub-agent spend gathered during the turn, for the hover breakdown. - pub(crate) subagents: Vec, + pub subagents: Vec, } /// Shared, mutable list of sub-agent spend gathered during one parent turn. From b8aaaa8d886d129bcff69f2e0f74242b4908be3e Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:19:41 +0530 Subject: [PATCH 45/86] fix(conversations): add required seq to ToolTimelineEntry test fixtures (fixes red main, #4948) (#4949) --- app/src/pages/__tests__/Conversations.render.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/pages/__tests__/Conversations.render.test.tsx b/app/src/pages/__tests__/Conversations.render.test.tsx index 060e8bb118..2eccd61320 100644 --- a/app/src/pages/__tests__/Conversations.render.test.tsx +++ b/app/src/pages/__tests__/Conversations.render.test.tsx @@ -2411,7 +2411,7 @@ describe('Conversations — agent task insights panel anchoring (#3717 Bug 2)', store!.dispatch( setToolTimelineForThread({ threadId: threadA.id, - entries: [{ id: 'a-1', name: 'web_fetch', round: 1, status: 'success' }], + entries: [{ id: 'a-1', name: 'web_fetch', round: 1, seq: 0, status: 'success' }], }) ); }); @@ -2435,7 +2435,7 @@ describe('Conversations — agent task insights panel anchoring (#3717 Bug 2)', store!.dispatch( setToolTimelineForThread({ threadId: threadB.id, - entries: [{ id: 'b-1', name: 'send_email', round: 1, status: 'success' }], + entries: [{ id: 'b-1', name: 'send_email', round: 1, seq: 0, status: 'success' }], }) ); }); From 4b0fd25f7ec85f2816a7d476ab5ea0c2cd3467ea Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:24:31 +0530 Subject: [PATCH 46/86] fix(app): forward voice + tokenjuice-treesitter to the desktop build (#4916) Co-authored-by: Steven Enamakel --- app/src-tauri/Cargo.lock | 60 ++++++++++++++++++++++++++++++++++++++++ app/src-tauri/Cargo.toml | 1 + 2 files changed, 61 insertions(+) diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 34e997c970..ff79b41969 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -8371,6 +8371,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "strict-num" version = "0.1.1" @@ -9337,6 +9343,10 @@ dependencies = [ "sha2 0.11.0", "thiserror 2.0.18", "tokio", + "tree-sitter", + "tree-sitter-python", + "tree-sitter-rust", + "tree-sitter-typescript", "unicode-segmentation", "unicode-width", ] @@ -9787,6 +9797,56 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "tree-sitter" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1c71c1c4cc0920b20d6b0f6572e7682cd07a6a2faec71067a31fa394c586df" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-python" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "try-lock" version = "0.2.5" diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 4fbdab7e8b..f9714f4621 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -158,6 +158,7 @@ cef = { version = "=146.4.1", default-features = false } openhuman_core = { path = "../..", package = "openhuman", default-features = false, features = [ "media", "voice", + "tokenjuice-treesitter", "web3", ] } tinyjuice = { version = "0.2.1", default-features = false } From 4f3a3615fd305a4a177d7df0f2d920577e32660a Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:47:27 +0530 Subject: [PATCH 47/86] feat(core): compile-time meet feature gate (#4800) (#4915) Co-authored-by: Steven Enamakel --- AGENTS.md | 12 ++++++++ Cargo.toml | 16 +++++++++- app/src-tauri/Cargo.toml | 1 + src/core/all.rs | 10 ++++-- src/core/all_tests.rs | 37 ++++++++++++++++++++++ src/openhuman/agent_meetings/mod.rs | 25 +++++++++++++++ src/openhuman/agent_meetings/stub.rs | 46 ++++++++++++++++++++++++++++ src/openhuman/meet_agent/mod.rs | 38 +++++++++++++++++++++++ src/openhuman/mod.rs | 1 + 9 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 src/openhuman/agent_meetings/stub.rs diff --git a/AGENTS.md b/AGENTS.md index 731e96b7f7..e9bc252853 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -235,6 +235,7 @@ GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml \ | `voice` | ON | `openhuman::voice` + `openhuman::audio_toolkit` domains — STT/TTS providers, dictation server, always-on listening, podcast audio + email | `hound`, `lettre` | | `web3` | ON | `openhuman::wallet` + `openhuman::web3` + `openhuman::x402` domains — crypto wallet (multi-chain sign/broadcast), swaps/bridges/dapp calls, x402 machine payments | `bitcoin`, `curve25519-dalek` | | `media` | ON | `openhuman::media_generation` (the `media_generate_*` agent tools) + `openhuman::image` scaffold | none (surface-only) | +| `meet` | ON | `openhuman::meet` (join-URL validation) + `openhuman::meet_agent` (live STT/LLM/TTS loop) + `openhuman::agent_meetings` (backend-delegated Meet bot over Socket.IO) | none — see note | **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. @@ -243,6 +244,17 @@ GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml \ **`web3` gate — first gate that sheds real crypto deps.** Same facade pattern: `pub mod wallet;` / `pub mod web3;` / `pub mod x402;` stay always-compiled, real submodules are `#[cfg(feature = "web3")]`, and each domain's `stub.rs` re-exposes the always-on caller surface with disabled-error / empty bodies. When off, the wallet/web3/x402 controllers are unregistered, the web3 swap/bridge/dapp agent tools are absent (via `all_web3_agent_tools()` → empty), and the exclusive `bitcoin` (BTC P2WPKH PSBT) + `curve25519-dalek` (Solana off-curve ATA) deps are dropped. **tinyplace on-chain payments + Polymarket writes degrade to graceful "wallet disabled" errors** (the tinyplace comms path and the core itself are unaffected — `tinyplace::signer` still works via ed25519). The stubs cover `WALLET_NOT_CONFIGURED_MESSAGE`, `status`, `secret_material`, `WalletChain`, `prepare_transfer`/`execute_prepared` (+ param/result types), `solana_cluster`/`SolanaCluster`/`tinyplace_solana_rpc_endpoints`, `tinyplace_signer_seed`, `wallet::rpc::{redact_rpc_url, with_tinyplace_solana_endpoints}`, and the `all_*_registered_controllers`/`all_*_controller_schemas`/`all_web3_agent_tools` entry points. Two caller families still need per-call `#[cfg(feature = "web3")]` because they name concrete gated types rather than a stubbable aggregator: the six `Wallet*Tool` + `X402RequestTool` registrations in `tools/ops.rs`, the `wallet::tools::*` glob in `tools/mod.rs`, and the x402 402-retry path in `tools/impl/network/http_request.rs` (with the feature off a 402 returns to the caller unpaid). **Does NOT drop `ethers-core` / `ethers-signers` / `coins-bip39` / `bs58` / `ed25519-dalek` / `ripemd`** — those are shared with the Polymarket tools (`tools/impl/network/polymarket*`, `clob_auth`) + tinyplace + orchestration and stay always-on. Run the disabled build (`--no-default-features --features tokenjuice-treesitter`) before pushing any change to the wallet/web3/x402 surface — it is the only drift catcher. **Leaf-gate variant (`media`, #4804).** Unlike `voice`, the `media` gate needs **no** stub facade: `media_generation` has a single caller (the `build_media_tools` call in `src/openhuman/tools/ops.rs`, itself `#[cfg(feature = "media")]`) and `openhuman::image` is unwired scaffold (#2997), so both modules are simply `#[cfg(feature = "media")] pub mod …`. It is a **surface-only** gate: media generation is backend-proxied (`reqwest`, shared) and the `image` crate is shared with channel upload, so no exclusive deps are shed — the issue's "sheds media processing dependencies" / "controllers unregistered" DoD lines are superseded (Media is agent-tools-only; no controller/store/subscriber is tagged `Media`). When a gated domain is a true leaf, prefer this over the facade+stub. +**`meet` gate (#4800)** — uses all three module patterns, one per domain, chosen by the rule *"does always-compiled code reach a non-registration symbol in here?"*: + +- **`meet` → leaf-gate.** `#[cfg(feature = "meet")] pub mod meet;` outright. Its only outside reference is the registration site in `src/core/all.rs`, so no facade is needed (same shape as `audio_toolkit`). +- **`meet_agent` → facade + carve-out, no stub file.** Every submodule is gated **except `wav`** (below). Nothing outside the Meet domain calls the gated submodules. +- **`agent_meetings` → facade + stub.** Three always-compiled call sites reach in — the heartbeat planner (`calendar::handle_calendar_meeting_candidate`) and two subscriber registrations (`core::jsonrpc`, `channels::runtime::startup`) — so `src/openhuman/agent_meetings/stub.rs` supplies no-op equivalents and those callers need no `#[cfg]`. + +**No deps to shed (do not re-litigate).** Unlike `voice`, this gate drops **zero** dependencies — the Meet domains have no exclusive crates. `meet_agent::wav` is a hand-rolled 79-line RIFF writer with no `use` statements, written precisely so Meet never needed `hound` (which `voice` already owns and sheds). The dependency shed was pre-paid; this gate's value is compile-time surface and binary size, not the dep tree. + +**⚠ The `wav` carve-out is load-bearing.** `meet_agent::wav::pack_pcm16le_mono_wav` is called by `desktop_companion::pipeline::stt`, which is `DomainGroup::Platform` and compiled in **every** build. `pub mod wav;` must stay **ungated** so that call site keeps its real implementation — it costs nothing, since `wav.rs` is dependency-free. If someone gates it, the `--no-default-features` build fails loudly at `desktop_companion`; that failure is correct. Do **not** "fix" it by stubbing `pack_pcm16le_mono_wav` — that trades a compile error for green CI while silently corrupting desktop-companion STT (the STT backend would receive a malformed WAV). Revert the cfg instead. + +**Both-ways tests.** `src/core/all_tests.rs` pins the gate in both directions (`meet_controllers_registered_when_feature_on` / `meet_controllers_absent_when_feature_off`). The negative half is the one that proves the gate removes anything. Note CI's smoke lane runs `cargo check` only and never compiles test code, so a disabled-build **test** break is invisible to it — run `cargo test --lib --no-default-features --features tokenjuice-treesitter core::all::tests` locally after touching any gated surface. ### Event bus (`src/core/event_bus/`) diff --git a/Cargo.toml b/Cargo.toml index c5e73ffce2..ec5e3b80b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -341,7 +341,7 @@ tokio = { version = "1", features = ["test-util"] } proptest = "1" [features] -default = ["tokenjuice-treesitter", "voice", "web3", "media"] +default = ["tokenjuice-treesitter", "voice", "web3", "media", "meet"] # 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 = [ @@ -384,6 +384,20 @@ web3 = ["dep:bitcoin", "dep:curve25519-dalek"] # controllers / stores / subscribers tagged `Media` (agent tools only), and # `openhuman::image` is currently unwired scaffold (added #2997). media = [] +# Meet domains: `meet` (join-URL validation), `meet_agent` (live STT/LLM/TTS +# loop over an open call), and `agent_meetings` (backend-delegated Meet bot via +# Socket.IO). Default-ON — the desktop app always ships with Meet. Slim / +# headless builds opt out via `--no-default-features --features ""`. Composes with the runtime `DomainSet::meet` flag (#4796): the feature +# narrows the compile-time surface, `DomainSet` gates it at runtime. +# NOTE: unlike `voice`, this gate drops NO dependencies — the Meet domains have +# zero exclusive crates. `meet_agent::wav` is a hand-rolled RIFF writer +# precisely so Meet never needed `hound` (which `voice` already owns), so the +# dependency shed was pre-paid. The gate's value is compile-time surface + +# binary size, not the dep tree. +# CARVE-OUT: `meet_agent::wav` stays compiled in ALL builds — the always-on +# `desktop_companion` STT path depends on it. See `meet_agent/mod.rs`. +meet = [] 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 f9714f4621..7ed60465c3 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -160,6 +160,7 @@ openhuman_core = { path = "../..", package = "openhuman", default-features = fal "voice", "tokenjuice-treesitter", "web3", + "meet", ] } tinyjuice = { version = "0.2.1", default-features = false } diff --git a/src/core/all.rs b/src/core/all.rs index e7697117be..eeeb04fda9 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -712,19 +712,25 @@ fn build_registered_controllers() -> Vec { DomainGroup::Platform, crate::openhuman::notifications::all_notifications_registered_controllers(), ); - // Google Meet call-join request validation (shell handles the webview) + // Google Meet call-join request validation (shell handles the webview). + // Gated behind the `meet` feature. + #[cfg(feature = "meet")] push( &mut controllers, DomainGroup::Meet, crate::openhuman::meet::all_meet_registered_controllers(), ); // Agent meetings — backend-delegated Meet bot via Socket.IO + // (gated with meet). + #[cfg(feature = "meet")] push( &mut controllers, DomainGroup::Meet, crate::openhuman::agent_meetings::all_agent_meetings_registered_controllers(), ); - // Live meet-agent loop: STT/LLM/TTS over the open call's audio. + // Live meet-agent loop: STT/LLM/TTS over the open call's audio + // (gated with meet). + #[cfg(feature = "meet")] push( &mut controllers, DomainGroup::Meet, diff --git a/src/core/all_tests.rs b/src/core/all_tests.rs index b8980b2f33..c5fbf6af43 100644 --- a/src/core/all_tests.rs +++ b/src/core/all_tests.rs @@ -909,7 +909,44 @@ fn group_mapping_smoke() { assert_eq!(group_for_namespace("voice"), Some(DomainGroup::Voice)); #[cfg(feature = "web3")] assert_eq!(group_for_namespace("wallet"), Some(DomainGroup::Web3)); + // `meet` is compiled out under `--no-default-features`, so the registry has + // no entry to map (#4800). + #[cfg(feature = "meet")] assert_eq!(group_for_namespace("meet"), Some(DomainGroup::Meet)); // Internal-only registry is grouped too (mcp_audit → Mcp). assert_eq!(group_for_namespace("mcp_audit"), Some(DomainGroup::Mcp)); } + +/// All three Meet namespaces register when the `meet` feature is on (#4800). +/// +/// Paired with `meet_controllers_absent_when_feature_off` below: together they +/// pin *both* directions of the compile-time gate. The negative half is the one +/// that actually proves the gate does something — a gate that never removes +/// anything would still pass this positive test. +#[cfg(feature = "meet")] +#[test] +fn meet_controllers_registered_when_feature_on() { + for ns in ["meet", "agent_meetings", "meet_agent"] { + assert_eq!( + group_for_namespace(ns), + Some(DomainGroup::Meet), + "`{ns}` must register under DomainGroup::Meet when the `meet` feature is on" + ); + } +} + +/// No Meet namespace registers when the `meet` feature is off (#4800). +/// +/// This is the half that proves the gate: with `meet` compiled out the three +/// domains must leave zero trace in either the public or the internal registry. +#[cfg(not(feature = "meet"))] +#[test] +fn meet_controllers_absent_when_feature_off() { + for ns in ["meet", "agent_meetings", "meet_agent"] { + assert_eq!( + group_for_namespace(ns), + None, + "`{ns}` must not register when the `meet` feature is off" + ); + } +} diff --git a/src/openhuman/agent_meetings/mod.rs b/src/openhuman/agent_meetings/mod.rs index ef2c892494..7539e3b4d9 100644 --- a/src/openhuman/agent_meetings/mod.rs +++ b/src/openhuman/agent_meetings/mod.rs @@ -13,18 +13,43 @@ //! - [`schemas`] — controller schema + registered handler wrappers //! - [`store`] — SQLite persistence for meeting sessions //! - [`in_call`] — Phase 2 in-call agency: wake-phrase command → orchestrator → `bot:speak` +//! +//! ## Compile-time gating (`meet` feature, #4800) +//! +//! All submodules are `#[cfg(feature = "meet")]`. Three always-compiled call +//! sites reach in here for non-registration symbols — the heartbeat planner +//! (`calendar::handle_calendar_meeting_candidate`) plus two subscriber +//! registrations (`core::jsonrpc`, `channels::runtime::startup`) — so a +//! `#[cfg(not(feature = "meet"))]` [`stub`] supplies no-op equivalents and +//! those callers need no cfg of their own. +#[cfg(feature = "meet")] pub mod bus; +#[cfg(feature = "meet")] pub mod calendar; +#[cfg(feature = "meet")] pub mod in_call; +#[cfg(feature = "meet")] pub mod ops; +#[cfg(feature = "meet")] pub mod recent_calls; +#[cfg(feature = "meet")] pub mod schemas; +#[cfg(feature = "meet")] pub mod store; +#[cfg(feature = "meet")] pub mod summary; +#[cfg(feature = "meet")] pub mod types; +#[cfg(feature = "meet")] pub mod upcoming; +#[cfg(not(feature = "meet"))] +mod stub; +#[cfg(not(feature = "meet"))] +pub use stub::*; + +#[cfg(feature = "meet")] pub use schemas::{ all_controller_schemas as all_agent_meetings_controller_schemas, all_registered_controllers as all_agent_meetings_registered_controllers, diff --git a/src/openhuman/agent_meetings/stub.rs b/src/openhuman/agent_meetings/stub.rs new file mode 100644 index 0000000000..ecbf3b339f --- /dev/null +++ b/src/openhuman/agent_meetings/stub.rs @@ -0,0 +1,46 @@ +//! No-op stand-ins for the `agent_meetings` symbols that always-compiled code +//! reaches for, used when the `meet` feature is off (#4800). +//! +//! Only the three symbols with non-registration call sites outside the Meet +//! domain live here. Everything else in `agent_meetings` is reachable only via +//! the controller registry, which is itself `#[cfg(feature = "meet")]` at +//! `core::all` — so it needs no stub. +//! +//! These are genuine no-ops, not "register a controller that then errors": a +//! `register_*` that registers nothing is exactly the intended disabled-build +//! behaviour — the subscriber simply never exists, so the events never route. + +/// Stub of [`super::calendar`](../calendar/index.html) for `--no-default-features` builds. +pub mod calendar { + /// No-op stand-in for `calendar::register_meet_calendar_subscriber`. + /// + /// With `meet` compiled out there is no calendar-triggered meeting flow to + /// drive, so registering nothing is the correct behaviour: the event bus + /// keeps routing, it just has no Meet subscriber attached. + pub fn register_meet_calendar_subscriber() {} + + /// No-op stand-in for `calendar::handle_calendar_meeting_candidate`. + /// + /// The `bool` means "Meet published its own actionable card, so the caller + /// should skip its generic one". With `meet` compiled out no card was ever + /// published, so `false` is semantically exact — the heartbeat planner + /// correctly falls through to its plain "meeting starting" reminder. + pub async fn handle_calendar_meeting_candidate( + _meet_url: String, + _event_title: String, + _owner_display_name: Option, + _calendar_event_id: Option, + ) -> bool { + false + } +} + +/// Stub of [`super::bus`](../bus/index.html) for `--no-default-features` builds. +pub mod bus { + /// No-op stand-in for `bus::register_meeting_event_subscriber`. + /// + /// Same reasoning as `calendar::register_meet_calendar_subscriber`: the + /// `Meeting*` / `BackendMeet*` domain events still exist on the bus (they + /// are inert plain data), they simply have no subscriber and never fire. + pub fn register_meeting_event_subscriber() {} +} diff --git a/src/openhuman/meet_agent/mod.rs b/src/openhuman/meet_agent/mod.rs index 4207171bf5..1d83815b8a 100644 --- a/src/openhuman/meet_agent/mod.rs +++ b/src/openhuman/meet_agent/mod.rs @@ -33,19 +33,57 @@ //! - `openhuman.meet_agent_push_listen_pcm` — shell pushes captured PCM frames //! - `openhuman.meet_agent_poll_speech` — shell pulls synthesized PCM frames //! - `openhuman.meet_agent_stop_session` — close session, flush pending audio +//! +//! ## Compile-time gating (`meet` feature, #4800) +//! +//! Every submodule here is `#[cfg(feature = "meet")]` — **except [`wav`]**. +//! +//! ### ⚠ The `wav` carve-out is load-bearing — do not "tidy" it +//! +//! [`wav::pack_pcm16le_mono_wav`] is called by +//! `desktop_companion::pipeline::stt`, which is `DomainGroup::Platform` and is +//! therefore compiled in **every** build, including `--no-default-features`. +//! `wav` must stay ungated so that call site keeps its **real** implementation. +//! +//! This is safe to leave ungated at zero cost: `wav.rs` is a self-contained +//! hand-rolled RIFF writer with no `use` statements and no dependencies. It +//! pulls in nothing when the rest of the domain is compiled out. (It is +//! hand-rolled precisely so Meet never needed `hound`, which the `voice` gate +//! already owns and sheds.) +//! +//! **If you ever add `#[cfg(feature = "meet")]` to `pub mod wav;`**, the +//! `--no-default-features` build will fail loudly at `desktop_companion`. That +//! failure is *correct and useful*. Do **not** "fix" it by stubbing +//! `pack_pcm16le_mono_wav` to return an empty/placeholder buffer: that turns a +//! compile error into green CI while silently corrupting desktop-companion STT +//! forever (the STT backend would receive a malformed WAV). Revert the cfg +//! instead — or, if `wav` genuinely must move, relocate it to a always-compiled +//! home rather than stubbing it. +#[cfg(feature = "meet")] pub mod brain; +#[cfg(feature = "meet")] pub mod ops; +#[cfg(feature = "meet")] pub mod rpc; +#[cfg(feature = "meet")] pub mod schemas; +#[cfg(feature = "meet")] pub mod session; +#[cfg(feature = "meet")] pub mod store; +#[cfg(feature = "meet")] pub mod types; +// NOT gated — see the carve-out note above. `desktop_companion` (always-on) +// depends on the real implementation. pub mod wav; +#[cfg(feature = "meet")] pub use schemas::{ all_controller_schemas as all_meet_agent_controller_schemas, all_registered_controllers as all_meet_agent_registered_controllers, }; +#[cfg(feature = "meet")] pub use session::{MeetAgentSession, MeetAgentSessionRegistry, SESSION_REGISTRY}; +#[cfg(feature = "meet")] pub use types::*; diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 601a569cfc..561581b40c 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -71,6 +71,7 @@ pub mod mcp_registry; pub mod mcp_server; #[cfg(feature = "media")] pub mod media_generation; +#[cfg(feature = "meet")] pub mod meet; pub mod meet_agent; pub mod memory; From c7009d96bb1c65b0e55d247a8b777daaa3f1d836 Mon Sep 17 00:00:00 2001 From: CodeGhost21 <164498022+CodeGhost21@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:45:45 +0530 Subject: [PATCH 48/86] feat(safety): Emergency Stop for desktop automation (#4255) (#4600) Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Steven Enamakel Co-authored-by: M3gA-Mind --- 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 + .../channels/providers/web/event_bus.rs | 177 +++ src/openhuman/channels/providers/web/mod.rs | 4 +- src/openhuman/channels/runtime/startup.rs | 5 + src/openhuman/cron/scheduler.rs | 44 +- 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/mod.rs | 1 + src/openhuman/screen_intelligence/ops.rs | 68 + src/openhuman/tinyagents/middleware.rs | 59 + tests/json_rpc_e2e.rs | 84 + 49 files changed, 3447 insertions(+), 4 deletions(-) create mode 100644 app/src/components/safety/AutomationHaltedBanner.test.tsx create mode 100644 app/src/components/safety/AutomationHaltedBanner.tsx create mode 100644 app/src/components/safety/EmergencyStopButton.test.tsx create mode 100644 app/src/components/safety/EmergencyStopButton.tsx create mode 100644 app/src/services/api/emergencyApi.test.ts create mode 100644 app/src/services/api/emergencyApi.ts create mode 100644 app/src/services/safety/hydrateEmergencyState.test.ts create mode 100644 app/src/services/safety/hydrateEmergencyState.ts create mode 100644 app/src/store/safetySlice.test.ts create mode 100644 app/src/store/safetySlice.ts create mode 100644 docs/superpowers/plans/2026-07-06-emergency-stop.md create mode 100644 docs/superpowers/specs/2026-07-06-emergency-stop-design.md create mode 100644 src/openhuman/emergency_stop/mod.rs create mode 100644 src/openhuman/emergency_stop/ops.rs create mode 100644 src/openhuman/emergency_stop/schemas.rs create mode 100644 src/openhuman/emergency_stop/state.rs create mode 100644 src/openhuman/emergency_stop/types.rs diff --git a/app/src/App.tsx b/app/src/App.tsx index 09969b5df9..645b948731 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -30,6 +30,8 @@ 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'; @@ -57,6 +59,7 @@ import { startInternetStatusListener, stopInternetStatusListener, } from './services/internetStatusListener'; +import { hydrateEmergencyState } from './services/safety/hydrateEmergencyState'; import { hideWebviewAccount, startWebviewAccountService, @@ -256,6 +259,16 @@ 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(); @@ -291,6 +304,9 @@ 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 && ( @@ -332,6 +348,17 @@ 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 new file mode 100644 index 0000000000..f9b2d2034d --- /dev/null +++ b/app/src/components/safety/AutomationHaltedBanner.test.tsx @@ -0,0 +1,116 @@ +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 new file mode 100644 index 0000000000..ded9a7a25b --- /dev/null +++ b/app/src/components/safety/AutomationHaltedBanner.tsx @@ -0,0 +1,89 @@ +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 new file mode 100644 index 0000000000..12f72d8da1 --- /dev/null +++ b/app/src/components/safety/EmergencyStopButton.test.tsx @@ -0,0 +1,65 @@ +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 new file mode 100644 index 0000000000..254b38ac70 --- /dev/null +++ b/app/src/components/safety/EmergencyStopButton.tsx @@ -0,0 +1,73 @@ +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 5673bc812c..52703f1dc2 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -7131,6 +7131,13 @@ 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 a05e1d5c99..22ac86f6d9 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -7299,6 +7299,13 @@ 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 1f6b30194e..541f755f2c 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -7513,6 +7513,15 @@ 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 0632c83197..73ab4fa04a 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -7598,6 +7598,15 @@ 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 bf70c3d356..ff6d916360 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -7451,6 +7451,15 @@ 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 2377b11924..e7555e5681 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -7486,6 +7486,15 @@ 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 944335e945..88b8c8b624 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -7295,6 +7295,13 @@ 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 8f4c546936..fefb2dbc58 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -7333,6 +7333,13 @@ 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 79b773be88..8c48eefb30 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -7440,6 +7440,13 @@ 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 7d04b5c76d..71b5452a7b 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -7210,6 +7210,13 @@ 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 06db2ea10f..cfe0e77a5b 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -7409,6 +7409,13 @@ 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 9aff9b3a57..6c199906a6 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -7420,6 +7420,13 @@ 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 980bc5c830..c4cf037bc8 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -7380,6 +7380,15 @@ 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 b799817bd3..727630da1b 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -6899,6 +6899,13 @@ 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 c61553be3f..9202dd559b 100644 --- a/app/src/services/__tests__/socketService.events.test.ts +++ b/app/src/services/__tests__/socketService.events.test.ts @@ -452,3 +452,156 @@ 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 new file mode 100644 index 0000000000..5e80fe8fd8 --- /dev/null +++ b/app/src/services/api/emergencyApi.test.ts @@ -0,0 +1,38 @@ +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 new file mode 100644 index 0000000000..224f485b6b --- /dev/null +++ b/app/src/services/api/emergencyApi.ts @@ -0,0 +1,31 @@ +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 new file mode 100644 index 0000000000..4ad2b6b9c1 --- /dev/null +++ b/app/src/services/safety/hydrateEmergencyState.test.ts @@ -0,0 +1,49 @@ +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 new file mode 100644 index 0000000000..3429746192 --- /dev/null +++ b/app/src/services/safety/hydrateEmergencyState.ts @@ -0,0 +1,21 @@ +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 4097563bae..f82ec9090f 100644 --- a/app/src/services/socketService.ts +++ b/app/src/services/socketService.ts @@ -17,6 +17,7 @@ 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'; @@ -463,6 +464,46 @@ 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 b41171943a..1a7b4435a6 100644 --- a/app/src/store/index.ts +++ b/app/src/store/index.ts @@ -34,6 +34,7 @@ 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'; @@ -244,6 +245,7 @@ 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 new file mode 100644 index 0000000000..bcf652650e --- /dev/null +++ b/app/src/store/safetySlice.test.ts @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000000..29f8d437dd --- /dev/null +++ b/app/src/store/safetySlice.ts @@ -0,0 +1,49 @@ +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 beda189643..b3667becf5 100644 --- a/app/src/test/test-utils.tsx +++ b/app/src/test/test-utils.tsx @@ -25,6 +25,7 @@ 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'; @@ -54,6 +55,7 @@ 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 new file mode 100644 index 0000000000..ce629ba8d8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-emergency-stop.md @@ -0,0 +1,1358 @@ +# 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 new file mode 100644 index 0000000000..45b3f60939 --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-emergency-stop-design.md @@ -0,0 +1,102 @@ +# 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 eeeb04fda9..948c916e0f 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -334,6 +334,12 @@ 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 355153dead..2d2ee80fe7 100644 --- a/src/core/event_bus/events.rs +++ b/src/core/event_bus/events.rs @@ -1127,6 +1127,22 @@ 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 @@ -1454,7 +1470,9 @@ impl DomainEvent { | Self::HealthChanged { .. } | Self::HealthRestarted { .. } | Self::HarnessInitProgress { .. } - | Self::HarnessInitCompleted { .. } => "system", + | Self::HarnessInitCompleted { .. } + | Self::AutomationHalted { .. } + | Self::AutomationResumed { .. } => "system", Self::KeyringConsentRequired | Self::KeyringDecryptFailed { .. } => "keyring", @@ -1610,6 +1628,8 @@ 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 c977286852..551c664743 100644 --- a/src/core/event_bus/events_tests.rs +++ b/src/core/event_bus/events_tests.rs @@ -594,3 +594,18 @@ 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 ed242d4176..4a553ab456 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -2343,6 +2343,12 @@ 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::channels::providers::web::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::channels::providers::web::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 @@ -2361,6 +2367,7 @@ 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/providers/web/event_bus.rs b/src/openhuman/channels/providers/web/event_bus.rs index 0015788d06..bf7d8335a8 100644 --- a/src/openhuman/channels/providers/web/event_bus.rs +++ b/src/openhuman/channels/providers/web/event_bus.rs @@ -40,6 +40,30 @@ 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() { @@ -382,9 +406,60 @@ impl EventHandler for ApprovalSurfaceSubscriber { } } +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 } => { + 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` @@ -417,6 +492,108 @@ 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/channels/providers/web/mod.rs b/src/openhuman/channels/providers/web/mod.rs index 833df235f4..4c979e8a78 100644 --- a/src/openhuman/channels/providers/web/mod.rs +++ b/src/openhuman/channels/providers/web/mod.rs @@ -23,8 +23,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_egress_surface_subscriber, - subscribe_web_channel_events, + register_artifact_surface_subscriber, register_automation_halt_subscriber, + register_egress_surface_subscriber, subscribe_web_channel_events, }; // Test-only: OnceLock-bypassing approval bridge for per-runtime integration tests. diff --git a/src/openhuman/channels/runtime/startup.rs b/src/openhuman/channels/runtime/startup.rs index 76f1899ec7..b3a13b5a11 100644 --- a/src/openhuman/channels/runtime/startup.rs +++ b/src/openhuman/channels/runtime/startup.rs @@ -171,6 +171,11 @@ 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::channels::providers::web::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::channels::providers::web::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 d9ef195001..1c869beb21 100644 --- a/src/openhuman/cron/scheduler.rs +++ b/src/openhuman/cron/scheduler.rs @@ -483,6 +483,27 @@ 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; @@ -494,6 +515,23 @@ 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; @@ -980,7 +1018,11 @@ async fn run_agent_job(config: &Config, job: &CronJob) -> (bool, String, Option< // and provider URLs are appropriate; it must NOT reach the // user-visible notification body. let user_message = classify_agent_anyhow_for_user(&e); - (false, user_message.to_string(), Some(e.to_string())) + // Preserve the FULL anyhow chain (`{:#}`), not just the top-level + // message: the loopback-unreachable classifier and the observability + // pipeline key on the transport cause (`… tcp connect error: Connection + // refused (os error N)`), which a bare `to_string()` drops. + (false, user_message.to_string(), Some(format!("{e:#}"))) } } } diff --git a/src/openhuman/cron/scheduler_tests.rs b/src/openhuman/cron/scheduler_tests.rs index 0e63788c8e..fc5b31df3b 100644 --- a/src/openhuman/cron/scheduler_tests.rs +++ b/src/openhuman/cron/scheduler_tests.rs @@ -439,6 +439,43 @@ 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 new file mode 100644 index 0000000000..b4d3c5e9fe --- /dev/null +++ b/src/openhuman/emergency_stop/mod.rs @@ -0,0 +1,16 @@ +//! 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 new file mode 100644 index 0000000000..b1b3bc67eb --- /dev/null +++ b/src/openhuman/emergency_stop/ops.rs @@ -0,0 +1,165 @@ +//! 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 new file mode 100644 index 0000000000..171a0fcd55 --- /dev/null +++ b/src/openhuman/emergency_stop/schemas.rs @@ -0,0 +1,166 @@ +//! 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 new file mode 100644 index 0000000000..91a910b3a1 --- /dev/null +++ b/src/openhuman/emergency_stop/state.rs @@ -0,0 +1,174 @@ +//! 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 new file mode 100644 index 0000000000..f83d179355 --- /dev/null +++ b/src/openhuman/emergency_stop/types.rs @@ -0,0 +1,51 @@ +//! 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/mod.rs b/src/openhuman/mod.rs index 561581b40c..83df574557 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -49,6 +49,7 @@ 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 4608e8b00b..76ad746736 100644 --- a/src/openhuman/screen_intelligence/ops.rs +++ b/src/openhuman/screen_intelligence/ops.rs @@ -152,6 +152,19 @@ 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?; @@ -307,4 +320,59 @@ 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 e87b528e14..6ca9ef1f75 100644 --- a/src/openhuman/tinyagents/middleware.rs +++ b/src/openhuman/tinyagents/middleware.rs @@ -1029,6 +1029,26 @@ 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 { @@ -1046,6 +1066,12 @@ 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); @@ -4536,6 +4562,39 @@ 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/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 73580df669..81d6134168 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -1228,6 +1228,90 @@ 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 5f55f33feef74781448d9174ce7320cf1923ecd0 Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:12:48 +0530 Subject: [PATCH 49/86] feat(core): compile-time skills feature gate (#4798) (#4913) Co-authored-by: Steven Enamakel Co-authored-by: M3gA-Mind Co-authored-by: M3gA-Mind --- AGENTS.md | 16 +++ Cargo.toml | 23 +++- app/src-tauri/Cargo.toml | 1 + src/core/all_tests.rs | 39 +++++++ .../agent/harness/definition_tests.rs | 4 + src/openhuman/agent/harness/session/tests.rs | 7 ++ .../agent/task_dispatcher/executor.rs | 14 ++- src/openhuman/agent/tools.rs | 5 + src/openhuman/agent_registry/agents/loader.rs | 5 + src/openhuman/skill_registry/mod.rs | 27 ++++- src/openhuman/skill_registry/stub.rs | 46 ++++++++ src/openhuman/skill_runtime/mod.rs | 25 +++++ src/openhuman/skill_runtime/stub.rs | 33 ++++++ src/openhuman/skills/mod.rs | 66 +++++++++++- src/openhuman/skills/stub.rs | 101 ++++++++++++++++++ src/openhuman/tools/mod.rs | 3 + src/openhuman/tools/ops.rs | 21 ++++ 17 files changed, 429 insertions(+), 7 deletions(-) create mode 100644 src/openhuman/skill_registry/stub.rs create mode 100644 src/openhuman/skill_runtime/stub.rs create mode 100644 src/openhuman/skills/stub.rs diff --git a/AGENTS.md b/AGENTS.md index e9bc252853..2840c0ac12 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -236,6 +236,7 @@ GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml \ | `web3` | ON | `openhuman::wallet` + `openhuman::web3` + `openhuman::x402` domains — crypto wallet (multi-chain sign/broadcast), swaps/bridges/dapp calls, x402 machine payments | `bitcoin`, `curve25519-dalek` | | `media` | ON | `openhuman::media_generation` (the `media_generate_*` agent tools) + `openhuman::image` scaffold | none (surface-only) | | `meet` | ON | `openhuman::meet` (join-URL validation) + `openhuman::meet_agent` (live STT/LLM/TTS loop) + `openhuman::agent_meetings` (backend-delegated Meet bot over Socket.IO) | none — see note | +| `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) | **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. @@ -256,6 +257,21 @@ GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml \ **Both-ways tests.** `src/core/all_tests.rs` pins the gate in both directions (`meet_controllers_registered_when_feature_on` / `meet_controllers_absent_when_feature_off`). The negative half is the one that proves the gate removes anything. Note CI's smoke lane runs `cargo check` only and never compiles test code, so a disabled-build **test** break is invisible to it — run `cargo test --lib --no-default-features --features tokenjuice-treesitter core::all::tests` locally after touching any gated surface. +**`skills` gate — the type carve-out (read before adding the next gate).** The three skill domains follow the same facade+stub shape as `voice`, with one important refinement: **`skills` is not a leaf — it is partly load-bearing infrastructure.** `src/openhuman/tools/traits.rs` re-exports the crate's unified `ToolResult` / `ToolContent` out of `skills::types`, and ~236 files consume them (`mcp_client`, `runtime_node`, every `Tool` impl). `Workflow` / `WorkflowFrontmatter` / `WorkflowScope` from `skills::ops_types` likewise appear in always-on agent-harness and prompt signatures. Gating `skills` wholesale would take down the entire tool trait system, MCP, and the Node runtime. + +So `skills::types` and `skills::ops_types` stay **compiled in both directions** — they are inert serde/std-only definitions with zero coupling to their gated siblings — and only *behaviour* is gated. `src/openhuman/skills/stub.rs` therefore mirrors **functions only** and re-exports the real types (`pub use super::ops_types::{Workflow, …}`), so there is **zero type duplication** — strictly less drift surface than the `voice` stub, which had to re-declare `SttResult` + the `SttProvider` trait because those live inside its gated tree. + +> **Generalizable rule for the remaining gates:** put a domain's inert types in a dep-free submodule and leave it **ungated**; stub only the behaviour. Reach for a stub type only when the type genuinely cannot be carved out. + +Two places the carve-out doesn't reach, and why they are `#[cfg]` at the call site instead of stubbed: + +- `agent_registry/agents/loader.rs` — the `skill_setup` / `skill_executor` `BuiltinAgent` entries. `include_str!` embeds the agent TOML from disk regardless of module gating, so the entry itself must disappear. +- `agent/task_dispatcher/executor.rs` — the workflow-resolution branch. `registry::get_workflow` returns `Option`, which flattens in `AgentDefinition` and is destructured at the call site; stubbing it would mean re-declaring that struct (exactly what the carve-out avoids). With the domain compiled out no handle can resolve to a skill, so falling through to the builtin-agent branch is correct, not degraded. + +**Dep note:** `skills = []` — the empty list is **intentional, do not "fix" it**. Unlike `voice` (`hound`/`lettre`), these domains have no exclusive dependencies: every crate they touch is shared with always-on domains, and `runtime_node` / `runtime_python` are used by Agent / Flows / Memory too. This gate's value is tool-surface + prompt-bloat + startup cost, **not** binary size. + +When skills are off: the `skills` / `skill_runtime` / `skill_registry` controllers are unregistered (unknown-method over `/rpc`, absent from `/schema`), the 16 skill agent tools (incl. `run_workflow` / `await_workflow`) are **absent** from the tool list rather than degraded to an error, the `skill_setup` / `skill_executor` builtin agents are gone, and the boot-time remote catalog refresh is skipped. Composes with the runtime `DomainSet::skills` flag (#4796) — that axis needed no change here; #4798 is compile-time only. + ### Event bus (`src/core/event_bus/`) Typed pub/sub + native request/response. Both singletons — use module-level functions. diff --git a/Cargo.toml b/Cargo.toml index ec5e3b80b4..000e22a42a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -341,7 +341,7 @@ tokio = { version = "1", features = ["test-util"] } proptest = "1" [features] -default = ["tokenjuice-treesitter", "voice", "web3", "media", "meet"] +default = ["tokenjuice-treesitter", "voice", "web3", "media", "meet", "skills"] # 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 = [ @@ -398,6 +398,27 @@ media = [] # CARVE-OUT: `meet_agent::wav` stays compiled in ALL builds — the always-on # `desktop_companion` STT path depends on it. See `meet_agent/mod.rs`. meet = [] +# Skills domains: `openhuman::skills` (metadata/discovery/install), +# `openhuman::skill_runtime` (SKILL.md execution + the skill_executor agent), +# and `openhuman::skill_registry` (remote catalogs + the skill_setup agent). +# Default-ON — the desktop app always ships with skills. Slim / headless builds +# opt out via `--no-default-features --features ""`. +# Composes with the runtime `DomainSet::skills` flag (#4796): the feature +# narrows the compile-time surface, `DomainSet` gates it at runtime. +# +# The dep list is INTENTIONALLY EMPTY — do not "fix" it. Unlike `voice` +# (`hound`/`lettre`), these domains have no exclusive dependencies: every crate +# they touch (serde, serde_json, tokio, anyhow, async-trait, …) is shared with +# always-on domains, and `runtime_node`/`runtime_python` are used by Agent / +# Flows / Memory too. This gate's value is tool-surface + prompt-bloat + +# startup cost, NOT binary size. +# +# NOTE: `openhuman::skills::types` and `::ops_types` stay compiled even when +# this feature is OFF — `tools::traits` re-exports `ToolResult`/`ToolContent` +# out of `skills::types` and ~236 files consume them, so those inert serde +# types are a type carve-out, not part of the gated behaviour. See +# `src/openhuman/skills/stub.rs`. +skills = [] 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 7ed60465c3..6909d53d45 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -161,6 +161,7 @@ openhuman_core = { path = "../..", package = "openhuman", default-features = fal "tokenjuice-treesitter", "web3", "meet", + "skills", ] } tinyjuice = { version = "0.2.1", default-features = false } diff --git a/src/core/all_tests.rs b/src/core/all_tests.rs index c5fbf6af43..6049ffebbf 100644 --- a/src/core/all_tests.rs +++ b/src/core/all_tests.rs @@ -234,6 +234,42 @@ fn voice_and_audio_controllers_absent_when_feature_off() { ); } +/// With the `skills` feature on (the default), all three skill domains are +/// compiled in and registered — the desktop build is byte-identical. +#[test] +#[cfg(feature = "skills")] +fn skill_controllers_registered_when_feature_on() { + let schemas = all_controller_schemas(); + for ns in ["skills", "skill_runtime", "skill_registry"] { + assert!( + schemas.iter().any(|s| s.namespace == ns), + "`{ns}` controllers must be registered when the `skills` feature is on" + ); + } +} + +/// With the `skills` feature off, all three domains are compiled out: their +/// controllers never enter the registry, so skills RPC methods are +/// unknown-method and absent from `/schema`. This is the compile-time +/// stub-facade correctness gate (see `openhuman::skills::stub`). +/// +/// Note this does NOT cover `skills::types` / `skills::ops_types`: those stay +/// compiled in both directions (the type carve-out — `tools::traits` re-exports +/// `ToolResult`/`ToolContent` out of them), but they expose no controllers, so +/// the namespaces are absent either way. +#[test] +#[cfg(not(feature = "skills"))] +fn skill_controllers_absent_when_feature_off() { + let schemas = all_controller_schemas(); + assert!( + !schemas.iter().any(|s| s.namespace == "skills" + || s.namespace == "skill_runtime" + || s.namespace == "skill_registry"), + "skills/skill_runtime/skill_registry controllers must be compiled out \ + when the `skills` feature is off" + ); +} + /// With the `web3` feature on (the default), the wallet + web3 + x402 /// controllers are compiled in and registered, and the high-level web3 agent /// tools (swap/bridge/dapp) are present — the desktop build is byte-identical. @@ -905,6 +941,9 @@ fn group_mapping_smoke() { assert_eq!(group_for_namespace("agent"), Some(DomainGroup::Agent)); // …and a representative gated one maps to its gate group. assert_eq!(group_for_namespace("flows"), Some(DomainGroup::Flows)); + // `group_for_namespace` is registry-derived, so a compile-time-gated domain + // has no controller to map. Skip when its Cargo feature is off. + #[cfg(feature = "skills")] assert_eq!(group_for_namespace("skills"), Some(DomainGroup::Skills)); assert_eq!(group_for_namespace("voice"), Some(DomainGroup::Voice)); #[cfg(feature = "web3")] diff --git a/src/openhuman/agent/harness/definition_tests.rs b/src/openhuman/agent/harness/definition_tests.rs index ed905d26dc..d6d8ebc0f7 100644 --- a/src/openhuman/agent/harness/definition_tests.rs +++ b/src/openhuman/agent/harness/definition_tests.rs @@ -376,6 +376,8 @@ fn all_builtin_agent_definitions_have_expected_effective_max_iterations() { ("tools_agent", 50), ("flow_discovery", 50), ("workflow_builder", 50), + // Compiled out with the `skills` gate — see `openhuman::skills::stub`. + #[cfg(feature = "skills")] ("skill_executor", 50), ("tinyplace_agent", 50), ("subconscious", 30), @@ -404,6 +406,8 @@ fn all_builtin_agent_definitions_have_expected_effective_max_iterations() { ("vision_agent", 6), // Unchanged. ("presentation_agent", 10), + // Compiled out with the `skills` gate — see `openhuman::skills::stub`. + #[cfg(feature = "skills")] ("skill_setup", 10), ]; diff --git a/src/openhuman/agent/harness/session/tests.rs b/src/openhuman/agent/harness/session/tests.rs index 992c028d67..da62b719cf 100644 --- a/src/openhuman/agent/harness/session/tests.rs +++ b/src/openhuman/agent/harness/session/tests.rs @@ -485,7 +485,11 @@ fn skill_listener_closed_channel_nulls_rx_and_is_not_a_signal() { ); } +/// Exercises real SKILL.md discovery from disk, so it is meaningful only with +/// the `skills` domain compiled in — the disabled facade's +/// `load_workflow_metadata` always returns an empty catalog by design. #[test] +#[cfg(feature = "skills")] fn refresh_workflows_picks_up_skill_installed_on_disk() { use crate::openhuman::skills::ops_types::{SKILL_MD, TRUST_MARKER}; @@ -551,7 +555,10 @@ fn refresh_workflows_picks_up_skill_installed_on_disk() { ); } +/// See [`refresh_workflows_picks_up_skill_installed_on_disk`] — same +/// disk-discovery dependency, so same `skills` gate. #[test] +#[cfg(feature = "skills")] fn refresh_workflows_retracts_skill_removed_from_disk() { use crate::openhuman::skills::ops_types::{SKILL_MD, TRUST_MARKER}; diff --git a/src/openhuman/agent/task_dispatcher/executor.rs b/src/openhuman/agent/task_dispatcher/executor.rs index daabdc7d7a..d54571bfca 100644 --- a/src/openhuman/agent/task_dispatcher/executor.rs +++ b/src/openhuman/agent/task_dispatcher/executor.rs @@ -6,7 +6,10 @@ use std::path::Path; -use crate::openhuman::agent::harness::definition::{AgentDefinitionRegistry, PromptSource}; +use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry; +// Only read by the `skills`-gated workflow-resolution branch below. +#[cfg(feature = "skills")] +use crate::openhuman::agent::harness::definition::PromptSource; use crate::openhuman::agent::harness::session::Agent; use crate::openhuman::agent::harness::subagent_runner::with_autonomous_iter_cap; use crate::openhuman::agent::task_board::TaskCardStatus; @@ -69,6 +72,15 @@ pub(super) fn resolve_executor(workspace_dir: &Path, assigned: Option<&str>) -> } // 2) Workflow (#2824): the same autonomous run, seeded with SKILL.md. + // + // `#[cfg]` rather than a stubbed `get_workflow`: the real one returns + // `Option`, which flattens in `AgentDefinition` and is + // destructured just below — stubbing it would mean re-declaring that + // struct in the disabled facade (exactly the type duplication the skills + // carve-out exists to avoid). With the domain compiled out no handle can + // ever resolve to a skill, so falling through to (3) is the correct + // behaviour, not a degradation. + #[cfg(feature = "skills")] if let Some(skill) = crate::openhuman::skills::registry::get_workflow(workspace_dir, handle) { let guidelines = match &skill.definition.system_prompt { PromptSource::Inline(s) => truncate_chars(s, EXECUTOR_PREAMBLE_MAX_CHARS), diff --git a/src/openhuman/agent/tools.rs b/src/openhuman/agent/tools.rs index dcba4c3d27..cc399a060e 100644 --- a/src/openhuman/agent/tools.rs +++ b/src/openhuman/agent/tools.rs @@ -3,6 +3,10 @@ mod delegate; mod delegate_to_personality; mod plan_exit; pub mod remember_preference; +// Pure `skill_runtime` client (spawn + await a workflow run) — compiled out +// with the `skills` gate so the tool list OMITS these rather than degrading +// them to a disabled-error. +#[cfg(feature = "skills")] mod run_workflow; pub mod save_preference; mod todo; @@ -13,6 +17,7 @@ pub use delegate::DelegateTool; pub use delegate_to_personality::DelegateToPersonalityTool; pub use plan_exit::{PlanExitTool, PLAN_EXIT_MARKER}; pub use remember_preference::RememberPreferenceTool; +#[cfg(feature = "skills")] pub use run_workflow::{ AwaitWorkflowTool, RunWorkflowTool, AWAIT_WORKFLOW_TOOL_NAME, RUN_WORKFLOW_TOOL_NAME, }; diff --git a/src/openhuman/agent_registry/agents/loader.rs b/src/openhuman/agent_registry/agents/loader.rs index 1a9111fa13..c8ea3415a6 100644 --- a/src/openhuman/agent_registry/agents/loader.rs +++ b/src/openhuman/agent_registry/agents/loader.rs @@ -263,12 +263,17 @@ pub const BUILTINS: &[BuiltinAgent] = &[ prompt_fn: super::mcp_agent::prompt::build, graph_fn: None, }, + // Skill agents — `#[cfg]` rather than stub: `include_str!` embeds the + // agent TOML from disk regardless of module gating, so the entry itself + // must disappear when the `skills` feature is off. + #[cfg(feature = "skills")] BuiltinAgent { id: "skill_setup", toml: include_str!("../../skill_registry/agent/skill_setup/agent.toml"), prompt_fn: crate::openhuman::skill_registry::agent::skill_setup::prompt::build, graph_fn: None, }, + #[cfg(feature = "skills")] BuiltinAgent { id: "skill_executor", toml: include_str!("../../skill_runtime/agent/skill_executor/agent.toml"), diff --git a/src/openhuman/skill_registry/mod.rs b/src/openhuman/skill_registry/mod.rs index 5cacac9850..6461dd6db9 100644 --- a/src/openhuman/skill_registry/mod.rs +++ b/src/openhuman/skill_registry/mod.rs @@ -1,19 +1,44 @@ //! Skill registry: browse, search, and install skills from the aggregated //! Hermes catalog (HermesHub, ClawHub, skills.sh, LobeHub, browse.sh) //! with local caching. +//! +//! ## Compile-time gate (`skills` feature) +//! +//! `pub mod skill_registry;` is ALWAYS compiled — it is a facade. The real +//! implementation is gated behind the default-ON `skills` Cargo feature (the +//! same gate as `openhuman::skills` and `openhuman::skill_runtime` — the three +//! domains ship as one unit). When the feature is off, [`stub`] takes its +//! place with no-op / empty bodies. See `src/openhuman/skills/mod.rs` for the +//! pattern and the type carve-out. +#[cfg(feature = "skills")] pub mod agent; +#[cfg(feature = "skills")] pub mod ops; +#[cfg(feature = "skills")] pub mod schemas; +#[cfg(feature = "skills")] pub mod store; +#[cfg(feature = "skills")] pub mod tools; +#[cfg(feature = "skills")] pub mod types; +#[cfg(feature = "skills")] pub use schemas::{ all_skill_registry_controller_schemas, all_skill_registry_registered_controllers, }; /// Serializes tests that mutate the process-global `OPENHUMAN_SKILL_REGISTRY_CACHE_DIR` /// env var, so cargo's parallel runner can't interleave their cache dirs. -#[cfg(test)] +#[cfg(all(test, feature = "skills"))] pub(crate) static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +// --------------------------------------------------------------------------- +// Disabled facade — compiled only when the `skills` feature is OFF. +// --------------------------------------------------------------------------- + +#[cfg(not(feature = "skills"))] +mod stub; +#[cfg(not(feature = "skills"))] +pub use stub::*; diff --git a/src/openhuman/skill_registry/stub.rs b/src/openhuman/skill_registry/stub.rs new file mode 100644 index 0000000000..44d72065eb --- /dev/null +++ b/src/openhuman/skill_registry/stub.rs @@ -0,0 +1,46 @@ +//! Disabled-skills facade for the skill-registry domain. +//! +//! Compiled only when the `skills` Cargo feature is OFF (see the gate in +//! [`super`]). Mirrors only what always-on code reaches: the boot catalog +//! refresh kicked off by `core::runtime::services`, the controller aggregators +//! (`src/core/all.rs`), and the `tools` module glob +//! (`src/openhuman/tools/mod.rs`). Everything else — the catalog store, wire +//! types, and the `skill_setup` agent — is only referenced from code gated by +//! the same feature, so it vanishes alongside. +//! +//! The signatures here MUST match the real ones exactly. The disabled build +//! (`cargo check --no-default-features --features tokenjuice-treesitter`) is +//! the only thing that catches drift. + +use crate::core::all::RegisteredController; +use crate::core::ControllerSchema; + +/// Always empty: the `openhuman.skill_registry_*` controllers are compiled +/// out, so they never enter the registry (unknown-method over `/rpc`, absent +/// from `/schema`). +pub fn all_skill_registry_registered_controllers() -> Vec { + Vec::new() +} + +/// Always empty — see [`all_skill_registry_registered_controllers`]. +pub fn all_skill_registry_controller_schemas() -> Vec { + Vec::new() +} + +// --------------------------------------------------------------------------- +// ops::start_boot_catalog_refresh — called unconditionally at core boot from +// `src/core/runtime/services.rs`, so it must stay callable without a `#[cfg]` +// at that always-on site. +// --------------------------------------------------------------------------- + +pub mod ops { + /// No-op: there is no remote skill catalog to warm when the skill domains + /// are compiled out. Skips the boot-time network fetch entirely. + pub fn start_boot_catalog_refresh() { + log::debug!("[skill-registry-stub] start_boot_catalog_refresh skipped (skills disabled)"); + } +} + +// NOTE: no `tools` module here — the `pub use skill_registry::tools::*` glob in +// `tools/mod.rs` is `#[cfg(feature = "skills")]` instead, mirroring the `voice` +// gate. See the note in `skills/stub.rs`. diff --git a/src/openhuman/skill_runtime/mod.rs b/src/openhuman/skill_runtime/mod.rs index ea903d99d6..4d0dfedb5a 100644 --- a/src/openhuman/skill_runtime/mod.rs +++ b/src/openhuman/skill_runtime/mod.rs @@ -5,15 +5,40 @@ //! owns remote catalogs and install sources. This module owns actually running //! a skill, regardless of whether the skill's instructions call Python, Node, //! shell tools, or another OpenHuman agent tool. +//! +//! ## Compile-time gate (`skills` feature) +//! +//! `pub mod skill_runtime;` is ALWAYS compiled — it is a facade. The real +//! implementation is gated behind the default-ON `skills` Cargo feature (the +//! same gate as `openhuman::skills` and `openhuman::skill_registry` — the +//! three domains ship as one unit). When the feature is off, [`stub`] takes +//! its place with disabled-error / empty bodies. See +//! `src/openhuman/skills/mod.rs` for the pattern and the type carve-out. +#[cfg(feature = "skills")] pub mod agent; +#[cfg(feature = "skills")] pub mod ops; +#[cfg(feature = "skills")] mod run_machinery; +#[cfg(feature = "skills")] pub mod schemas; +#[cfg(feature = "skills")] pub mod tools; +#[cfg(feature = "skills")] pub use run_machinery::{await_run_outcome, spawn_workflow_run_background, WorkflowRunStarted}; +#[cfg(feature = "skills")] pub use schemas::{ all_skill_runtime_controller_schemas, all_skill_runtime_registered_controllers, skill_runtime_schemas, }; + +// --------------------------------------------------------------------------- +// Disabled facade — compiled only when the `skills` feature is OFF. +// --------------------------------------------------------------------------- + +#[cfg(not(feature = "skills"))] +mod stub; +#[cfg(not(feature = "skills"))] +pub use stub::*; diff --git a/src/openhuman/skill_runtime/stub.rs b/src/openhuman/skill_runtime/stub.rs new file mode 100644 index 0000000000..aa1d1aa56b --- /dev/null +++ b/src/openhuman/skill_runtime/stub.rs @@ -0,0 +1,33 @@ +//! Disabled-skills facade for the skill-runtime domain. +//! +//! Compiled only when the `skills` Cargo feature is OFF (see the gate in +//! [`super`]). Deliberately tiny: every caller of the run machinery +//! (`spawn_workflow_run_background`, `await_run_outcome`, `WorkflowRunStarted`) +//! lives inside code gated by the *same* feature — the `run_workflow` agent +//! tool, the `openhuman.skills_*` handlers, and this domain's own tests — so +//! those vanish together and the stub owes only what always-on code reaches: +//! the controller aggregators (`src/core/all.rs`) and the `tools` module glob +//! (`src/openhuman/tools/mod.rs`). +//! +//! The signatures here MUST match the real ones exactly. The disabled build +//! (`cargo check --no-default-features --features tokenjuice-treesitter`) is +//! the only thing that catches drift. + +use crate::core::all::RegisteredController; +use crate::core::ControllerSchema; + +/// Always empty: the `openhuman.skill_runtime_*` controllers are compiled out, +/// so they never enter the registry (unknown-method over `/rpc`, absent from +/// `/schema`). +pub fn all_skill_runtime_registered_controllers() -> Vec { + Vec::new() +} + +/// Always empty — see [`all_skill_runtime_registered_controllers`]. +pub fn all_skill_runtime_controller_schemas() -> Vec { + Vec::new() +} + +// NOTE: no `tools` module here — the `pub use skill_runtime::tools::*` glob in +// `tools/mod.rs` is `#[cfg(feature = "skills")]` instead, mirroring the `voice` +// gate. See the note in `skills/stub.rs`. diff --git a/src/openhuman/skills/mod.rs b/src/openhuman/skills/mod.rs index ac6643f138..9d24a2923a 100644 --- a/src/openhuman/skills/mod.rs +++ b/src/openhuman/skills/mod.rs @@ -1,28 +1,86 @@ //! Skills metadata helpers (discovery, parse, install, run). +//! +//! ## Compile-time gate (`skills` feature) +//! +//! `pub mod skills;` is ALWAYS compiled — it is a facade. The behavioural +//! submodules below are gated behind the default-ON `skills` Cargo feature; +//! when it is off, [`stub`] takes their place and exposes the same public +//! surface that always-on callers depend on (`load_workflow_metadata`, +//! `init_workflows_dir`, `registry`, `bus`, the controller aggregators) with +//! no-op / empty bodies. Callers therefore do **not** need per-call `#[cfg]`. +//! (The `tools` glob is `#[cfg(feature = "skills")]` at its re-export site in +//! `tools/mod.rs`, so the stub omits it — mirroring the `voice` gate.) +//! +//! ### Type carve-out (why `types` + `ops_types` are NOT gated) +//! +//! [`types`] and [`ops_types`] stay compiled in **both** directions. They are +//! inert serde/std-only type definitions with zero coupling to the gated +//! siblings, and they are load-bearing far outside this domain: +//! `tools::traits` re-exports `ToolResult`/`ToolContent` out of [`types`] as +//! the crate's unified tool-result type (`mcp_client`, `runtime_node`, and +//! ~236 files consume it), and `Workflow`/`WorkflowFrontmatter`/ +//! `WorkflowScope` from [`ops_types`] appear in always-on agent-harness and +//! prompt signatures. Gating them would take down the entire tool trait +//! system, MCP, and the Node runtime. +//! +//! The generalizable rule: **put inert types in a dep-free submodule and leave +//! it ungated; stub only the behaviour.** The stub below therefore mirrors +//! FUNCTIONS ONLY and re-exports the real types — zero type duplication, so +//! strictly less drift surface than the `voice` stub (which had to re-declare +//! `SttResult` + the `SttProvider` trait because those live inside its gated +//! tree). +//! +//! Signatures in [`stub`] must match the real ones exactly — the disabled +//! build (`cargo check --no-default-features --features tokenjuice-treesitter`) +//! is the only thing that catches drift. +// Type carve-out: always compiled, both feature directions. See module docs. +pub mod ops_types; +pub mod types; + +#[cfg(feature = "skills")] pub mod bus; +#[cfg(feature = "skills")] pub mod ops; +#[cfg(feature = "skills")] pub mod ops_create; +#[cfg(feature = "skills")] pub mod ops_discover; +#[cfg(feature = "skills")] pub mod ops_install; +#[cfg(feature = "skills")] pub mod ops_parse; -pub mod ops_types; +#[cfg(feature = "skills")] pub mod preflight; +#[cfg(feature = "skills")] pub mod registry; +#[cfg(feature = "skills")] pub mod run_log; +#[cfg(feature = "skills")] pub mod schemas; +#[cfg(feature = "skills")] pub mod tools; -pub mod types; -#[cfg(test)] +#[cfg(all(test, feature = "skills"))] #[path = "e2e_plumbing_tests.rs"] mod e2e_plumbing_tests; -#[cfg(test)] +#[cfg(all(test, feature = "skills"))] #[path = "e2e_run_tests.rs"] mod e2e_run_tests; +#[cfg(feature = "skills")] pub use ops::*; +#[cfg(feature = "skills")] pub use schemas::{ all_skills_controller_schemas, all_skills_registered_controllers, skills_schemas, }; + +// --------------------------------------------------------------------------- +// Disabled facade — compiled only when the `skills` feature is OFF. +// --------------------------------------------------------------------------- + +#[cfg(not(feature = "skills"))] +mod stub; +#[cfg(not(feature = "skills"))] +pub use stub::*; diff --git a/src/openhuman/skills/stub.rs b/src/openhuman/skills/stub.rs new file mode 100644 index 0000000000..a250923198 --- /dev/null +++ b/src/openhuman/skills/stub.rs @@ -0,0 +1,101 @@ +//! Disabled-skills facade. +//! +//! Compiled only when the `skills` Cargo feature is OFF (see the gate in +//! [`super`]). It mirrors the subset of the real `skills` public surface that +//! always-on callers depend on, with no-op / empty bodies so the crate still +//! compiles, boots, and serves `/rpc` without the skills domains. +//! +//! Unlike the `voice` stub, this one mirrors **functions only**: the domain's +//! types live in the ungated [`super::types`] / [`super::ops_types`] carve-out +//! and are re-exported verbatim below, so there is zero type duplication and +//! correspondingly less drift surface. +//! +//! The signatures here MUST match the real ones exactly (return types +//! included). The disabled build +//! (`cargo check --no-default-features --features tokenjuice-treesitter`) is +//! the only thing that catches drift — if a real signature changes, update the +//! mirror below until that build is green again. + +use std::path::Path; + +use crate::core::all::RegisteredController; +use crate::core::ControllerSchema; + +// --------------------------------------------------------------------------- +// Type surface — re-exported from the ungated carve-out, NOT re-declared. +// Mirrors the `pub use ops::*` → `pub use super::ops_types::{…}` chain that the +// real build exposes at the `skills` root (`skills::Workflow`, etc.). +// --------------------------------------------------------------------------- + +pub use super::ops_types::{ + Workflow, WorkflowFrontmatter, WorkflowScope, MAX_WORKFLOW_RESOURCE_BYTES, +}; + +// --------------------------------------------------------------------------- +// Discovery surface (mirrors `ops_discover::*` re-exported at the skills root) +// --------------------------------------------------------------------------- + +/// Always empty: no skills are discovered when the domain is compiled out. +/// Callers (agent harness, channel startup, prompt rendering) treat an empty +/// catalog as "user has no skills installed", which is the correct degraded +/// behaviour. +pub fn load_workflow_metadata(_workspace_dir: &Path) -> Vec { + log::debug!("[skills-stub] load_workflow_metadata -> [] (skills disabled)"); + Vec::new() +} + +/// No-op success: with skills compiled out there is no skills directory to +/// provision, and workspace bootstrap must not fail because of it. +pub fn init_workflows_dir(_workspace_dir: &Path) -> Result<(), String> { + log::debug!("[skills-stub] init_workflows_dir skipped (skills disabled)"); + Ok(()) +} + +// --------------------------------------------------------------------------- +// Controller aggregators — empty so `src/core/all.rs` needs no `#[cfg]`. +// --------------------------------------------------------------------------- + +/// Always empty: the `openhuman.skills_*` controllers are compiled out, so +/// they never enter the registry (unknown-method over `/rpc`, absent from +/// `/schema`). +pub fn all_skills_registered_controllers() -> Vec { + Vec::new() +} + +/// Always empty — see [`all_skills_registered_controllers`]. +pub fn all_skills_controller_schemas() -> Vec { + Vec::new() +} + +// --------------------------------------------------------------------------- +// registry::prune_legacy_default_workflows +// --------------------------------------------------------------------------- + +pub mod registry { + use std::path::Path; + + /// No-op: the legacy bundled-skill prune only removes directories this + /// domain created, and it never ran when skills are compiled out. + pub fn prune_legacy_default_workflows(_workspace_dir: &Path) { + log::debug!("[skills-stub] prune_legacy_default_workflows skipped (skills disabled)"); + } +} + +// --------------------------------------------------------------------------- +// bus::{ensure_triggered_workflow_subscriber, register_workflow_cleanup_subscriber} +// --------------------------------------------------------------------------- + +pub mod bus { + /// No-op: there are no triggered skills to subscribe to the event bus for. + 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 +// `tools/mod.rs` is `#[cfg(feature = "skills")]` instead — mirroring the +// `voice` gate's handling of the `audio_toolkit::tools::*` glob. An empty stub +// module would re-export nothing and trip `unused_imports` at the glob site. diff --git a/src/openhuman/tools/mod.rs b/src/openhuman/tools/mod.rs index 1b18ff8b4b..074838b02a 100644 --- a/src/openhuman/tools/mod.rs +++ b/src/openhuman/tools/mod.rs @@ -46,8 +46,11 @@ pub use crate::openhuman::screen_intelligence::tools::*; pub use crate::openhuman::search::tools::*; pub use crate::openhuman::security::tools::*; pub use crate::openhuman::service::tools::*; +#[cfg(feature = "skills")] pub use crate::openhuman::skill_registry::tools::*; +#[cfg(feature = "skills")] pub use crate::openhuman::skill_runtime::tools::*; +#[cfg(feature = "skills")] pub use crate::openhuman::skills::tools::*; pub use crate::openhuman::task_sources::tools::*; pub use crate::openhuman::team::tools::*; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index adb7b52617..7369298d41 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -101,6 +101,11 @@ pub fn all_tools_with_runtime( skill_allowlist: Option<&std::collections::HashSet>, mcp_allowlist: Option<&[String]>, ) -> Vec> { + // `skill_allowlist` scopes only the `skills`-gated tool registrations + // below, so it is genuinely unread when that feature is compiled out. + #[cfg(not(feature = "skills"))] + let _ = skill_allowlist; + // Build a session-scoped managed Node.js bootstrap once, so ShellTool, // NodeExecTool, and NpmExecTool all share the same memoised resolution // state. Disabled when `node.enabled = false` — in that case shell skips @@ -220,7 +225,9 @@ pub fn all_tools_with_runtime( // Both wrap `skill_runtime::spawn_workflow_run_background` + // `await_run_outcome` — the same spawn path `openhuman.skills_run` // JSON-RPC uses, so RPC and tool callers stay in sync. + #[cfg(feature = "skills")] Box::new(RunWorkflowTool::new().with_skill_allowlist(skill_allowlist.cloned())), + #[cfg(feature = "skills")] Box::new(AwaitWorkflowTool::new()), Box::new(CurrentTimeTool::new()), // Reversibility for native tool-output compaction (Stage 1a): when a @@ -472,9 +479,11 @@ pub fn all_tools_with_runtime( // above, so it is not duplicated. Reads ship default-ON; the // create/install/uninstall mutators ship default-OFF via // `tools::user_filter` (install also fetches remote content). + #[cfg(feature = "skills")] Box::new( WorkflowListTool::new(config.clone()).with_skill_allowlist(skill_allowlist.cloned()), ), + #[cfg(feature = "skills")] Box::new( WorkflowDescribeTool::new(config.clone()) .with_skill_allowlist(skill_allowlist.cloned()), @@ -482,22 +491,34 @@ pub fn all_tools_with_runtime( // Skill registry tools — browse/search/install from remote registries. // Browse and search are read-only (default-ON); install is a write // operation (fetches remote content and writes to disk). + #[cfg(feature = "skills")] Box::new(SkillRegistryBrowseTool), + #[cfg(feature = "skills")] Box::new(SkillRegistrySearchTool), + #[cfg(feature = "skills")] Box::new(SkillRegistryInstallTool::new(config.clone())), + #[cfg(feature = "skills")] Box::new(SkillRegistrySourcesTool), + #[cfg(feature = "skills")] Box::new(SkillRegistryUninstallTool), // Skill runtime probes — resolve the reusable Node/Python runtimes // that skill execution relies on before a script-backed skill runs. + #[cfg(feature = "skills")] Box::new(SkillRuntimeResolveRuntimesTool::new(config.clone())), + #[cfg(feature = "skills")] Box::new( WorkflowReadResourceTool::new(config.clone()) .with_skill_allowlist(skill_allowlist.cloned()), ), + #[cfg(feature = "skills")] Box::new(WorkflowRecentRunsTool::new(config.clone())), + #[cfg(feature = "skills")] Box::new(WorkflowReadRunLogTool::new(config.clone())), + #[cfg(feature = "skills")] Box::new(WorkflowCreateTool::new(config.clone())), + #[cfg(feature = "skills")] Box::new(WorkflowInstallFromUrlTool::new(config.clone())), + #[cfg(feature = "skills")] Box::new(WorkflowUninstallTool), // Threads (conversation) tools. Read/bounded-write ship default-ON; // the destructive thread_delete / thread_purge_all ship default-OFF From 78546ff235ae79c1ac5d4bc21722ca7231bce194 Mon Sep 17 00:00:00 2001 From: CodeGhost21 <164498022+CodeGhost21@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:17:27 +0530 Subject: [PATCH 50/86] fix(build): forward tokenjuice-treesitter + gate feature-forwarding drift in CI (#4918, #4919) (#4934) Co-authored-by: M3gA-Mind --- .github/workflows/ci-lite.yml | 26 +++ AGENTS.md | 4 + app/src-tauri/Cargo.toml | 10 +- scripts/__tests__/feature-forwarding.test.mjs | 187 +++++++++++++++++ scripts/ci/check-feature-forwarding.mjs | 80 ++++++++ scripts/lib/feature-forwarding.mjs | 189 ++++++++++++++++++ 6 files changed, 494 insertions(+), 2 deletions(-) create mode 100644 scripts/__tests__/feature-forwarding.test.mjs create mode 100644 scripts/ci/check-feature-forwarding.mjs create mode 100644 scripts/lib/feature-forwarding.mjs diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index 77839de64c..1e02e343dd 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -665,6 +665,30 @@ jobs: - name: Run orchestration IP gate run: bash scripts/ci/orch-ip-gate.sh + feature-forwarding-gate: + name: Feature Forwarding Gate (shell forwards core defaults) + runs-on: ubuntu-22.04 + timeout-minutes: 5 + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 1 + persist-credentials: false + + # The shell sets `default-features = false` on `openhuman_core`, so it does + # NOT inherit the core's default gates — each must be forwarded by hand. + # When that list drifts, the domain is compiled out of the shipped app with + # no build error and no failing test: that is how #4901 (voice — 56 users, + # ~93k Sentry events) and #4918 (tokenjuice-treesitter — silent) shipped. + # + # Deliberately NOT filtered on `changes`: drift can be introduced by + # editing either manifest, so a path filter watching one would miss it, and + # a skipped job counts as a pass in the gate below. The check is a few + # seconds of pure Node with no dependencies. + - name: Verify the desktop shell forwards every default-ON core gate + run: node scripts/ci/check-feature-forwarding.mjs + pester-install: name: PowerShell Install Test (Pester) needs: [changes] @@ -731,6 +755,7 @@ jobs: - pester-install - tinycortex-tests - orch-ip-gate + - feature-forwarding-gate if: always() runs-on: ubuntu-latest timeout-minutes: 15 @@ -749,6 +774,7 @@ jobs: ["PowerShell Install Test"]="${{ needs['pester-install'].result }}" ["TinyCortex Memory Tests"]="${{ needs['tinycortex-tests'].result }}" ["Orchestration IP Gate"]="${{ needs['orch-ip-gate'].result }}" + ["Feature Forwarding Gate"]="${{ needs['feature-forwarding-gate'].result }}" ) failed=0 diff --git a/AGENTS.md b/AGENTS.md index 2840c0ac12..364dedf352 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -222,6 +222,10 @@ Two independent runtime axes on `CoreBuilder` (`src/core/runtime/builder.rs`): Per-domain Cargo features drop whole domains **at compile time** (smaller binary, fewer deps), composing with the runtime `DomainSet` axis above. Each gate is **default-ON**, so the desktop build is byte-identical; slim builds opt out explicitly. +> **Adding a default-ON gate? You must forward it to the desktop shell.** +> `app/src-tauri/Cargo.toml` declares `openhuman_core` with `default-features = false` (set in #1061, before gates existed), so the shipped app does **not** inherit the core's `default` list. A gate you add to `default` but not to the shell's `features` list is **compiled out of the shipped desktop app** — with no build error and no failing test. This is not hypothetical: `voice` shipped missing from v0.58.19 to v0.61.x (56 users, ~93k Sentry events, #4901), and `tokenjuice-treesitter` was never forwarded once since #4123 and failed *soft*, silently degrading AST compression (#4918). +> `scripts/ci/check-feature-forwarding.mjs` (the **Feature Forwarding Gate** lane) now fails CI on drift and covers new gates automatically. If a gate genuinely must not ship, add it to `INTENTIONALLY_NOT_FORWARDED` in that script **with a reason** — an explicit exclusion is the only way "deliberate" stays distinguishable from "forgotten". + **Slim-profile convention** (no `full` meta-feature): build slim variants with `cargo build --no-default-features --features ""`. This mirrors the existing standalone-feature style (`sandbox-landlock`, `browser-native`, …). Example — everything except voice: ```bash diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 6909d53d45..1677ee9280 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -152,11 +152,17 @@ cef = { version = "=146.4.1", default-features = false } # the pre-gate desktop tool surface. # - `web3` — keeps the wallet/web3/x402 domains and their agent tools in the # desktop build while allowing slim builds to omit the crypto-only deps. +# - `tokenjuice-treesitter` — forwards `tinyjuice/tinyjuice-treesitter`, which +# pulls the tree-sitter Rust/TS/Python grammars. Without it TokenJuice falls +# back to the brace-depth heuristic, so the desktop app compresses code worse +# than intended. This failed *soft* (no error, no Sentry signal) and had never +# once been forwarded since the gate was added in #4123 (#4918). # -# `tokenjuice-treesitter` is the remaining un-forwarded default — tracked in -# #4918, deliberately not bundled here because dropping it may be intentional. +# `scripts/ci/check-feature-forwarding.mjs` fails CI when this list drifts from +# the core's `[features] default` (#4919) — do not hand-maintain it from memory. openhuman_core = { path = "../..", package = "openhuman", default-features = false, features = [ "media", + "tokenjuice-treesitter", "voice", "tokenjuice-treesitter", "web3", diff --git a/scripts/__tests__/feature-forwarding.test.mjs b/scripts/__tests__/feature-forwarding.test.mjs new file mode 100644 index 0000000000..745c3a6c82 --- /dev/null +++ b/scripts/__tests__/feature-forwarding.test.mjs @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { + diffForwarding, + parseCoreDefaultFeatures, + parseShellForwardedFeatures, + stripComments, +} from '../lib/feature-forwarding.mjs'; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const CHECKER = resolve(REPO_ROOT, 'scripts/ci/check-feature-forwarding.mjs'); + +// ── parsing ──────────────────────────────────────────────────────────────── + +test('parses the core default gate list', () => { + const toml = ` +[features] +default = ["tokenjuice-treesitter", "voice", "media"] +voice = ["dep:hound"] +`; + assert.deepEqual(parseCoreDefaultFeatures(toml), ['tokenjuice-treesitter', 'voice', 'media']); +}); + +test('parses a multi-line default gate list', () => { + const toml = ` +[features] +default = [ + "voice", + "media", +] +`; + assert.deepEqual(parseCoreDefaultFeatures(toml), ['voice', 'media']); +}); + +test('ignores a default key belonging to another table', () => { + const toml = ` +[some-other-table] +default = ["not-a-gate"] + +[features] +default = ["voice"] +`; + assert.deepEqual(parseCoreDefaultFeatures(toml), ['voice']); +}); + +test('parses the shell forwarded list across multiple lines', () => { + const toml = ` +openhuman_core = { path = "../..", package = "openhuman", default-features = false, features = [ + "media", + "voice", +] } +`; + assert.deepEqual(parseShellForwardedFeatures(toml), { + defaultFeatures: false, + features: ['media', 'voice'], + }); +}); + +test('detects when the shell inherits defaults instead of forwarding', () => { + const toml = 'openhuman_core = { path = "../..", package = "openhuman" }\n'; + assert.deepEqual(parseShellForwardedFeatures(toml), { defaultFeatures: true, features: [] }); +}); + +test('comment stripping does not truncate on a # inside a quoted value', () => { + const stripped = stripComments('a = "issue #4901" # trailing comment\n'); + assert.match(stripped, /issue #4901/); + assert.doesNotMatch(stripped, /trailing comment/); +}); + +test('a commented-out gate does not count as forwarded', () => { + const toml = ` +openhuman_core = { path = "../..", package = "openhuman", default-features = false, features = [ + # "voice", + "media", +] } +`; + assert.deepEqual(parseShellForwardedFeatures(toml).features, ['media']); +}); + +// ── drift detection ──────────────────────────────────────────────────────── + +test('passes when every default gate is forwarded', () => { + const result = diffForwarding({ + coreDefaults: ['voice', 'media'], + shell: { defaultFeatures: false, features: ['media', 'voice'] }, + }); + assert.equal(result.ok, true); + assert.deepEqual(result.missing, []); +}); + +test('reproduces #4901: a dropped voice gate is reported missing', () => { + const result = diffForwarding({ + coreDefaults: ['tokenjuice-treesitter', 'voice', 'media'], + shell: { defaultFeatures: false, features: ['media', 'tokenjuice-treesitter'] }, + }); + assert.equal(result.ok, false); + assert.deepEqual(result.missing, ['voice']); +}); + +test('reproduces #4918: a dropped tokenjuice-treesitter gate is reported missing', () => { + const result = diffForwarding({ + coreDefaults: ['tokenjuice-treesitter', 'voice', 'media'], + shell: { defaultFeatures: false, features: ['media', 'voice'] }, + }); + assert.equal(result.ok, false); + assert.deepEqual(result.missing, ['tokenjuice-treesitter']); +}); + +test('a brand new default gate is covered automatically, with no per-gate wiring', () => { + const result = diffForwarding({ + coreDefaults: ['voice', 'media', 'some-future-gate'], + shell: { defaultFeatures: false, features: ['voice', 'media'] }, + }); + assert.equal(result.ok, false); + assert.deepEqual(result.missing, ['some-future-gate']); +}); + +test('an allow-listed gate passes and is reported as intentional', () => { + const result = diffForwarding({ + coreDefaults: ['voice', 'heavy-gate'], + shell: { defaultFeatures: false, features: ['voice'] }, + allowlist: { 'heavy-gate': 'Adds 400MB of models to the bundle.' }, + }); + assert.equal(result.ok, true); + assert.deepEqual(result.allowed, ['heavy-gate']); + assert.deepEqual(result.missing, []); +}); + +test('an allow-list entry for a gate that IS forwarded is flagged as stale', () => { + const result = diffForwarding({ + coreDefaults: ['voice'], + shell: { defaultFeatures: false, features: ['voice'] }, + allowlist: { voice: 'stale entry' }, + }); + assert.equal(result.ok, false); + assert.deepEqual(result.stale, ['voice']); +}); + +test('inheriting defaults needs no forwarding', () => { + const result = diffForwarding({ + coreDefaults: ['voice'], + shell: { defaultFeatures: true, features: [] }, + }); + assert.equal(result.ok, true); +}); + +test('a missing dependency fails rather than passing vacuously', () => { + const result = diffForwarding({ coreDefaults: ['voice'], shell: null }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'dependency-not-found'); +}); + +// ── the real manifests + CLI ─────────────────────────────────────────────── + +test('the checked-in manifests pass the guard', () => { + const out = execFileSync('node', [CHECKER], { encoding: 'utf8' }); + assert.match(out, /every default-ON core gate is forwarded/); +}); + +test('--help exits 0', () => { + const out = execFileSync('node', [CHECKER, '--help'], { encoding: 'utf8' }); + assert.match(out, /Usage:/); +}); + +test('the real shell manifest forwards every real core default', () => { + const coreDefaults = parseCoreDefaultFeatures( + readFileSync(resolve(REPO_ROOT, 'Cargo.toml'), 'utf8') + ); + const shell = parseShellForwardedFeatures( + readFileSync(resolve(REPO_ROOT, 'app/src-tauri/Cargo.toml'), 'utf8') + ); + // Guards the guard: if the parser silently returned nothing, the assertions + // below would pass against empty input and prove nothing. + 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) { + 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 new file mode 100644 index 0000000000..76844cc4fc --- /dev/null +++ b/scripts/ci/check-feature-forwarding.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +// Fails when the desktop shell does not forward a default-ON core Cargo gate. +// +// See scripts/lib/feature-forwarding.mjs for why this exists (#4919). Short +// version: the shell sets `default-features = false` on `openhuman_core`, so +// every default-ON gate must be forwarded by hand. When someone forgets, the +// domain vanishes from the shipped app with no build error — that is how #4901 +// (voice, 56 users) and #4918 (tokenjuice-treesitter) shipped. +// +// Usage: check-feature-forwarding.mjs [core-manifest] [shell-manifest] +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + diffForwarding, + formatReport, + 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.', +}; + +function usage() { + return 'Usage: check-feature-forwarding.mjs [core-manifest] [shell-manifest]'; +} + +const [coreArg, shellArg, extra] = process.argv.slice(2); +if (coreArg === '--help' || coreArg === '-h') { + console.log(usage()); + process.exit(0); +} +if (extra) { + console.error(usage()); + process.exit(2); +} + +const corePath = coreArg ? resolve(coreArg) : resolve(REPO_ROOT, 'Cargo.toml'); +const shellPath = shellArg ? resolve(shellArg) : resolve(REPO_ROOT, 'app/src-tauri/Cargo.toml'); + +let coreToml; +let shellToml; +try { + coreToml = readFileSync(corePath, 'utf8'); + shellToml = readFileSync(shellPath, 'utf8'); +} catch (err) { + console.error(`Could not read manifests: ${err.message}`); + process.exit(2); +} + +const coreDefaults = parseCoreDefaultFeatures(coreToml); +const shell = parseShellForwardedFeatures(shellToml); + +// A parser that silently finds nothing would turn this guard into a rubber +// stamp, which is worse than not having it. Treat "no defaults found" as a +// failure of the check itself rather than a pass. +if (coreDefaults.length === 0) { + console.error( + `FAIL: parsed zero default features from ${corePath}.\n` + + 'Either the manifest changed shape or the parser is broken — refusing to pass vacuously.' + ); + process.exit(2); +} + +const result = diffForwarding({ coreDefaults, shell, allowlist: INTENTIONALLY_NOT_FORWARDED }); +console.log(formatReport(result, { coreDefaults, shell, allowlist: INTENTIONALLY_NOT_FORWARDED })); +process.exit(result.ok ? 0 : 1); diff --git a/scripts/lib/feature-forwarding.mjs b/scripts/lib/feature-forwarding.mjs new file mode 100644 index 0000000000..a105e0646b --- /dev/null +++ b/scripts/lib/feature-forwarding.mjs @@ -0,0 +1,189 @@ +// Detects drift between the core crate's default-ON Cargo gates and the gates +// the Tauri shell forwards to its embedded copy of that crate. +// +// Why this exists (#4919): the shell declares `openhuman_core` with +// `default-features = false`, so it does NOT inherit the core's `default` list. +// Every default-ON gate must be forwarded by hand, and nothing enforced that. +// When the two drift, the domain is compiled out of the shipped desktop app and +// the failure is invisible — no build error, no failing test: +// +// - `voice` shipped missing from v0.58.19 to v0.61.x. Every `openhuman.voice_*` +// RPC answered "unknown method"; 56 users, ~93k Sentry events (#4901). +// - `tokenjuice-treesitter` was never forwarded once since #4123 and failed +// *soft* — AST compression silently degraded to a heuristic (#4918). +// +// Two of three gates were dropped, by two different authors, one of whom knew +// about the trap and documented it in a comment. A comment is documentation, +// not enforcement. +// +// This is a deliberately narrow TOML reader rather than a general parser: it +// 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`. + +/** + * 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. + */ +export function stripComments(text) { + const out = []; + for (const line of text.split(/\r?\n/)) { + let quote = null; + let cut = -1; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (quote) { + if (ch === quote && line[i - 1] !== '\\') quote = null; + } else if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '#') { + cut = i; + break; + } + } + out.push(cut === -1 ? line : line.slice(0, cut)); + } + return out.join('\n'); +} + +/** + * Read a bracketed array starting at `open` (the index of `[`), returning its + * raw inner text. Scans for the balanced close so multi-line arrays work. + */ +function readArray(text, open) { + let depth = 0; + for (let i = open; i < text.length; i++) { + if (text[i] === '[') depth++; + else if (text[i] === ']') { + depth--; + if (depth === 0) return text.slice(open + 1, i); + } + } + return null; +} + +/** Pull the quoted string items out of a raw TOML array body. */ +function arrayItems(raw) { + if (raw === null) return []; + return [...raw.matchAll(/"([^"]+)"/g)].map(m => m[1]); +} + +/** + * The core crate's default-ON gates: `[features] default = [...]`. + * + * Scoped to the `[features]` table so an unrelated `default = [...]` in another + * table cannot be picked up by mistake. + */ +export function parseCoreDefaultFeatures(coreToml) { + const text = stripComments(coreToml); + // NOTE: `[ \t]` not `\s` — `\s` matches newlines, so `^\s*\[features\]` would + // happily anchor several lines early and slice the section to nothing. + const header = text.match(/^[ \t]*\[features\][ \t]*$/m); + if (!header) return []; + // Bound the search at the next table header so we stay inside [features]. + const rest = text.slice(header.index + header[0].length); + const nextTable = rest.search(/^[ \t]*\[[^[\]]+\][ \t]*$/m); + const section = nextTable === -1 ? rest : rest.slice(0, nextTable); + const defaultAt = section.search(/^[ \t]*default[ \t]*=[ \t]*\[/m); + if (defaultAt === -1) return []; + return arrayItems(readArray(section, section.indexOf('[', defaultAt))); +} + +/** + * What the shell forwards on its `openhuman_core` dependency. + * + * Returns `{ defaultFeatures, features }`. `defaultFeatures: true` means the + * shell inherits the core's defaults and forwarding is moot — there is nothing + * to drift. + */ +export function parseShellForwardedFeatures(shellToml, depName = 'openhuman_core') { + const text = stripComments(shellToml); + // `[ \t]` not `\s`, for the same newline-matching reason as above. + const declAt = text.search(new RegExp(`^[ \\t]*${depName}[ \\t]*=[ \\t]*\\{`, 'm')); + if (declAt === -1) return null; + const braceOpen = text.indexOf('{', declAt); + // Scan to the matching close brace; the inline table spans lines. + let depth = 0; + let braceClose = -1; + for (let i = braceOpen; i < text.length; i++) { + if (text[i] === '{') depth++; + else if (text[i] === '}') { + depth--; + if (depth === 0) { + braceClose = i; + break; + } + } + } + if (braceClose === -1) return null; + const decl = text.slice(braceOpen, braceClose + 1); + const defaultFeatures = !/default-features\s*=\s*false/.test(decl); + const featuresAt = decl.search(/features\s*=\s*\[/); + const features = + featuresAt === -1 ? [] : arrayItems(readArray(decl, decl.indexOf('[', featuresAt))); + return { defaultFeatures, features }; +} + +/** + * Compare the two lists. + * + * `allowlist` maps a gate name to the reason it is intentionally NOT forwarded. + * An intentional exclusion must be explicit and carry a reason, so that + * "deliberately excluded" and "forgotten" stop looking identical — which is the + * ambiguity that let #4918 sit unnoticed. + */ +export function diffForwarding({ coreDefaults, shell, allowlist = {} }) { + if (shell === null) { + return { ok: false, reason: 'dependency-not-found', missing: [], stale: [], allowed: [] }; + } + // Inheriting defaults means there is no forwarding list to drift. + if (shell.defaultFeatures) { + return { ok: true, reason: 'inherits-defaults', missing: [], stale: [], allowed: [] }; + } + const forwarded = new Set(shell.features); + const missing = []; + const allowed = []; + for (const gate of coreDefaults) { + if (forwarded.has(gate)) continue; + if (Object.prototype.hasOwnProperty.call(allowlist, gate)) allowed.push(gate); + else missing.push(gate); + } + // A gate that is allow-listed AND forwarded is a contradiction: the allow-list + // entry is stale and would mask a real drop if the gate were later removed. + const stale = Object.keys(allowlist).filter(gate => forwarded.has(gate)); + return { ok: missing.length === 0 && stale.length === 0, reason: null, missing, stale, allowed }; +} + +export function formatReport(result, { coreDefaults, shell, allowlist = {} }) { + if (result.reason === 'dependency-not-found') { + return 'FAIL: could not find the `openhuman_core` dependency in the shell manifest.\nThe guard cannot verify forwarding — fix the parser or the manifest.'; + } + if (result.reason === 'inherits-defaults') { + return 'OK: the shell inherits the core default features (no `default-features = false`), so no forwarding is required.'; + } + const lines = [ + `Core default gates (${coreDefaults.length}): ${coreDefaults.join(', ') || '(none)'}`, + `Shell forwards (${shell.features.length}): ${shell.features.join(', ') || '(none)'}`, + ]; + for (const gate of result.allowed) { + lines.push(` allowed: ${gate} — ${allowlist[gate]}`); + } + if (result.stale.length > 0) { + lines.push('', 'Stale allow-list entries (gate is forwarded, so the entry is wrong):'); + for (const gate of result.stale) lines.push(` - ${gate}`); + } + if (result.missing.length > 0) { + lines.push('', 'Default-ON core gates NOT forwarded by the desktop shell:'); + for (const gate of result.missing) lines.push(` - ${gate}`); + lines.push( + '', + 'Each of these is compiled OUT of the shipped desktop app, silently.', + 'Fix by adding the gate to the `openhuman_core` features list in', + 'app/src-tauri/Cargo.toml — or, if the exclusion is deliberate, add it to', + 'INTENTIONALLY_NOT_FORWARDED in scripts/ci/check-feature-forwarding.mjs', + 'with a reason. See #4901 (voice) and #4918 (tokenjuice-treesitter).' + ); + } + if (result.ok) + lines.push('', 'OK: every default-ON core gate is forwarded to the desktop shell.'); + return lines.join('\n'); +} From 31aba3bbea4f8f0c2b0cfe98dbf757d6961bc498 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:01:28 +0530 Subject: [PATCH 51/86] fix(flows): trail-off backstop no longer clobbers real questions (#4956) --- src/openhuman/flows/ops.rs | 181 ++++++++++++++++++++++++++++--- src/openhuman/flows/ops_tests.rs | 144 +++++++++++++++++++++++- 2 files changed, 305 insertions(+), 20 deletions(-) diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index 4ee914ab64..2075d20722 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -4367,15 +4367,17 @@ pub async fn flows_build( let trail_off = !capped && proposal.is_none() && run_error.is_none(); let assistant_text = if trail_off && !text_looks_like_question(&assistant_text) { let fallback = build_trail_off_fallback(agent.history()); + let combined = combine_trail_off_fallback(&fallback, &assistant_text); tracing::warn!( target: "flows", flow_id = req.flow_id.as_deref().unwrap_or(""), original_len = assistant_text.len(), fallback_len = fallback.len(), + combined_len = combined.len(), "[flows] flows_build: trail-off detected (no proposal, no cap, no question) — \ - guaranteeing a fallback question instead of silence" + guaranteeing a fallback question while preserving the model's original text" ); - fallback + combined } else { assistant_text }; @@ -4414,15 +4416,31 @@ pub async fn flows_build( )) } -/// Heuristic: does `text` already end with a clear, answerable question? -/// Conservative by design (issue: builder convergence) — a false negative (an -/// actual question this misses) just wraps it in the trail-off fallback, -/// which still includes the blocker context, so the safe failure mode is -/// "over-wrap", never "under-detect and stay silent". +/// Heuristic: does `text` already contain a clear, answerable question in its +/// final paragraph? Conservative by design (issue: builder convergence) — a +/// false negative (an actual question this misses) no longer discards the +/// model's text (see `combine_trail_off_fallback`), so the safe failure mode +/// stays "add a guaranteed question on top", never "under-detect and stay +/// silent". +/// +/// Regression (#4887 follow-up): the original version only checked for a `?` +/// at the very end of the text / last line, which false-negatived on the +/// extremely common LLM pattern "What's X? You can find it at Y." — a real +/// question immediately followed by a trailing instructional sentence. The +/// backstop then clobbered a specific, answerable question with a generic +/// fallback. To catch that shape, this now also scans the LAST non-empty +/// paragraph for a `?` that isn't inside inline code or a fenced code block +/// (so a literal `?` in a code sample, e.g. `WHERE id = ?`, doesn't count). +/// +/// Note: the trailing-noise strip below deliberately does NOT include the +/// backtick. Stripping a trailing backtick would peel off the CLOSING +/// delimiter of a code span whose last character is `?` (e.g. `` `id = ?` `` +/// at the very end of the text), exposing that `?` as if it were a bare +/// trailing question mark and defeating the code guard entirely. fn text_looks_like_question(text: &str) -> bool { let trimmed = text .trim() - .trim_end_matches(['"', '\'', ')', ']', '*', '_', '`', '.']) + .trim_end_matches(['"', '\'', ')', ']', '*', '_', '.']) .trim_end(); if trimmed.is_empty() { return false; @@ -4432,15 +4450,132 @@ fn text_looks_like_question(text: &str) -> bool { } // The question may not be the literal last character (trailing markdown // like a closing code fence or list marker on its own line) — fall back - // to the last non-blank line. This does NOT catch a question followed by - // a further trailing sentence ("...channel?\n\nLet me know!") — that's - // an accepted false negative: the turn still ends in a real (if - // over-eagerly replaced) question, never in silence, which is the - // invariant this function exists to protect. - trimmed + // to the last non-blank line. + if trimmed .lines() .rfind(|line| !line.trim().is_empty()) .is_some_and(|last_line| last_line.trim_end().ends_with('?')) + { + return true; + } + // Final-paragraph scan: a question can sit mid-paragraph, followed by a + // further trailing sentence on the SAME line/paragraph ("...ID? You can + // find it under Profile > Copy member ID."). Take the last non-blank + // paragraph and accept it if it contains a `?` that isn't inside inline + // code / a code fence. + last_paragraph(trimmed) + .as_deref() + .is_some_and(question_mark_outside_code) +} + +/// Returns the last non-blank paragraph of `text` — a maximal run of +/// consecutive non-blank lines, working backward from the end and skipping +/// any trailing blank lines first. `None` if `text` has no non-blank lines. +/// +/// CodeRabbit review follow-up: this used to split on the literal `"\n\n"` +/// byte sequence, which mishandles two real shapes: +/// - **CRLF input** (`"question?\r\n\r\nstatus"`): the separator is +/// `"\r\n\r\n"`, not `"\n\n"`, so the whole text was treated as ONE +/// paragraph — an earlier question could then suppress the fallback for a +/// trailing non-question status paragraph. +/// - **Whitespace-only separator lines** (`"question?\n \nstatus"` — a blank +/// line that isn't perfectly empty): same failure, same reason. +/// +/// Working line-by-line via [`str::lines`] (which normalizes CRLF) and +/// treating any all-whitespace line as blank fixes both. +fn last_paragraph(text: &str) -> Option { + let mut collected: Vec<&str> = Vec::new(); + for line in text.lines().rev() { + if line.trim().is_empty() { + if collected.is_empty() { + continue; // still skipping trailing blank lines + } + break; // blank line marks the start of the paragraph above + } + collected.push(line); + } + if collected.is_empty() { + return None; + } + collected.reverse(); + Some(collected.join("\n")) +} + +/// Does `text` contain at least one *sentence-terminal* `?` that isn't +/// inside a backtick-delimited code span (inline code like `` `U...` `` or a +/// fenced block like `` ``` ``)? Follows the CommonMark code-span rule: a +/// *run* of one or more consecutive backticks opens a span, and that span is +/// closed only by the next run of the SAME length — a shorter or longer run +/// of backticks encountered while inside a span is just literal backtick +/// characters, not a delimiter. +/// +/// CodeRabbit review follow-up: an earlier version tracked a running +/// per-character backtick COUNT and used its parity (even = outside code). +/// That misclassifies any multi-backtick span whose delimiter is more than +/// one backtick — e.g. ``` ``SELECT ? FROM t`` ``` opens with a 2-backtick +/// run (count 0→2, even → looks "outside" again immediately), so the `?` +/// inside a valid double-backtick span was wrongly treated as outside code. +/// Tracking delimiter run LENGTH (not raw backtick count) fixes this while +/// still handling the common single-backtick and triple-backtick-fence +/// cases, since those are just the run-length-1 and run-length-3 instances +/// of the same rule. +/// +/// Codex review follow-up: a bare `?` outside code isn't necessarily a real +/// question — a status line like "Checked https://api.example/search?q=foo +/// and got 403." has one mid-token, in a URL query string. Counting that +/// would flip `text_looks_like_question` to `true` and skip +/// `combine_trail_off_fallback` entirely, leaving the user with an +/// unanswerable status note — exactly the failure mode this backstop exists +/// to prevent. So each candidate `?` is additionally required to be +/// sentence-terminal via [`is_sentence_terminal_question_mark`]. +fn question_mark_outside_code(text: &str) -> bool { + let chars: Vec = text.chars().collect(); + // `Some(n)` while scanning is inside a code span opened by a run of `n` + // backticks; that span closes only on the next run of exactly `n`. + let mut open_run_len: Option = None; + let mut i = 0; + while i < chars.len() { + if chars[i] == '`' { + let start = i; + while i < chars.len() && chars[i] == '`' { + i += 1; + } + let run_len = i - start; + open_run_len = match open_run_len { + None => Some(run_len), + Some(n) if n == run_len => None, + Some(n) => Some(n), // mismatched run length: still inside the span + }; + continue; + } + if chars[i] == '?' + && open_run_len.is_none() + && is_sentence_terminal_question_mark(&chars, i) + { + return true; + } + i += 1; + } + false +} + +/// Is the `?` at `chars[index]` sentence-terminal — i.e. does it read as an +/// actual question mark rather than a character that merely happens to be a +/// `?` mid-token (a URL query string like `search?q=foo`, a shell glob, +/// etc.)? Skips over any immediately-following closing quote/bracket +/// punctuation (`"`, `'`, right single/double quotes, `)`, `]`) and requires +/// what remains to be whitespace or the end of the text — the shape a `?` +/// takes at the end of a real sentence or clause. +fn is_sentence_terminal_question_mark(chars: &[char], index: usize) -> bool { + let mut i = index + 1; + while let Some(&c) = chars.get(i) { + if matches!(c, '"' | '\'' | '\u{2019}' | '\u{201D}' | ')' | ']') { + i += 1; + continue; + } + return c.is_whitespace(); + } + true // '?' was the last character in the paragraph. } /// Builder-authoring tools whose result body can explain a trail-off — the @@ -4480,6 +4615,24 @@ fn build_trail_off_fallback( } } +/// Combines the guaranteed trail-off `fallback` question with the model's own +/// `original` text instead of discarding it (#4887 follow-up, Change 2). Even +/// after loosening `text_looks_like_question`, a future false negative must +/// never destroy the model's words — it should only ever ADD the guaranteed +/// question on top. The `fallback` is prepended (so the user sees the +/// actionable question first) and the original is kept below a divider for +/// context. When `original` is empty/whitespace-only (a genuine silent +/// turn — there's nothing to preserve), returns the fallback alone rather +/// than prepending an empty divider. +fn combine_trail_off_fallback(fallback: &str, original: &str) -> String { + let trimmed_original = original.trim(); + if trimmed_original.is_empty() { + fallback.to_string() + } else { + format!("{fallback}\n\n---\n\n{trimmed_original}") + } +} + /// Scans `history` in reverse for the last result from a /// [`TRAIL_OFF_BLOCKER_TOOLS`] call that reads as a failure — a plain-text /// error message (gate rejection), or a JSON body with `"ok": false` — and diff --git a/src/openhuman/flows/ops_tests.rs b/src/openhuman/flows/ops_tests.rs index 5744046515..09a9e9db5d 100644 --- a/src/openhuman/flows/ops_tests.rs +++ b/src/openhuman/flows/ops_tests.rs @@ -4751,18 +4751,120 @@ fn text_looks_like_question_detects_trailing_question_mark() { )); } -/// A question followed by a further trailing sentence on its own line -/// ("...channel?\n\nLet me know!") is an accepted false negative — the -/// heuristic is deliberately conservative (see the function doc). Pin that -/// this case is NOT detected so a future "improvement" doesn't silently -/// change the accepted trade-off without a matching design review. +/// Regression (#4887 follow-up): a question immediately followed by a +/// trailing pleasantry/instruction in the SAME paragraph ("...to? Let me +/// know!") used to be an accepted false negative. That false negative let the +/// trail-off backstop clobber real, specific questions with a generic +/// fallback — this is now DETECTED via the final-paragraph scan in +/// `text_looks_like_question`. +/// +/// Note: a question mark separated from the trailing sentence by a full +/// blank-line paragraph break (`"...to?\n\nLet me know!"`) is a DIFFERENT +/// shape — the `?` there sits in an earlier paragraph, not the last one — and +/// remains an intentional false negative: the final-paragraph scan only +/// looks at the LAST non-blank paragraph, by design (see the function doc +/// and `text_looks_like_question_ignores_question_mark_in_earlier_paragraph` +/// below, which pins that scope decision). #[test] -fn text_looks_like_question_accepts_false_negative_on_trailing_pleasantry() { +fn text_looks_like_question_detects_same_paragraph_trailing_pleasantry() { + assert!(text_looks_like_question( + "Which channel should I post to? Let me know!" + )); +} + +/// Pins the intentional cross-paragraph false negative documented above: a +/// `?` that sits in an EARLIER paragraph than the last one is deliberately +/// NOT detected — the final-paragraph scan only looks at the last non-blank +/// paragraph, by design. This is harmless because the trail-off backstop's +/// fallback is non-destructive (PREPEND, not REPLACE): even when this false +/// negative fires, the model's original question is preserved below the +/// fallback rather than discarded. +#[test] +fn text_looks_like_question_ignores_question_mark_in_earlier_paragraph() { assert!(!text_looks_like_question( "Which channel should I post to?\n\nLet me know!" )); } +/// The exact shape a live tester hit (#4887 regression): a clear, specific +/// question mid-sentence, immediately followed by a trailing instructional +/// sentence on the SAME paragraph/line. The old last-line-only check missed +/// this entirely; the final-paragraph scan must catch it. +#[test] +fn text_looks_like_question_detects_mid_sentence_question_with_trailing_instruction() { + assert!(text_looks_like_question( + "Alan — what's your **Slack user ID** (the `U...` code) so I can DM you the daily \ + update? You can find it in Slack under Profile > Copy member ID." + )); +} + +/// A `?` that only appears inside inline code or a fenced code block must +/// NOT be treated as a question — the guard on `question_mark_outside_code` +/// has to hold, or a code sample like `WHERE id = ?` would false-positive. +#[test] +fn text_looks_like_question_ignores_question_mark_inside_code() { + assert!(!text_looks_like_question( + "Run the query below to check the row.\n\n`SELECT * FROM t WHERE id = ?`" + )); + assert!(!text_looks_like_question( + "Here's the query:\n\n```sql\nSELECT * FROM t WHERE id = ?\n```" + )); +} + +/// Codex review follow-up: a `?` mid-token that isn't a real question mark — +/// e.g. a URL query string in a status update — must NOT flip +/// `text_looks_like_question` to `true`. Counting it would make `flows_build` +/// skip `combine_trail_off_fallback` entirely, leaving the user with an +/// unanswerable status note and no guaranteed question — exactly the failure +/// mode this backstop exists to prevent. +#[test] +fn text_looks_like_question_ignores_question_mark_in_url_query_string() { + assert!(!text_looks_like_question( + "Checked https://api.example/search?q=foo and got 403." + )); + assert!(!text_looks_like_question( + "Ran the search with filter?status=open but the API rejected it." + )); +} + +/// CodeRabbit review follow-up: paragraph boundaries must be recognized for +/// CRLF line endings and whitespace-only blank lines, not just a literal +/// `"\n\n"` byte sequence — otherwise an earlier question survives into what +/// should be treated as a separate, later, non-question status paragraph, +/// and the fallback gets wrongly suppressed for that trailing paragraph. +#[test] +fn text_looks_like_question_treats_crlf_and_whitespace_lines_as_paragraph_breaks() { + // CRLF paragraph break: the earlier "?" must not leak into the final + // paragraph, which is a plain status line with no question of its own. + assert!(!text_looks_like_question( + "Which channel should I post to?\r\n\r\nPosted the update just now." + )); + // Whitespace-only blank line (not perfectly empty) must also count as a + // paragraph break. + assert!(!text_looks_like_question( + "Which channel should I post to?\n \nPosted the update just now." + )); +} + +/// CodeRabbit review follow-up: a multi-backtick Markdown code span (e.g. +/// double backtick, used so the span can itself contain a literal single +/// backtick) must still be recognized as code — a naive backtick-count +/// parity check misclassifies it because two backticks flip parity back to +/// "even" immediately. The span must only close on a run of the SAME length +/// that opened it. +#[test] +fn text_looks_like_question_ignores_question_mark_inside_double_backtick_span() { + assert!(!text_looks_like_question( + "Run the query below to check the row.\n\n``SELECT * FROM t WHERE id = ?``" + )); + // A single backtick embedded inside a double-backtick span (the classic + // reason to use a longer delimiter) must not be mistaken for the span's + // closing delimiter. + assert!(!text_looks_like_question( + "Use ``SELECT `id` FROM t WHERE id = ?`` before retrying." + )); +} + #[test] fn text_looks_like_question_rejects_status_dumps_and_silence() { assert!(!text_looks_like_question( @@ -4887,3 +4989,33 @@ fn build_trail_off_fallback_does_not_resurface_a_resolved_blocker() { ); assert!(text_looks_like_question(&fallback)); } + +/// Change 2 of the #4887 regression fix: when the trail-off backstop fires on +/// a genuine non-question (a status dump), the model's original words must +/// still be present in the combined output — the fallback question is added +/// on top, never a replacement. +#[test] +fn combine_trail_off_fallback_preserves_original_text_on_genuine_non_question() { + let original = "## Done so far\n- Checked connections\n- Verified contracts"; + let fallback = build_trail_off_fallback(&[]); + let combined = combine_trail_off_fallback(&fallback, original); + // Assert the exact combined string, not just that both pieces appear + // somewhere — this pins the documented fallback-first ordering and the + // `---` divider, which a looser `contains`-based check wouldn't catch a + // regression in (e.g. original-first ordering, or a missing divider). + assert_eq!(combined, format!("{fallback}\n\n---\n\n{original}")); + // The combined text still ends in the model's original (non-question) + // words, so the "is this a question" invariant applies to the + // fallback alone, not the full combined string. + assert!(text_looks_like_question(&fallback)); +} + +/// Guards against prepending an empty divider when the original text is a +/// genuine silent turn (empty/whitespace-only) — there is nothing to +/// preserve, so the combined output should just be the fallback. +#[test] +fn combine_trail_off_fallback_returns_fallback_alone_for_genuine_silence() { + let fallback = build_trail_off_fallback(&[]); + assert_eq!(combine_trail_off_fallback(&fallback, ""), fallback); + assert_eq!(combine_trail_off_fallback(&fallback, " \n\n "), fallback); +} From 24c63b1468364caa76c6ff3f6d1d6949160f7556 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:06:45 +0530 Subject: [PATCH 52/86] fix(flows): resolve a non-owner Slack DM recipient via a lookup node (#4955) --- .../agents/workflow_builder/builder_prompt.rs | 62 +++++++++++++++++++ .../flows/agents/workflow_builder/prompt.md | 42 +++++++++++++ 2 files changed, 104 insertions(+) diff --git a/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs b/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs index b6ab680b8d..4321049174 100644 --- a/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs +++ b/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs @@ -400,6 +400,68 @@ mod tests { ask the user for their member id in one question rather than \ guessing a channel" ); + + // Positive: non-owner DM resolution — the prompt must teach the + // builder to resolve a NAMED recipient who is NOT the connected + // owner via a lookup node, not just the owner's own + // `platform_user_id`. This guidance must be PLATFORM-AGNOSTIC (no + // toolkit-specific slug hardcoded) — the same shape applies to + // Slack, Discord, Telegram, or any other messaging toolkit. + assert!( + STANDING_PROMPT.contains("is NOT the connected"), + "standing prompt must teach the non-owner DM case explicitly" + ); + assert!( + STANDING_PROMPT.contains("platform-agnostic"), + "standing prompt must state the non-owner DM guidance is \ + platform-agnostic, not tied to one toolkit" + ); + assert!( + STANDING_PROMPT.contains("search_tool_catalog { query, toolkit }"), + "standing prompt must teach resolving the lookup action via \ + search_tool_catalog scoped to the TARGET toolkit, rather than \ + hardcoding one platform's slug" + ); + assert!( + STANDING_PROMPT.contains("tool_call` node upstream of the send"), + "standing prompt must teach wiring the lookup as a tool_call \ + node upstream of the send node" + ); + assert!( + STANDING_PROMPT.contains("resolves to exactly one match"), + "standing prompt must require a name search to resolve to \ + exactly one match before binding it without asking" + ); + assert!( + STANDING_PROMPT.contains("ask the user to confirm which person"), + "standing prompt must preserve the safety rule: never message an \ + unverified same-name match, ask instead when ambiguous" + ); + assert!( + STANDING_PROMPT.contains("Check the send action") + && STANDING_PROMPT.contains("open conversation"), + "standing prompt must teach checking the send tool's own contract \ + for a required open-conversation step, handled generally via the \ + contract rather than a single-platform special case" + ); + + // Negative: none of the non-owner DM guidance may hardcode a + // toolkit-specific action slug or arg name — the reviewer flagged an + // earlier draft of this guidance as Slack-only, which violates the + // platform-agnostic rule. + for banned in [ + "SLACK_FIND_USERS", + "SLACK_LIST_ALL_USERS", + "config.args.email", + "exact_match", + ] { + assert!( + !STANDING_PROMPT.contains(banned), + "standing prompt's non-owner DM guidance must not hardcode \ + the platform-specific `{banned}` — it must stay \ + platform-agnostic (any messaging toolkit)" + ); + } } #[test] diff --git a/src/openhuman/flows/agents/workflow_builder/prompt.md b/src/openhuman/flows/agents/workflow_builder/prompt.md index 8b8de71aca..79076686fc 100644 --- a/src/openhuman/flows/agents/workflow_builder/prompt.md +++ b/src/openhuman/flows/agents/workflow_builder/prompt.md @@ -623,6 +623,48 @@ into exactly one bucket before you write the node: `#channel` name) — no need to ask. Only if `platform_user_id` is null for that connection, ask the user for their member id in ONE concise question rather than guessing a channel. + - "DM ``" / "message ``" where `` is NOT the connected + owner (no matching `platform_user_id`) → you don't have their platform + user id up front, and guessing one is unsafe. This shape is + **platform-agnostic** — it applies the same way whether the + destination toolkit is Slack, Discord, Telegram, or any other + messaging app. Don't ask immediately — resolve it: + 1. `search_tool_catalog { query, toolkit }` scoped to the TARGET + toolkit to find its user-lookup action — a "find user" / "lookup by + email" / "list users" style action, whatever that platform exposes + (never assume a slug across toolkits; always search for it). + 2. Wire that lookup as a **`tool_call` node upstream of the send**. + 3. Prefer an **email / exact lookup** when the platform offers one — + that's unambiguous, so bind its result directly with no question. + A **name search** can return multiple people: only bind it straight + through when it resolves to exactly one match; otherwise this is + bucket 3 — **ask the user to confirm which person / their email** + rather than messaging an unverified same-name match. If the + toolkit's lookup action can't resolve the person by name or email at + all, fall back to its "list users" style action plus a downstream + `transform`/`code` filter on an identifying field (email/display + name/etc). + 4. Bind the resolved id into the send node's recipient arg with an `=` + expression off the lookup node — use `get_tool_contract` to find the + exact output field and confirm with `dry_run_workflow` rather than + guessing — same as the owner path above. + 5. **Check the send action's own `get_tool_contract` for a required + "open conversation" step first.** Some messaging toolkits require + opening/creating a DM conversation for a user id before you can send + to it; others accept a user id as the recipient directly and + open/reuse the DM automatically. Never assume either way — if the + contract names a separate open/create-conversation action as a + prerequisite, wire that `tool_call` too, between the lookup and the + send. + + Worked example (illustrative, not tied to one platform) — "every + Monday at 9am, message alan@acme.com his open tickets": `trigger` + (schedule, Mon 09:00) → `tool_call` `find_alan` (the target toolkit's + user-lookup action, args grounded via `get_tool_contract`, e.g. an + `email` arg) → `tool_call` fetching the tickets → (an + open-conversation `tool_call` first, only if that toolkit's contract + requires one) → `tool_call` `dm_alan` (the toolkit's send action, + recipient arg bound to `=nodes.find_alan.item.json.data.`). - Exactly one connected account for the toolkit the step needs → that account (`list_flow_connections` / `composio_list_connections` tell you this; don't ask "which Gmail?" when there's only one). From e7ceea9cc022ba97b86202d623ceea015841c49b Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:29:13 +0530 Subject: [PATCH 53/86] feat(core): compile-time flows feature gate (#4797) (#4912) Co-authored-by: Steven Enamakel Co-authored-by: M3gA-Mind --- .github/workflows/ci-lite.yml | 2 +- AGENTS.md | 7 ++ Cargo.toml | 28 +++++- app/src-tauri/Cargo.toml | 1 + src/core/all.rs | 2 + src/core/all_tests.rs | 47 +++++++++- src/core/jsonrpc.rs | 8 ++ src/core/jsonrpc_tests.rs | 5 + src/core/runtime/services.rs | 2 + .../agent/harness/builtin_definitions.rs | 3 + .../agent/harness/definition_tests.rs | 3 + src/openhuman/agent_registry/agents/loader.rs | 11 ++- src/openhuman/mod.rs | 3 + src/openhuman/tools/mod.rs | 4 + src/openhuman/tools/ops.rs | 55 ++++++++++- src/openhuman/tools/ops_tests.rs | 92 ++++++++++++++++++- tests/json_rpc_e2e.rs | 12 +++ 17 files changed, 274 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index 1e02e343dd..97ef5bbec3 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -401,7 +401,7 @@ jobs: cache-on-failure: true shared-key: pr-rust-feature-gate-smoke - - name: Check core builds with the voice + media gates disabled + - name: Check core builds with the default domain gates disabled run: bash scripts/ci-cancel-aware.sh cargo check --manifest-path Cargo.toml --no-default-features --features tokenjuice-treesitter rust-core-coverage: diff --git a/AGENTS.md b/AGENTS.md index 364dedf352..14b63465a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -241,6 +241,7 @@ GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml \ | `media` | ON | `openhuman::media_generation` (the `media_generate_*` agent tools) + `openhuman::image` scaffold | none (surface-only) | | `meet` | ON | `openhuman::meet` (join-URL validation) + `openhuman::meet_agent` (live STT/LLM/TTS loop) + `openhuman::agent_meetings` (backend-delegated Meet bot over Socket.IO) | none — see note | | `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` | **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. @@ -276,6 +277,12 @@ Two places the carve-out doesn't reach, and why they are `#[cfg]` at the call si When skills are off: the `skills` / `skill_runtime` / `skill_registry` controllers are unregistered (unknown-method over `/rpc`, absent from `/schema`), the 16 skill agent tools (incl. `run_workflow` / `await_workflow`) are **absent** from the tool list rather than degraded to an error, the `skill_setup` / `skill_executor` builtin agents are gone, and the boot-time remote catalog refresh is skipped. Composes with the runtime `DomainSet::skills` flag (#4796) — that axis needed no change here; #4798 is compile-time only. +**Leaf-gate pattern (`flows`).** Where `voice` needs a stub facade, `flows` needs **none** — and deliberately so. Every symbol reached from outside the gate is a *registration site* (controller push in `src/core/all.rs`, the `FlowTriggerSubscriber` in `src/core/jsonrpc.rs`, boot reconcile in `src/core/runtime/services.rs`, agent-tool `vec!` elements in `src/openhuman/tools/ops.rs`, `BuiltinAgent` entries in `agent_registry/agents/loader.rs`). Registration sites want **absence**: a stub that registered a controller returning `Err("flows disabled")` would make `flows.*` a *known* method that fails at runtime — the opposite of the intended "unknown method / omitted tool". So the three modules are `#[cfg(feature = "flows")]` at their `pub mod` declaration and each call site carries its own `#[cfg]`. Nothing inside `flows/`, `tinyflows/`, or `rhai_workflows/` is modified. There is no `openhuman flows` CLI subcommand, so no CLI stub is needed either. When flows is off: the `flows.*` controllers are unregistered (unknown-method over `/rpc`, absent from `/schema`), all 25 flow agent tools + `rhai_workflows` are absent, and the `workflow_builder` / `flow_discovery` built-in agents are not advertised. + +**Scope note (`flows` deps):** the gate sheds `tinyflows` + its `jaq-core` / `jaq-std` / `jaq-json` JSON-query stack, and `rhai`. It does **not** shed `tinyagents` — 26+ domains consume that crate. The issue-level DoD line reading "sheds the rhai scripting engine" is therefore true only at the **feature** level: `rhai` arrives via `tinyagents/repl`, which the root `Cargo.toml` no longer enables directly — the `flows` feature turns it on. Dropping `flows` drops `repl`, which drops `rhai`; `tinyagents` itself stays. Verify a claimed shed with `cargo tree -i --no-default-features --features tokenjuice-treesitter` (must return nothing) — compiling clean is **not** proof that a dep was dropped. + +**Testing gotcha (applies to every gate).** The CI smoke lane runs `cargo check` only — it never runs `cargo test --no-default-features`, so CI stays green while the disabled-build **test** suite is broken. Tests that hard-assert a gated family (`.expect("a flows.* method exists")`, `assert!(full_ns.contains("flows"))`, `group_for_namespace("flows")`, built-in-agent id lists) must be `#[cfg]`-gated in lockstep with the feature. Run `GGML_NATIVE=OFF cargo test --lib --no-default-features --features tokenjuice-treesitter core::` locally before pushing any gate change. + ### Event bus (`src/core/event_bus/`) Typed pub/sub + native request/response. Both singletons — use module-level functions. diff --git a/Cargo.toml b/Cargo.toml index 000e22a42a..b21b8ac941 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,7 +61,10 @@ tinyplace = "2.0" # deterministic in-memory capability bundle the flows `dry_run_workflow` agent # tool (Phase 5b) runs a *draft* graph against so the workflow-builder agent can # self-verify a proposal without any real side effects (no real LLM/tool/HTTP/code). -tinyflows = { version = "0.5", features = ["mock"] } +# +# Optional: exclusive to the default-ON `flows` feature (#4797). A slim build +# without `flows` drops this crate and its `jaq-*` JSON-query stack entirely. +tinyflows = { version = "0.5", features = ["mock"], optional = true } # TinyJuice — host-agnostic TokenJuice compression engine. OpenHuman keeps # config/RPC/tool/runtime adapters in `src/openhuman/tokenjuice/` and patches # this dependency to the vendored submodule below. @@ -77,9 +80,12 @@ tinyjuice = { version = "0.2.1", default-features = false } # aligned to 0.40, avoiding duplicate `links = "sqlite3"` native bindings. # Durable graph checkpoints still use `SqlRunLedgerCheckpointer` until the # migration re-points those rows to the crate checkpointer. -# The `repl` feature adds the embedded Rhai `.ragsh` session runtime powering -# the `rhai_workflows` language-workflow tool (`src/openhuman/rhai_workflows/`). -tinyagents = { version = "1.7", features = ["sqlite", "repl"] } +# The `repl` feature (embedded Rhai `.ragsh` session runtime powering the +# `rhai_workflows` language-workflow tool, `src/openhuman/rhai_workflows/`) is +# 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"] } # 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 @@ -341,7 +347,7 @@ tokio = { version = "1", features = ["test-util"] } proptest = "1" [features] -default = ["tokenjuice-treesitter", "voice", "web3", "media", "meet", "skills"] +default = ["tokenjuice-treesitter", "voice", "web3", "media", "meet", "skills", "flows"] # 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 = [ @@ -384,6 +390,18 @@ web3 = ["dep:bitcoin", "dep:curve25519-dalek"] # controllers / stores / subscribers tagged `Media` (agent tools only), and # `openhuman::image` is currently unwired scaffold (added #2997). media = [] +# Flows domains: the `flows::` automation surface (saved tinyflows graphs — +# create/run/schedule + the workflow_builder / flow_discovery agents), the +# `tinyflows::` adapter seam, and the `rhai_workflows::` language-workflow tool. +# Default-ON — the desktop app always ships with Workflows. Slim / headless +# builds opt out via `--no-default-features --features ""`, +# which drops `tinyflows` + its `jaq-core`/`jaq-std`/`jaq-json` JSON-query stack +# and, via `tinyagents/repl`, the `rhai` scripting engine. Composes with the +# runtime `DomainSet::flows` flag (#4796): the feature narrows the compile-time +# surface, `DomainSet` gates it at runtime. +# NOTE: this gate does NOT drop `tinyagents` itself — 26+ domains consume it. +# Only its `repl` feature (⇒ `rhai`) is exclusive to flows. +flows = ["dep:tinyflows", "tinyagents/repl"] # Meet domains: `meet` (join-URL validation), `meet_agent` (live STT/LLM/TTS # loop over an open call), and `agent_meetings` (backend-delegated Meet bot via # Socket.IO). Default-ON — the desktop app always ships with Meet. Slim / diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 1677ee9280..70f2403053 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -166,6 +166,7 @@ openhuman_core = { path = "../..", package = "openhuman", default-features = fal "voice", "tokenjuice-treesitter", "web3", + "flows", "meet", "skills", ] } diff --git a/src/core/all.rs b/src/core/all.rs index 948c916e0f..d686517cca 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -232,6 +232,8 @@ fn build_registered_controllers() -> Vec { crate::openhuman::cron::all_cron_registered_controllers(), ); // Saved automation workflows (tinyflows graphs): create/get/list/update/delete/run + // (gated with flows). + #[cfg(feature = "flows")] push( &mut controllers, DomainGroup::Flows, diff --git a/src/core/all_tests.rs b/src/core/all_tests.rs index 6049ffebbf..68715e682b 100644 --- a/src/core/all_tests.rs +++ b/src/core/all_tests.rs @@ -820,6 +820,7 @@ async fn harness_excludes_gated_namespaces() { .iter() .map(|s| s.namespace) .collect(); + #[cfg(feature = "flows")] assert!(full_ns.contains("flows"), "full() must expose flows"); assert!(full_ns.contains("voice"), "full() must expose voice"); @@ -859,6 +860,12 @@ async fn harness_excludes_gated_namespaces() { ); } +// Uses a `flows.*` method as its gated-family vehicle, so the whole test is +// `#[cfg(feature = "flows")]`: without the feature there is no flows controller +// in the registry at all and the `.expect()` below would panic. The runtime +// gating this proves is orthogonal to the compile-time gate, and CI runs the +// test suite on default features (flows ON), so no coverage is lost there. +#[cfg(feature = "flows")] #[tokio::test] async fn dispatch_returns_none_for_gated_method() { // A method whose group is gated OFF under the ambient DomainSet must @@ -890,6 +897,8 @@ async fn dispatch_returns_none_for_gated_method() { ); } +// Same flows-vehicle reasoning as `dispatch_returns_none_for_gated_method`. +#[cfg(feature = "flows")] #[tokio::test] async fn schema_lookup_is_gated_in_lockstep_with_dispatch() { // #4808 review: `schema_for_rpc_method` must gate identically to @@ -939,7 +948,10 @@ fn group_mapping_smoke() { assert_eq!(group_for_namespace("config"), Some(DomainGroup::Config)); assert_eq!(group_for_namespace("security"), Some(DomainGroup::Security)); assert_eq!(group_for_namespace("agent"), Some(DomainGroup::Agent)); - // …and a representative gated one maps to its gate group. + // …and a representative gated one maps to its gate group. `group_for_namespace` + // reads the real controller registry, so a compile-time-gated family has no + // entry to map when its feature is off. + #[cfg(feature = "flows")] assert_eq!(group_for_namespace("flows"), Some(DomainGroup::Flows)); // `group_for_namespace` is registry-derived, so a compile-time-gated domain // has no controller to map. Skip when its Cargo feature is off. @@ -956,6 +968,39 @@ fn group_mapping_smoke() { assert_eq!(group_for_namespace("mcp_audit"), Some(DomainGroup::Mcp)); } +// --- #4797: `flows` compile-time gate (directional proof) ------------------- +// +// One namespace, not three: `tinyflows` registers no controllers, and +// `rhai_workflows` is `scope() = AgentOnly` (no controller schemas in v1), so +// `flows` is the gate's entire controller surface. + +#[cfg(feature = "flows")] +#[test] +fn flows_controllers_registered_when_feature_on() { + let namespaces: Vec<&str> = all_controller_schemas() + .iter() + .map(|s| s.namespace) + .collect(); + assert!( + namespaces.contains(&"flows"), + "with the `flows` feature ON the flows controllers must be registered" + ); +} + +#[cfg(not(feature = "flows"))] +#[test] +fn flows_controllers_absent_when_feature_off() { + let namespaces: Vec<&str> = all_controller_schemas() + .iter() + .map(|s| s.namespace) + .collect(); + assert!( + !namespaces.contains(&"flows"), + "with the `flows` feature OFF the flows controllers must be absent \ + (unknown-method over /rpc, omitted from /schema)" + ); +} + /// All three Meet namespaces register when the `meet` feature is on (#4800). /// /// Paired with `meet_controllers_absent_when_feature_off` below: together they diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index 4a553ab456..8bcabec2f3 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -2024,6 +2024,10 @@ fn register_domain_subscribers( // runs `flows::ops::flows_run`, so schedule/app-event workflows still // dispatch when no realtime channel is configured or // `OPENHUMAN_DISABLE_CHANNEL_LISTENERS` short-circuits `start_channels`. + // The `plan.flows` runtime guard cannot stand in for the compile-time gate: + // the `flows::bus::FlowTriggerSubscriber` type path below must still resolve + // for this to compile, so the whole block is `#[cfg]`-gated too. + #[cfg(feature = "flows")] if plan.flows { if group_first_time(DomainGroup::Flows) { if let Some(handle) = crate::core::event_bus::subscribe_global(Arc::new( @@ -2039,6 +2043,10 @@ fn register_domain_subscribers( } else { log::debug!("[event_bus] flows trigger subscriber SKIPPED — Flows domain disabled"); } + #[cfg(not(feature = "flows"))] + log::debug!( + "[event_bus] flows trigger subscriber SKIPPED — flows feature disabled at compile time" + ); // Memory: conversation-persistence + sync-stage bridge. if plan.memory { diff --git a/src/core/jsonrpc_tests.rs b/src/core/jsonrpc_tests.rs index fdb88798c1..c711fc3ace 100644 --- a/src/core/jsonrpc_tests.rs +++ b/src/core/jsonrpc_tests.rs @@ -243,6 +243,11 @@ async fn invoke_doctor_models_rejects_unknown_param() { assert!(err.contains("unknown param 'invalid'")); } +// Uses a `flows.*` method as its gated-family vehicle: without the `flows` +// feature there is no flows controller in the registry and the `.expect()` +// below would panic. The transport-layer gating it proves is orthogonal to the +// compile-time gate (#4797). +#[cfg(feature = "flows")] #[tokio::test] async fn gated_method_is_unknown_at_transport_even_with_malformed_params() { // #4808 review (CodeRabbit): prove the schema-gate fix at the JSON-RPC diff --git a/src/core/runtime/services.rs b/src/core/runtime/services.rs index 89b36521e1..3b814b1d37 100644 --- a/src/core/runtime/services.rs +++ b/src/core/runtime/services.rs @@ -127,6 +127,8 @@ pub fn spawn_cron_service() { // flow (issue B2) — idempotent, so a flow whose binding // predates this feature (or was otherwise lost) gets its // schedule re-registered without the user re-toggling it. + // Gated with flows — absent entirely from a slim build. + #[cfg(feature = "flows")] if let Err(e) = crate::openhuman::flows::ops::reconcile_schedule_triggers_on_boot(&config).await { diff --git a/src/openhuman/agent/harness/builtin_definitions.rs b/src/openhuman/agent/harness/builtin_definitions.rs index 8764788832..f1cd08eeda 100644 --- a/src/openhuman/agent/harness/builtin_definitions.rs +++ b/src/openhuman/agent/harness/builtin_definitions.rs @@ -266,7 +266,10 @@ mod tests { "critic", "archivist", "summarizer", + // Gated with `flows` (#4797) — absent from a slim build. + #[cfg(feature = "flows")] "workflow_builder", + #[cfg(feature = "flows")] "flow_discovery", ] { assert!(ids.contains(&expected.to_string()), "missing {expected}"); diff --git a/src/openhuman/agent/harness/definition_tests.rs b/src/openhuman/agent/harness/definition_tests.rs index d6d8ebc0f7..70ac7f7d68 100644 --- a/src/openhuman/agent/harness/definition_tests.rs +++ b/src/openhuman/agent/harness/definition_tests.rs @@ -374,7 +374,10 @@ fn all_builtin_agent_definitions_have_expected_effective_max_iterations() { ("skill_creator", 50), ("task_manager_agent", 50), ("tools_agent", 50), + // Gated with `flows` (#4797) — absent from a slim build. + #[cfg(feature = "flows")] ("flow_discovery", 50), + #[cfg(feature = "flows")] ("workflow_builder", 50), // Compiled out with the `skills` gate — see `openhuman::skills::stub`. #[cfg(feature = "skills")] diff --git a/src/openhuman/agent_registry/agents/loader.rs b/src/openhuman/agent_registry/agents/loader.rs index c8ea3415a6..e23fb71d75 100644 --- a/src/openhuman/agent_registry/agents/loader.rs +++ b/src/openhuman/agent_registry/agents/loader.rs @@ -295,6 +295,9 @@ pub const BUILTINS: &[BuiltinAgent] = &[ // Workflow-authoring specialist (Phase 5a): builds tinyflows automation // graphs from natural language and returns a validated PROPOSAL — it never // persists or enables a flow. Deliberately narrow propose-or-read tool belt. + // Gated with `flows`: a slim build must not advertise an agent whose entire + // tool belt is absent, so the entry (and its `include_str!`) is stripped. + #[cfg(feature = "flows")] BuiltinAgent { id: "workflow_builder", toml: include_str!("../../flows/agents/workflow_builder/agent.toml"), @@ -306,7 +309,9 @@ pub const BUILTINS: &[BuiltinAgent] = &[ // `suggest_workflows` to record concrete, buildable automation ideas for // the Flows page "Suggested for you" section. It never persists or enables // a flow — the read-only counterpart to `workflow_builder`, which turns a - // picked suggestion into a real graph proposal. + // picked suggestion into a real graph proposal. Gated with `flows` (same + // reasoning as `workflow_builder` above). + #[cfg(feature = "flows")] BuiltinAgent { id: "flow_discovery", toml: include_str!("../../flows/agents/flow_discovery/agent.toml"), @@ -1005,6 +1010,9 @@ mod tests { assert!(matches!(def.tools, ToolScope::Wildcard)); } + // Both flows agents are `#[cfg(feature = "flows")]` entries in `BUILTINS` + // (#4797), so these tests only apply when the gate is on. + #[cfg(feature = "flows")] #[test] fn workflow_builder_is_registered_worker_with_bounded_authoring_scope() { // Phase 5a/5b: the workflow-builder must be a Worker-tier leaf whose @@ -1135,6 +1143,7 @@ mod tests { ); } + #[cfg(feature = "flows")] #[test] fn flow_discovery_is_registered_readonly_reasoning_scout() { // The Flow Scout must be a read-only reasoning leaf: it reads the diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 83df574557..d364e6c923 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -53,6 +53,7 @@ pub mod emergency_stop; pub mod encryption; pub mod file_state; pub mod file_storage; +#[cfg(feature = "flows")] pub mod flows; pub mod harness_init; pub mod health; @@ -102,6 +103,7 @@ pub mod provider_surfaces; pub mod recall_calendar; pub mod redirect_links; pub mod referral; +#[cfg(feature = "flows")] pub mod rhai_workflows; pub mod routing; pub mod runtime_node; @@ -131,6 +133,7 @@ pub mod thread_goals; pub mod threads; pub mod tinyagents; pub mod tinycortex; +#[cfg(feature = "flows")] pub mod tinyflows; pub mod tinyplace; pub mod tls; diff --git a/src/openhuman/tools/mod.rs b/src/openhuman/tools/mod.rs index 074838b02a..994fd344b5 100644 --- a/src/openhuman/tools/mod.rs +++ b/src/openhuman/tools/mod.rs @@ -26,8 +26,11 @@ pub use crate::openhuman::credentials::tools::*; pub use crate::openhuman::cron::tools::*; pub use crate::openhuman::dashboard::tools::*; pub use crate::openhuman::doctor::tools::*; +#[cfg(feature = "flows")] pub use crate::openhuman::flows::builder_tools::*; +#[cfg(feature = "flows")] pub use crate::openhuman::flows::discovery_tools::*; +#[cfg(feature = "flows")] pub use crate::openhuman::flows::tools::*; pub use crate::openhuman::health::tools::*; pub use crate::openhuman::integrations::tools::*; @@ -41,6 +44,7 @@ pub use crate::openhuman::monitor::tools::*; pub use crate::openhuman::orchestration::tools::*; pub use crate::openhuman::people::tools::*; pub use crate::openhuman::referral::tools::*; +#[cfg(feature = "flows")] pub use crate::openhuman::rhai_workflows::tools::*; pub use crate::openhuman::screen_intelligence::tools::*; pub use crate::openhuman::search::tools::*; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 7369298d41..7696ceac4b 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -284,6 +284,7 @@ pub fn all_tools_with_runtime( // graph and returns a proposal summary — never creates/enables a // flow itself. Only the chat UI's WorkflowProposalCard "Save & // enable" action calls `flows_create`. + #[cfg(feature = "flows")] Box::new(ProposeWorkflowTool::new(config.clone())), // workflow-builder agent tool belt (Phase 5b). A deliberately narrow, // propose-or-read surface: revise a draft (validate-only), read saved @@ -292,38 +293,53 @@ pub fn all_tools_with_runtime( // or enable a flow (only the user's own `flows_create` click does); the // read tools are `PermissionLevel::None`, and `dry_run_workflow` is // autonomy-tier gated + wired to deterministic mock capabilities. + #[cfg(feature = "flows")] Box::new(ReviseWorkflowTool::new(config.clone())), // Structured incremental edits (F1): apply a small ops[] list to a base // graph (saved flow or inline) instead of re-emitting the whole graph, // then validate + gate + return a proposal (same contract as revise). // Proposal-only — never persists. + #[cfg(feature = "flows")] Box::new(EditWorkflowTool::new(config.clone())), // Standalone validate (F3): run the SAME structural + hard-gate stack // the propose/save tools use, without emitting a proposal — a pure // check so the agent can self-verify a draft mid-build. Read-only. + #[cfg(feature = "flows")] Box::new(ValidateWorkflowTool::new(config.clone())), // Read a saved flow's revision history (F6) — prior graph snapshots the // agent can inspect / pick a rollback target from. Read-only. + #[cfg(feature = "flows")] Box::new(GetFlowHistoryTool::new(config.clone())), // Phase 4 self-debug loop (F4): find a failing run, resume a parked // run (approval-gated), or cancel a runaway one. + #[cfg(feature = "flows")] Box::new(ListFlowRunsTool::new(config.clone())), + #[cfg(feature = "flows")] Box::new(ResumeFlowRunTool::new(config.clone())), + #[cfg(feature = "flows")] Box::new(CancelFlowRunTool::new(config.clone())), // Gated create (F4/F12): create a NEW flow — born disabled, approval // gated — and duplicate an existing one (disabled copy) for // clone-then-edit. Behind the Phase 3 safety rails. + #[cfg(feature = "flows")] Box::new(CreateWorkflowTool::new(config.clone())), + #[cfg(feature = "flows")] Box::new(DuplicateFlowTool::new(config.clone())), + #[cfg(feature = "flows")] Box::new(ListFlowsTool::new(config.clone())), + #[cfg(feature = "flows")] Box::new(GetFlowTool::new(config.clone())), + #[cfg(feature = "flows")] Box::new(GetFlowRunTool::new(config.clone())), + #[cfg(feature = "flows")] Box::new(ListFlowConnectionsTool::new(config.clone())), + #[cfg(feature = "flows")] Box::new(SearchToolCatalogTool::new(config.clone())), // Full live contract (schemas, real required_args/output_fields, // primary_array_path) for one action slug found via // search_tool_catalog — the grounding step before WIRING a node's // args/downstream bindings (systemic tool-contract fix, Part 1). + #[cfg(feature = "flows")] Box::new(GetToolContractTool::new(config.clone())), // B12: ONE bounded, READ-ONLY, REAL Composio call to derive the real // primary_array_path/output_fields when the live listing publishes no @@ -333,36 +349,45 @@ pub fn all_tools_with_runtime( // toolkits only — see builder_tools.rs's module doc for the carve-out // this makes in the workflow-builder agent's "no composio_execute" // invariant. + #[cfg(feature = "flows")] Box::new(GetToolOutputSampleTool::new(config.clone())), // Ground an `agent` node's `agent_ref` in real registered agent-kind ids // (researcher / code_executor / …) — the agent analogue of // search_tool_catalog. Read-only. + #[cfg(feature = "flows")] Box::new(ListAgentProfilesTool::new()), // Steer toolkit choice toward what's already connected + surface which // toolkits a flow still needs (Phase 5, item 19). Read-only. + #[cfg(feature = "flows")] Box::new(ListConnectableToolkitsTool::new(config.clone())), // Queryable DSL schema (F2): enumerate the 12 node kinds and fetch one // kind's full config-field/port/example/gotcha contract — the DSL // analogue of search_tool_catalog + get_tool_contract, so an agent need // not rely on prompt prose or memory for node config shapes. Read-only. + #[cfg(feature = "flows")] Box::new(ListNodeKindsTool::new()), + #[cfg(feature = "flows")] Box::new(GetNodeKindContractTool::new()), + #[cfg(feature = "flows")] Box::new(DryRunWorkflowTool::new(security.clone(), config.clone())), // Real end-to-end test run of a SAVED flow (Write / external-effect). The // workflow-builder prompt requires it to ask the user for confirmation // first, and the flow's own approval gate still pauses outbound nodes. + #[cfg(feature = "flows")] Box::new(RunFlowTool::new(config.clone())), // Persist a built graph onto an EXISTING saved flow (Write). Used only // when the USER explicitly asks the agent to save; the seeded build // turn from the Flows prompt bar is propose-only (see #4596) — Accept // + the canvas's own Save persist the graph. The tool itself can // never create a flow or change enabled/require_approval. + #[cfg(feature = "flows")] Box::new(SaveWorkflowTool::new(config.clone())), // Flow Scout discovery: the `flow_discovery` agent's terminal emit // sink. Read-only reasoning over the user's data ends by calling // `suggest_workflows`, which persists workflow ideas for the Flows page // "Suggested for you" section. `PermissionLevel::None`, no external // effect — writes only to the agent's own suggestions store. + #[cfg(feature = "flows")] Box::new(SuggestWorkflowsTool::new(config.clone())), // Wallet tools — expose wallet operations to the agent tool-call pipeline // so the crypto sub-agent can prepare transfers, check status, etc. @@ -1112,12 +1137,15 @@ pub fn all_tools_with_runtime( // tiers only — dark on `readonly` (it can drive effectful tools/sub-agents) // and behind the `OPENHUMAN_RHAI_WORKFLOWS=0` kill switch. Every effectful inner call // still re-gates itself in the Rhai bridge, so this surface adds no new - // ungated capability. + // ungated capability. Gated with `flows` — the whole tool (and the `rhai` + // engine behind it, via `tinyagents/repl`) is absent from a slim build. + #[cfg(feature = "flows")] let rhai_workflows_enabled = std::env::var("OPENHUMAN_RHAI_WORKFLOWS") .or_else(|_| std::env::var("OPENHUMAN_RHAI")) .or_else(|_| std::env::var("OPENHUMAN_RLM")) .map(|v| v != "0") .unwrap_or(true); + #[cfg(feature = "flows")] if rhai_workflows_enabled && security.autonomy != crate::openhuman::security::policy::AutonomyLevel::ReadOnly { @@ -1130,6 +1158,10 @@ pub fn all_tools_with_runtime( "[rhai_workflows] rhai_workflows tool not registered (readonly tier or OPENHUMAN_RHAI_WORKFLOWS=0)" ); } + #[cfg(not(feature = "flows"))] + tracing::debug!( + "[rhai_workflows] rhai_workflows tool not registered — flows feature disabled at compile time" + ); // DomainSet post-filter (#4796): drop tools whose DomainGroup is disabled // under the ambient CoreContext. With no active context, or under @@ -1196,13 +1228,28 @@ fn tool_group(name: &str) -> crate::core::all::DomainGroup { "skill_registry_uninstall", "skill_runtime_resolve_runtimes", ]; + // Flows has no clean tool-name prefix, so it MUST list every flow-owned + // tool explicitly — a missing name falls through to `Platform` below and + // stays callable under a custom `DomainSet { platform: true, flows: false }`, + // leaking the flows surface past the runtime gate (#4808 review; #4797 + // maintainer review). Keep this in lockstep with the `#[cfg(feature = + // "flows")]` registrations in `all_tools_with_runtime` above — the same 26 + // names asserted by `default_tools_omits_flows_tools_when_feature_off`. const FLOWS: &[&str] = &[ "propose_workflow", "revise_workflow", + "edit_workflow", + "validate_workflow", + "get_flow_history", "dry_run_workflow", "save_workflow", "suggest_workflows", "run_flow", + "list_flow_runs", + "resume_flow_run", + "cancel_flow_run", + "create_workflow", + "duplicate_flow", "list_flows", "get_flow", "get_flow_run", @@ -1211,6 +1258,12 @@ fn tool_group(name: &str) -> crate::core::all::DomainGroup { "get_tool_contract", "get_tool_output_sample", "list_agent_profiles", + "list_connectable_toolkits", + "list_node_kinds", + "get_node_kind_contract", + // The `rhai_workflows` (.ragsh) tool is compile-gated with `flows` and + // belongs to the same runtime domain — drop it when Flows is off too. + "rhai_workflows", ]; // Voice family agent tools (audio_toolkit) — no `voice_`/`tts_`/`stt_` // prefix, so they must be listed explicitly or they fall through to diff --git a/src/openhuman/tools/ops_tests.rs b/src/openhuman/tools/ops_tests.rs index 9ddb2cfe28..96d74a2363 100644 --- a/src/openhuman/tools/ops_tests.rs +++ b/src/openhuman/tools/ops_tests.rs @@ -2224,8 +2224,45 @@ fn tool_group_classifies_gate_and_harness_families() { assert_eq!(tool_group("run_workflow"), DomainGroup::Skills); assert_eq!(tool_group("skill_registry_browse"), DomainGroup::Skills); assert_eq!(tool_group("list_workflows"), DomainGroup::Skills); - assert_eq!(tool_group("propose_workflow"), DomainGroup::Flows); - assert_eq!(tool_group("list_flows"), DomainGroup::Flows); + // Flows has no name prefix, so EVERY flow-owned tool must be classified + // explicitly — a missing one falls through to Platform and stays callable + // when the Flows domain is runtime-gated off (#4797 maintainer review). + // This list mirrors the compile-time `#[cfg(feature = "flows")]` + // registrations and `default_tools_omits_flows_tools_when_feature_off`. + for flow_tool in [ + "propose_workflow", + "revise_workflow", + "edit_workflow", + "validate_workflow", + "get_flow_history", + "dry_run_workflow", + "save_workflow", + "suggest_workflows", + "run_flow", + "list_flow_runs", + "resume_flow_run", + "cancel_flow_run", + "create_workflow", + "duplicate_flow", + "list_flows", + "get_flow", + "get_flow_run", + "list_flow_connections", + "search_tool_catalog", + "get_tool_contract", + "get_tool_output_sample", + "list_agent_profiles", + "list_connectable_toolkits", + "list_node_kinds", + "get_node_kind_contract", + "rhai_workflows", + ] { + assert_eq!( + tool_group(flow_tool), + DomainGroup::Flows, + "flow-owned tool `{flow_tool}` must classify as Flows, not fall through to Platform" + ); + } assert_eq!(tool_group("media_generate_image"), DomainGroup::Media); // Voice audio_* tools have no voice_/tts_/stt_ prefix — must be classified // explicitly, not fall through to Platform (#4808 review). @@ -2303,3 +2340,54 @@ fn no_gate_family_tool_silently_defaults_to_platform() { ); } } + +// --- #4797: `flows` compile-time gate --------------------------------------- + +/// With the `flows` feature off, every flows-owned agent tool — and the +/// `rhai_workflows` tool whose engine the gate sheds via `tinyagents/repl` — is +/// compiled out of the default registry entirely. +/// +/// `SecurityPolicy::default()` is `Supervised` (not `ReadOnly`), so the +/// `rhai_workflows` assertion is a real one: that tool *would* be registered at +/// this tier if the feature were on. +#[test] +#[cfg(not(feature = "flows"))] +fn default_tools_omits_flows_tools_when_feature_off() { + let security = Arc::new(SecurityPolicy::default()); + let tools = default_tools(security); + let names = tool_names(&tools); + + for absent in [ + "propose_workflow", + "revise_workflow", + "edit_workflow", + "validate_workflow", + "get_flow_history", + "list_flow_runs", + "resume_flow_run", + "cancel_flow_run", + "create_workflow", + "duplicate_flow", + "list_flows", + "get_flow", + "get_flow_run", + "list_flow_connections", + "search_tool_catalog", + "get_tool_contract", + "get_tool_output_sample", + "list_agent_profiles", + "list_connectable_toolkits", + "list_node_kinds", + "get_node_kind_contract", + "dry_run_workflow", + "run_flow", + "save_workflow", + "suggest_workflows", + "rhai_workflows", + ] { + assert!( + !names.iter().any(|n| n == absent), + "tool `{absent}` must be compiled out when the `flows` feature is off; got: {names:?}" + ); + } +} diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 81d6134168..e374fa9184 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -12595,6 +12595,7 @@ async fn json_rpc_workflows_lifecycle_round_trip() { /// Shared boot for a flows E2E: isolates `HOME`, seeds a minimal config against /// a mock upstream, and stands up the core HTTP router. Returns the rpc base /// URL plus the join handles + tempdir the caller must keep alive/abort. +#[cfg(feature = "flows")] async fn boot_flows_rpc_env() -> ( String, tempfile::TempDir, @@ -12627,6 +12628,7 @@ async fn boot_flows_rpc_env() -> ( /// The smallest valid graph with a human-in-the-loop approval gate: /// `trigger → gate(requires_approval) → downstream`. A run pauses at `gate`; /// approving it via `flows_resume` runs `downstream`. +#[cfg(feature = "flows")] fn approval_gated_graph_json() -> Value { json!({ "name": "approval-gated", @@ -12649,6 +12651,7 @@ fn approval_gated_graph_json() -> Value { /// cancel operates on a run id (checkpoint thread id), so we cancel a *fresh* /// parked run rather than the already-completed one (cancelling a terminal run /// is an error). +#[cfg(feature = "flows")] #[tokio::test] async fn json_rpc_flows_lifecycle_round_trip() { let _env_lock = json_rpc_e2e_env_lock(); @@ -12865,6 +12868,7 @@ async fn json_rpc_flows_lifecycle_round_trip() { /// `false`. This pins that the four new controllers are registered and dispatch /// end-to-end (schema + handler wiring), independent of the agent-backed /// `flows_discover`, which needs a provider. +#[cfg(feature = "flows")] #[tokio::test] async fn json_rpc_flows_suggestion_lifecycle_methods_are_wired() { let _env_lock = json_rpc_e2e_env_lock(); @@ -12935,6 +12939,7 @@ async fn json_rpc_flows_suggestion_lifecycle_methods_are_wired() { /// resolves to `chat-v1` on the managed backend while a **reasoning**-tier node /// resolves to `reasoning-v1` — letting the full-arc test assert the two nodes /// routed to distinct managed tiers. +#[cfg(feature = "flows")] fn write_flows_tier_config(openhuman_dir: &Path, api_origin: &str) { let cfg = format!( r#"api_url = "{api_origin}" @@ -12975,6 +12980,7 @@ compaction_enabled = false /// `trigger` feeds a reasoning-tier `planner` (structured `{plan, angle}`) into a /// chat-tier `drafter` that references `nodes.planner.item.json.plan`, then a /// `transform` shapes `{topic, plan, draft}`. +#[cfg(feature = "flows")] fn opus_sonnet_demo_graph() -> Value { json!({ "schema_version": 1, @@ -13049,6 +13055,7 @@ fn opus_sonnet_demo_graph() -> Value { /// /// Runs on the agent-sized worker stack because the builder/scout turns and the /// agent-node run drive the full harness (deep async stacks). +#[cfg(feature = "flows")] #[test] fn json_rpc_flows_full_arc_discover_build_create_run() { run_json_rpc_e2e_on_agent_stack( @@ -13057,6 +13064,7 @@ fn json_rpc_flows_full_arc_discover_build_create_run() { ); } +#[cfg(feature = "flows")] async fn json_rpc_flows_full_arc_discover_build_create_run_inner() { let _env_lock = json_rpc_e2e_env_lock(); // Drain the scripted-completion FIFO even if an assertion below panics, so a @@ -13300,6 +13308,7 @@ async fn json_rpc_flows_full_arc_discover_build_create_run_inner() { /// edge (→ `downstream`) and an `error` edge (→ `recover`). Resuming with the /// gate in `rejections` routes the denied gate's error item to `recover`, and /// `downstream` must not run. +#[cfg(feature = "flows")] #[tokio::test] async fn json_rpc_flows_resume_deny_routes_to_error_port() { let _env_lock = json_rpc_e2e_env_lock(); @@ -13388,6 +13397,7 @@ async fn json_rpc_flows_resume_deny_routes_to_error_port() { /// clean but returns a loud, non-fatal warning; a `schedule` trigger (which /// does fire) warns nothing; a graph with no trigger is `valid: false` with a /// structural error and no warnings. +#[cfg(feature = "flows")] #[tokio::test] async fn json_rpc_flows_validate_reports_warnings_and_errors() { let _env_lock = json_rpc_e2e_env_lock(); @@ -13498,6 +13508,7 @@ async fn json_rpc_flows_validate_reports_warnings_and_errors() { /// becomes an annotated placeholder, and the approximations come back as /// warnings — all WITHOUT persisting (the returned payload is a graph, not a /// saved Flow row; `flows_list` stays empty afterwards). +#[cfg(feature = "flows")] #[tokio::test] async fn json_rpc_flows_import_native_and_n8n() { let _env_lock = json_rpc_e2e_env_lock(); @@ -13609,6 +13620,7 @@ async fn json_rpc_flows_import_native_and_n8n() { /// fault-tolerance: the mock upstream has no connected-accounts route, so the /// Composio source fails and is tolerated (the RPC still returns the HTTP half /// rather than erroring). +#[cfg(feature = "flows")] #[tokio::test] async fn json_rpc_flows_list_connections_aggregates_secret_free() { let _env_lock = json_rpc_e2e_env_lock(); From 2e787e33305a206ce9ea486acff9f195e15062da Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:51:11 +0530 Subject: [PATCH 54/86] fix(memory): exclude same-session docs from recall so agents don't retrieve their own request (#4991) --- .../agent/harness/session/turn/core.rs | 17 +- .../memory_search/tools/hybrid_search.rs | 19 ++- src/openhuman/memory_store/memory_trait.rs | 125 ++++++++++++++- src/openhuman/memory_store/unified/query.rs | 61 ++++++- .../memory_store/unified/query_tests.rs | 151 ++++++++++++++++++ 5 files changed, 367 insertions(+), 6 deletions(-) diff --git a/src/openhuman/agent/harness/session/turn/core.rs b/src/openhuman/agent/harness/session/turn/core.rs index 3e21abd82f..b6ca114c1f 100644 --- a/src/openhuman/agent/harness/session/turn/core.rs +++ b/src/openhuman/agent/harness/session/turn/core.rs @@ -585,8 +585,21 @@ impl Agent { let user_msg = user_message.to_string(); let autosave_key = format!("user_msg:{}", uuid::Uuid::new_v4()); let chars = user_msg.chars().count(); + // Captured *before* `tokio::spawn` — the ambient thread id is a + // `tokio::task_local` (see `inference::provider::thread_context`) + // and does not propagate into a spawned task, so it must be read + // on this (still-scoped) task and moved in explicitly. Tagging + // this document with the live chat thread id is what lets the + // same-session exclusion filter (`UnifiedMemory::recall` / + // `memory_hybrid_search`) recognize and drop it later this same + // turn, so the agent's own on-demand memory search doesn't echo + // its own triggering request back as a "relevant" result. + let session_id_for_autosave = + crate::openhuman::inference::provider::thread_context::current_thread_id(); log::debug!( - "[agent_autosave] enqueue user-message store key={autosave_key} chars={chars}" + "[agent_autosave] enqueue user-message store key={autosave_key} chars={chars} \ + session_id={}", + session_id_for_autosave.as_deref().unwrap_or("") ); tokio::spawn(async move { match memory @@ -595,7 +608,7 @@ impl Agent { &autosave_key, &user_msg, MemoryCategory::Conversation, - None, + session_id_for_autosave.as_deref(), ) .await { diff --git a/src/openhuman/memory_search/tools/hybrid_search.rs b/src/openhuman/memory_search/tools/hybrid_search.rs index eda4b46987..544aa76787 100644 --- a/src/openhuman/memory_search/tools/hybrid_search.rs +++ b/src/openhuman/memory_search/tools/hybrid_search.rs @@ -146,8 +146,25 @@ impl Tool for MemoryHybridSearchTool { ) .map_err(|e| anyhow::anyhow!("memory_hybrid_search: open store failed: {e}"))?; + // Self-echo guard (agent-agnostic, mirrors `UnifiedMemory::recall`): + // exclude documents auto-saved for the ambient chat thread (set by + // the web channel around the turn) so a search issued mid-turn + // never retrieves the very request that triggered it. `None` + // outside a chat turn — unchanged behavior for cron/CLI/tests. + let exclude_session_id = + crate::openhuman::inference::provider::thread_context::current_thread_id(); + if let Some(ref excluded) = exclude_session_id { + log::debug!( + "[tool][memory_hybrid_search] applying same-session exclusion exclude_session_id={excluded}" + ); + } let hits = memory - .query_namespace_hits(&parsed.namespace, &parsed.query, limit) + .query_namespace_hits_excluding_session( + &parsed.namespace, + &parsed.query, + limit, + exclude_session_id.as_deref(), + ) .await .map_err(|e| anyhow::anyhow!("memory_hybrid_search: query failed: {e}"))?; diff --git a/src/openhuman/memory_store/memory_trait.rs b/src/openhuman/memory_store/memory_trait.rs index 64f9d2cbc6..2ae6037451 100644 --- a/src/openhuman/memory_store/memory_trait.rs +++ b/src/openhuman/memory_store/memory_trait.rs @@ -133,8 +133,31 @@ impl Memory for UnifiedMemory { ) -> anyhow::Result> { let namespace = normalize_namespace(opts.namespace); + // Self-echo guard (agent-agnostic): when this recall runs inside a + // live chat turn, the harness has an ambient "current thread" id + // (set by the web channel around `agent.run_single`, see + // `inference::provider::thread_context`) and the turn's own user + // message was just auto-saved as a `[conversation]` document tagged + // with that same id (`agent::harness::session::turn::core`). Exclude + // it here so the agent's own on-demand `memory_recall` never surfaces + // the very request that triggered it. Outside a chat turn (cron, + // CLI, tests, standalone) the ambient id is `None` and this is a + // no-op — behavior is byte-for-byte unchanged. + let exclude_session_id = + crate::openhuman::inference::provider::thread_context::current_thread_id(); + if let Some(ref excluded) = exclude_session_id { + tracing::debug!( + "[memory-trait] recall applying same-session exclusion namespace={namespace} \ + exclude_session_id={excluded}" + ); + } let ranked = self - .query_namespace_ranked(namespace, query, limit as u32) + .query_namespace_ranked_excluding_session( + namespace, + query, + limit as u32, + exclude_session_id.as_deref(), + ) .await .map_err(anyhow::Error::msg)?; @@ -892,4 +915,104 @@ mod tests { "user-driven row must keep its Internal label so mixed contexts don't over-escalate" ); } + + // ── Same-session self-echo exclusion, via the ambient thread scope ──── + // + // `Memory::recall` (backing the agent's `memory_recall` tool) reads the + // ambient chat-thread id set by `inference::provider::thread_context` + // around a live turn, and excludes documents tagged with that same id — + // guarding against the harness's own `user_msg:` autosave being + // recalled as the top "relevant" result for the very request that + // triggered the search. See `agent::harness::session::turn::core` + // (autosave tagging) and `query::query_namespace_hits_excluding_session` + // (the exclusion mechanism). + + #[tokio::test] + async fn recall_excludes_document_from_ambient_current_thread() { + use crate::openhuman::inference::provider::thread_context::with_thread_id; + + let (_tmp, mem) = fresh_mem(); + mem.store( + "global", + "user_msg:current-turn", + "Please look up Jordan Rivera's chat platform user ID for me.", + MemoryCategory::Conversation, + Some("thread-current"), + ) + .await + .unwrap(); + mem.store( + "global", + "fact:jordan-rivera-platform-id", + "Jordan Rivera's chat platform user ID is U0000042.", + MemoryCategory::Conversation, + Some("thread-other"), + ) + .await + .unwrap(); + + let entries = with_thread_id("thread-current", async { + mem.recall( + "Jordan Rivera chat platform user ID", + 10, + RecallOpts { + namespace: Some("global"), + min_score: Some(0.0), + ..Default::default() + }, + ) + .await + .unwrap() + }) + .await; + + assert!( + !entries.iter().any(|e| e.key == "user_msg:current-turn"), + "recall inside the ambient current-thread scope must exclude that thread's own \ + autosaved request, got {entries:#?}" + ); + assert!( + entries + .iter() + .any(|e| e.key == "fact:jordan-rivera-platform-id"), + "an unrelated document from a different session must still be recalled, got {entries:#?}" + ); + } + + #[tokio::test] + async fn recall_outside_any_thread_scope_is_unaffected() { + let (_tmp, mem) = fresh_mem(); + mem.store( + "global", + "user_msg:current-turn", + "Please look up Jordan Rivera's chat platform user ID for me.", + MemoryCategory::Conversation, + Some("thread-current"), + ) + .await + .unwrap(); + + // No `with_thread_id(...)` scope active — mirrors cron, CLI, + // standalone, and any pre-existing caller. `current_thread_id()` + // returns `None`, so no exclusion applies and behavior is + // byte-for-byte the same as before this fix. + let entries = mem + .recall( + "Jordan Rivera chat platform user ID", + 10, + RecallOpts { + namespace: Some("global"), + min_score: Some(0.0), + ..Default::default() + }, + ) + .await + .unwrap(); + + assert!( + entries.iter().any(|e| e.key == "user_msg:current-turn"), + "with no ambient thread scope, recall must return the document exactly as before \ + this fix, got {entries:#?}" + ); + } } diff --git a/src/openhuman/memory_store/unified/query.rs b/src/openhuman/memory_store/unified/query.rs index 1ce671dfad..559bb52024 100644 --- a/src/openhuman/memory_store/unified/query.rs +++ b/src/openhuman/memory_store/unified/query.rs @@ -81,7 +81,23 @@ impl UnifiedMemory { query: &str, limit: u32, ) -> Result, String> { - let hits = self.query_namespace_hits(namespace, query, limit).await?; + self.query_namespace_ranked_excluding_session(namespace, query, limit, None) + .await + } + + /// Same as [`Self::query_namespace_ranked`], but excludes same-session + /// documents — see [`Self::query_namespace_hits_excluding_session`] for + /// the exact semantics and backward-compatibility guarantee. + pub async fn query_namespace_ranked_excluding_session( + &self, + namespace: &str, + query: &str, + limit: u32, + exclude_session_id: Option<&str>, + ) -> Result, String> { + let hits = self + .query_namespace_hits_excluding_session(namespace, query, limit, exclude_session_id) + .await?; let mut out = Vec::new(); for hit in hits { if hit.kind != MemoryItemKind::Document { @@ -106,9 +122,50 @@ impl UnifiedMemory { namespace: &str, query: &str, limit: u32, + ) -> Result, String> { + self.query_namespace_hits_excluding_session(namespace, query, limit, None) + .await + } + + /// Same as [`Self::query_namespace_hits`], but drops any document-kind + /// hit whose stored `session_id` matches `exclude_session_id`. + /// + /// This is the self-echo guard for agent-invoked search (`memory_recall`, + /// `memory_hybrid_search`): the harness auto-saves the user's own turn as + /// a `[conversation]` document tagged with the ambient chat thread id + /// (see `agent::harness::session::turn::core`), so without this filter a + /// search issued *during that same turn* can retrieve its own triggering + /// request as the top "relevant" result. Only documents are + /// session-filtered — KV rows carry no session concept, and + /// episodic/event hits already have their own dedicated session-scoping + /// (`RecallOpts::session_id` / `cross_session`). + /// + /// `exclude_session_id = None` (or an empty/whitespace string) is + /// identical to [`Self::query_namespace_hits`] — no filtering is + /// applied, so every existing caller (and every caller with no ambient + /// session context) keeps its exact prior behavior. + pub async fn query_namespace_hits_excluding_session( + &self, + namespace: &str, + query: &str, + limit: u32, + exclude_session_id: Option<&str>, ) -> Result, String> { let ns = Self::sanitize_namespace(namespace); - let docs = self.load_documents_for_scope(&ns).await?; + let exclude_session_id = exclude_session_id + .map(str::trim) + .filter(|id| !id.is_empty()); + let mut docs = self.load_documents_for_scope(&ns).await?; + if let Some(exclude) = exclude_session_id { + let before = docs.len(); + docs.retain(|doc| doc.session_id.as_deref() != Some(exclude)); + let dropped = before - docs.len(); + tracing::debug!( + "[query] session-exclusion filter namespace={ns} exclude_session_id={exclude} \ + dropped={dropped} remaining={}", + docs.len() + ); + } let kvs = self.kv_records_for_scope(&ns).await?; let graph_relations = self diff --git a/src/openhuman/memory_store/unified/query_tests.rs b/src/openhuman/memory_store/unified/query_tests.rs index bba017ffff..45b1e5ae4f 100644 --- a/src/openhuman/memory_store/unified/query_tests.rs +++ b/src/openhuman/memory_store/unified/query_tests.rs @@ -847,3 +847,154 @@ async fn recall_relevant_by_vector_gates_on_similarity() { "an unrelated message must surface no situational preferences" ); } + +// ── Same-session self-echo exclusion (memory-search self-echo fix) ───────── +// +// Regression coverage for the workflow_builder self-echo bug: the harness +// auto-saves the user's own turn as a `[conversation]` document tagged with +// the live chat thread id, and without a filter a search issued mid-turn +// could retrieve that very request as its own top "relevant" result. See +// `UnifiedMemory::query_namespace_hits_excluding_session`. + +fn conversation_doc_with_session( + key: &str, + content: &str, + session_id: Option<&str>, +) -> NamespaceDocumentInput { + NamespaceDocumentInput { + namespace: "global".to_string(), + key: key.to_string(), + title: key.to_string(), + content: content.to_string(), + source_type: "chat".to_string(), + priority: "medium".to_string(), + tags: vec![], + metadata: json!({}), + category: "conversation".to_string(), + session_id: session_id.map(str::to_string), + document_id: None, + taint: crate::openhuman::memory::MemoryTaint::Internal, + } +} + +#[tokio::test] +async fn excludes_same_session_document_but_keeps_unrelated_useful_doc() { + let tmp = TempDir::new().unwrap(); + let memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap(); + + // (a) The current turn's own auto-saved request — tagged with the live + // session/thread id, exactly like `agent::harness::session::turn::core` + // tags the `user_msg:` autosave. + memory + .upsert_document(conversation_doc_with_session( + "user_msg:current-turn", + "Please look up Jordan Rivera's chat platform user ID for me.", + Some("thread-current"), + )) + .await + .unwrap(); + + // (b) An unrelated, genuinely useful fact from a prior turn/session that + // actually answers the query. + memory + .upsert_document(conversation_doc_with_session( + "fact:jordan-rivera-platform-id", + "Jordan Rivera's chat platform user ID is U0000042.", + Some("thread-other"), + )) + .await + .unwrap(); + + let query = "Jordan Rivera chat platform user ID"; + + // Sanity check: without exclusion, both documents are lexically relevant + // and both come back (this is the pre-fix, buggy shape). + let unfiltered = memory + .query_namespace_hits("global", query, 10) + .await + .unwrap(); + assert!( + unfiltered.iter().any(|h| h.key == "user_msg:current-turn"), + "sanity check: the self-request doc must be lexically relevant without a filter, got {unfiltered:#?}" + ); + + // With the current-session exclusion applied, the self-echo document is + // dropped and the useful fact survives. + let filtered = memory + .query_namespace_hits_excluding_session("global", query, 10, Some("thread-current")) + .await + .unwrap(); + + assert!( + !filtered.iter().any(|h| h.key == "user_msg:current-turn"), + "same-session self-request document must be excluded, got {filtered:#?}" + ); + assert!( + filtered + .iter() + .any(|h| h.key == "fact:jordan-rivera-platform-id"), + "unrelated useful document from another session must still be returned, got {filtered:#?}" + ); +} + +#[tokio::test] +async fn no_session_context_leaves_results_unchanged() { + let tmp = TempDir::new().unwrap(); + let memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap(); + + memory + .upsert_document(conversation_doc_with_session( + "user_msg:current-turn", + "Please look up Jordan Rivera's chat platform user ID for me.", + Some("thread-current"), + )) + .await + .unwrap(); + memory + .upsert_document(conversation_doc_with_session( + "fact:jordan-rivera-platform-id", + "Jordan Rivera's chat platform user ID is U0000042.", + Some("thread-other"), + )) + .await + .unwrap(); + + let query = "Jordan Rivera chat platform user ID"; + + let baseline = memory + .query_namespace_hits("global", query, 10) + .await + .unwrap(); + + // `None` exclusion (no ambient session context — cron, CLI, standalone, + // or a caller with no `exclude_session_id` to give) must behave + // identically to the pre-existing `query_namespace_hits` entry point: + // same hit count, same keys, in the same order. + let explicit_none = memory + .query_namespace_hits_excluding_session("global", query, 10, None) + .await + .unwrap(); + + let baseline_keys: Vec<&str> = baseline.iter().map(|h| h.key.as_str()).collect(); + let explicit_none_keys: Vec<&str> = explicit_none.iter().map(|h| h.key.as_str()).collect(); + assert_eq!( + baseline_keys, explicit_none_keys, + "no session context must be a no-op vs. the unfiltered entry point" + ); + assert!( + explicit_none_keys.contains(&"user_msg:current-turn"), + "without any exclusion, the self-request document must still be present" + ); + + // An empty/whitespace exclude id must also be treated as "no filter", + // not accidentally matched against a document with `session_id: None`. + let empty_string = memory + .query_namespace_hits_excluding_session("global", query, 10, Some(" ")) + .await + .unwrap(); + let empty_string_keys: Vec<&str> = empty_string.iter().map(|h| h.key.as_str()).collect(); + assert_eq!( + baseline_keys, empty_string_keys, + "a blank exclude_session_id must not filter anything" + ); +} From ef93bd9b2e136d4cb8d36354fb28e6f377769c51 Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:38:12 +0530 Subject: [PATCH 55/86] feat(core): compile-time mcp feature gate (#4799) (#4914) Co-authored-by: Steven Enamakel Co-authored-by: M3gA-Mind Co-authored-by: oxoxDev --- AGENTS.md | 23 ++++ Cargo.toml | 23 +++- app/src-tauri/Cargo.toml | 2 +- src/core/all_tests.rs | 55 ++++++++ src/core/cli_tests.rs | 55 ++++++++ src/core/legacy_aliases.rs | 29 ++++ .../agent/harness/definition_tests.rs | 4 + .../harness/subagent_runner/tool_prep.rs | 5 + src/openhuman/agent_registry/agents/loader.rs | 83 ++++++++++++ src/openhuman/agent_registry/agents/mod.rs | 1 + src/openhuman/mcp_audit/mod.rs | 24 ++++ src/openhuman/mcp_audit/stub.rs | 58 ++++++++ src/openhuman/mcp_client/mod.rs | 38 +++++- src/openhuman/mcp_registry/connections.rs | 21 +-- src/openhuman/mcp_registry/mod.rs | 42 ++++++ src/openhuman/mcp_registry/stub.rs | 127 ++++++++++++++++++ src/openhuman/mcp_registry/types.rs | 27 ++++ src/openhuman/mcp_server/mod.rs | 44 +++++- src/openhuman/mcp_server/stub.rs | 90 +++++++++++++ src/openhuman/mcp_server/tools/mod.rs | 33 +++-- src/openhuman/tool_registry/ops_tests.rs | 49 +++++-- src/openhuman/tool_registry/schemas.rs | 12 +- src/openhuman/tools/impl/network/mod.rs | 8 ++ src/openhuman/tools/mod.rs | 1 + src/openhuman/tools/ops.rs | 66 ++++++--- src/openhuman/tools/ops_tests.rs | 68 ++++++++++ tests/mcp_registry_e2e.rs | 6 + tests/mcp_registry_multi_server.rs | 6 + tests/mcp_setup_e2e.rs | 7 + tests/mcp_stdio_integration.rs | 6 + 30 files changed, 954 insertions(+), 59 deletions(-) create mode 100644 src/openhuman/mcp_audit/stub.rs create mode 100644 src/openhuman/mcp_registry/stub.rs create mode 100644 src/openhuman/mcp_server/stub.rs diff --git a/AGENTS.md b/AGENTS.md index 14b63465a6..0bb6a8daa6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -242,6 +242,7 @@ GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml \ | `meet` | ON | `openhuman::meet` (join-URL validation) + `openhuman::meet_agent` (live STT/LLM/TTS loop) + `openhuman::agent_meetings` (backend-delegated Meet bot over Socket.IO) | none — see note | | `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) | **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. @@ -283,6 +284,28 @@ When skills are off: the `skills` / `skill_runtime` / `skill_registry` controlle **Testing gotcha (applies to every gate).** The CI smoke lane runs `cargo check` only — it never runs `cargo test --no-default-features`, so CI stays green while the disabled-build **test** suite is broken. Tests that hard-assert a gated family (`.expect("a flows.* method exists")`, `assert!(full_ns.contains("flows"))`, `group_for_namespace("flows")`, built-in-agent id lists) must be `#[cfg]`-gated in lockstep with the feature. Run `GGML_NATIVE=OFF cargo test --lib --no-default-features --features tokenjuice-treesitter core::` locally before pushing any gate change. +#### The `mcp` gate + +Follows the voice facade+stub pattern for `mcp_server` / `mcp_registry` / `mcp_audit` (`stub.rs` in each), with two refinements worth copying: + +- **Type carve-out.** Inert, dependency-free type modules stay **ungated**: `mcp_registry::types`, `mcp_audit::types`, `mcp_server::tools::types` (`McpToolSpec`). They are `serde`/`serde_json`-only data consumed by always-compiled callers (the orchestrator prompt builder, `tool_registry`). Both builds therefore share the **one real type definition** — the stubs carry behaviour only, so struct fields can never drift between the enabled and disabled builds. `ConnectedServerOverview` was moved from `connections.rs` into `types.rs` for exactly this reason and is re-exported from `connections` so existing paths still resolve. +- **Split facade (`mcp_client`).** `mcp_client` is **not** gated wholesale, because the directory does not match the real dependency graph. `mcp_client::sanitize` and `mcp_client::client` (`McpHttpClient`, `redact_endpoint`, `McpUnauthorizedError`) stay **always compiled**: they are mis-housed shared utilities. The `gitbooks` docs tool dials `McpHttpClient` directly (GitBook is modelled as a legacy MCP server), and the orchestrator prompt sanitizes **skill** descriptions through `sanitize::sanitize_for_llm` — neither has anything to do with MCP, and stubbing them would silently break a docs tool and corrupt the orchestrator prompt in slim builds. Only `registry`, `stdio`, `spawn_env`, `setup_agent` are gated. **The gate follows the real dependency graph, not the directory name.** (Relocating `sanitize` + `client` out of `mcp_client` is worthwhile follow-up.) A bonus of keeping `client` compiled: the `McpServerNeedsAuth` classifier coupling test in `core::observability` stays always-compiled — no `#[cfg]`, no wording-drift leak. + +**Scope note — the `mcp` gate drops ZERO dependencies.** There is no MCP SDK in this crate: the dependency declarations contain no MCP-specific SDK or transport dependency; `test-mcp-stub` is the only MCP-named bin target. The entire protocol stack is hand-rolled over tokio process stdio + `reqwest` + `axum`, all of which are load-bearing for non-MCP domains. The gate is worth having for the ~20k LOC / ~19 agent tools / RPC surface it removes, but the issue-level DoD line claiming it "sheds the MCP SDK / transport stack" is superseded by this correction. The `mcp = []` feature list in `Cargo.toml` is intentionally empty — do not "fix" it by adding `dep:` entries. + +**Static vs dynamic — the naming is INVERTED from intuition.** Both halves must be gated or the gate is only half-applied: + +| Module | Despite the name, it is… | Backed by | Agent tools | +| ------ | ------------------------ | --------- | ----------- | +| `mcp_client` | the **STATIC**, config-declared server set (`[[mcp_client.servers]]` in TOML → `McpServerRegistry::from_config`) | TOML config | `mcp_list_servers`, `mcp_list_tools`, `mcp_call_tool` | +| `mcp_registry` | the **DYNAMIC**, user-installed Smithery servers (live connection map, boot spawn, supervisor, OAuth) | SQLite `mcp_clients.db` | 11 × `mcp_registry_*` | + +**CLI when compiled out.** `src/core/cli.rs` is deliberately **untouched**: the `"mcp" | "mcp-server"` arm resolves to the stub's `run_stdio_from_cli`, which returns a "mcp feature disabled at compile time … rebuild with `--features mcp`" error. Deleting the arm would let `mcp` fall through to generic namespace resolution and fail with `unknown namespace: mcp` — which reads like a user typo rather than a build fact, and would leave an MCP host (Claude Desktop / Cursor) hanging on stdout that never speaks JSON-RPC. Pinned by `mcp_subcommand_reports_disabled_build_when_gate_off` in `src/core/cli_tests.rs`. + +**Dangling `mcp_agent` in the orchestrator TOML is expected and safe.** `agent.toml` is data and cannot be `#[cfg]`'d, so the orchestrator keeps listing `mcp_agent` in `subagents` even when the agent is compiled out. Both resolution sites already tolerate unknown ids — `collect_orchestrator_tools` warns and skips, `validate_tier_hierarchy` `continue`s — so the core still boots. `orchestrator_tolerates_unresolvable_subagent_id` / `orchestrator_tolerates_absent_mcp_agent` in `loader.rs` pin that contract; do not "tighten" unknown-subagent handling into a hard error without re-checking them. `src/core/legacy_aliases.rs`'s frontend-catalog drift tests ignore gated namespaces for the same data-vs-code reason. + +`src/core/all.rs` needs **no** `#[cfg]` for this gate: the stub aggregators return empty vecs, so the registration sites keep compiling unchanged. + ### Event bus (`src/core/event_bus/`) Typed pub/sub + native request/response. Both singletons — use module-level functions. diff --git a/Cargo.toml b/Cargo.toml index b21b8ac941..14b8e35b92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -347,7 +347,7 @@ tokio = { version = "1", features = ["test-util"] } proptest = "1" [features] -default = ["tokenjuice-treesitter", "voice", "web3", "media", "meet", "skills", "flows"] +default = ["tokenjuice-treesitter", "voice", "web3", "media", "meet", "skills", "flows", "mcp"] # 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 = [ @@ -437,6 +437,27 @@ meet = [] # types are a type carve-out, not part of the gated behaviour. See # `src/openhuman/skills/stub.rs`. skills = [] +# MCP domains: `mcp_server` (the `openhuman mcp` stdio/HTTP server exposing our +# tool surface), `mcp_registry` (dynamic Smithery installs — SQLite, lifecycle, +# `mcp_clients` RPC namespace), `mcp_audit` (write-audit log), and the +# static/config-declared server set in `mcp_client`. Default-ON — the desktop +# app always ships with MCP. Composes with the runtime `DomainSet::mcp` flag +# (#4796): this feature narrows the compile-time surface, `DomainSet` gates it +# at runtime. +# +# INTENTIONALLY EMPTY — do NOT "fix" this by adding `dep:` entries. There is no +# MCP SDK in this crate: the whole protocol stack is hand-rolled over tokio +# process stdio + reqwest + axum, every one of which is load-bearing for +# non-MCP domains. So this gate drops ~20k LOC and ~19 agent tools but ZERO +# dependencies. The issue-level DoD line claiming it "sheds the MCP SDK / +# transport stack" is superseded by this correction (see AGENTS.md). +# +# NOTE: `mcp_client::{sanitize, client}` stay ALWAYS compiled — they are +# mis-housed shared utilities (the gitbooks docs tool dials `McpHttpClient`; +# the orchestrator prompt sanitises *skill* descriptions through +# `sanitize::sanitize_for_llm`). The gate follows the real dependency graph, +# not the directory name. +mcp = [] 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 70f2403053..6e477344e4 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -164,11 +164,11 @@ openhuman_core = { path = "../..", package = "openhuman", default-features = fal "media", "tokenjuice-treesitter", "voice", - "tokenjuice-treesitter", "web3", "flows", "meet", "skills", + "mcp", ] } tinyjuice = { version = "0.2.1", default-features = false } diff --git a/src/core/all_tests.rs b/src/core/all_tests.rs index 68715e682b..10be165c8d 100644 --- a/src/core/all_tests.rs +++ b/src/core/all_tests.rs @@ -335,6 +335,7 @@ fn schema_for_rpc_method_finds_security_policy_info() { } #[test] +#[cfg(feature = "mcp")] fn schema_for_rpc_method_finds_internal_mcp_audit_list() { let schema = schema_for_rpc_method("openhuman.mcp_audit_list"); assert!( @@ -965,9 +966,63 @@ fn group_mapping_smoke() { #[cfg(feature = "meet")] assert_eq!(group_for_namespace("meet"), Some(DomainGroup::Meet)); // Internal-only registry is grouped too (mcp_audit → Mcp). + // Compiled out with the `mcp` feature: `group_for_namespace` reads the LIVE + // registry, and the gate unregisters the mcp_audit controller entirely. + #[cfg(feature = "mcp")] assert_eq!(group_for_namespace("mcp_audit"), Some(DomainGroup::Mcp)); } +// --- `mcp` compile-time gate (#4799) ------------------------------------ + +/// With the `mcp` feature ON (the default / shipped desktop build), both MCP +/// namespaces are registered: `mcp_clients` (the dynamic Smithery registry, +/// agent-facing) and `mcp_audit` (the write-audit log, internal-only). +/// +/// Paired with `mcp_namespaces_absent_when_gate_off` below so the gate is +/// pinned in BOTH directions — an assert that only ever runs in one build +/// configuration cannot prove a gate works. +#[test] +#[cfg(feature = "mcp")] +fn mcp_namespaces_registered_when_gate_on() { + assert_eq!( + group_for_namespace("mcp_clients"), + Some(DomainGroup::Mcp), + "with `mcp` compiled in, the dynamic registry's `mcp_clients` \ + namespace must be registered" + ); + assert_eq!( + group_for_namespace("mcp_audit"), + Some(DomainGroup::Mcp), + "with `mcp` compiled in, the internal `mcp_audit` namespace must be \ + registered" + ); +} + +/// With the `mcp` feature OFF, both MCP namespaces are gone from the live +/// registry — every `openhuman.mcp_clients_*` / `openhuman.mcp_audit_*` method +/// is an unknown method over `/rpc` and absent from `/schema`. +/// +/// This is the compile-time analogue of the runtime `DomainSet::mcp` filter: +/// `DomainSet` can hide these namespaces at runtime, this feature removes the +/// code that backs them altogether. Note the stubs make this work with NO +/// `#[cfg]` in `src/core/all.rs` — the aggregators simply return empty vecs. +#[test] +#[cfg(not(feature = "mcp"))] +fn mcp_namespaces_absent_when_gate_off() { + assert_eq!( + group_for_namespace("mcp_clients"), + None, + "with `mcp` compiled out, the `mcp_clients` namespace must not be \ + registered — the stub aggregator returns an empty vec" + ); + assert_eq!( + group_for_namespace("mcp_audit"), + None, + "with `mcp` compiled out, the internal `mcp_audit` namespace must not \ + be registered — the stub aggregator returns an empty vec" + ); +} + // --- #4797: `flows` compile-time gate (directional proof) ------------------- // // One namespace, not three: `tinyflows` registers no controllers, and diff --git a/src/core/cli_tests.rs b/src/core/cli_tests.rs index 1dcf86e21c..adf6d7c302 100644 --- a/src/core/cli_tests.rs +++ b/src/core/cli_tests.rs @@ -140,3 +140,58 @@ fn load_dotenv_for_cli_reads_cwd_dotenv_without_overwriting_existing_env() { ); assert_eq!(loaded_app_env.as_deref(), Some("production")); } + +// --- `mcp` compile-time gate (#4799) ------------------------------------ + +/// With the `mcp` feature compiled out, `openhuman mcp` must fail with a +/// diagnostic that names the BUILD as the cause — not a generic +/// "unknown namespace" error. +/// +/// Why this matters enough to test: the naive way to gate the CLI is to delete +/// the `"mcp" | "mcp-server"` match arm. That is WRONG — `mcp` would fall +/// through to generic namespace resolution and die with `unknown namespace: +/// mcp`, which reads like the user typo'd a command rather than like a +/// property of this build. Instead `cli.rs` is untouched and the arm resolves +/// to `mcp_server::stub::run_stdio_from_cli`, which bails with the message +/// asserted below. An MCP host (Claude Desktop, Cursor, …) spawning +/// `openhuman mcp` therefore gets a non-zero exit + a one-line reason on +/// stderr instead of hanging on stdout that never speaks JSON-RPC. +#[test] +#[cfg(not(feature = "mcp"))] +fn mcp_subcommand_reports_disabled_build_when_gate_off() { + let _guard = env_lock(); + + let err = crate::core::cli::run_from_cli_args(&["mcp".to_string()]) + .expect_err("`openhuman mcp` must fail when the `mcp` feature is compiled out"); + let msg = err.to_string(); + + assert!( + msg.contains("mcp feature disabled"), + "error must name the compile-time gate as the cause; got: {msg}" + ); + assert!( + msg.contains("--features mcp"), + "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 `mcp-server` alias must behave identically to `mcp` — both arms route +/// to the same stub, so neither can silently regress into the fall-through. +#[test] +#[cfg(not(feature = "mcp"))] +fn mcp_server_alias_reports_disabled_build_when_gate_off() { + let _guard = env_lock(); + + let err = crate::core::cli::run_from_cli_args(&["mcp-server".to_string()]) + .expect_err("`openhuman mcp-server` must fail when the `mcp` feature is compiled out"); + + assert!( + err.to_string().contains("mcp feature disabled"), + "the `mcp-server` alias must give the same build-fact diagnostic as `mcp`" + ); +} diff --git a/src/core/legacy_aliases.rs b/src/core/legacy_aliases.rs index 20574aff48..f08bf5850d 100644 --- a/src/core/legacy_aliases.rs +++ b/src/core/legacy_aliases.rs @@ -349,6 +349,33 @@ mod tests { .collect() } + /// Whether `method`'s controller is compiled out of THIS build by a + /// default-ON Cargo feature gate. + /// + /// The frontend RPC catalog and the alias table below are both **data**: + /// they are authored against the full (shipped desktop) surface and cannot + /// be `#[cfg]`'d per Rust feature — the frontend is built independently of + /// the core's feature set, and the shipped app always enables `mcp`. So in + /// a slim build they legitimately still name methods whose controllers no + /// longer exist. The drift checks below must therefore ignore exactly those + /// namespaces, and keep asserting on everything else — otherwise the whole + /// drift signal is lost in slim builds (or, worse, someone "fixes" the + /// failure by deleting live frontend methods from the catalog). + /// + /// 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_") + } + #[test] fn quoted_value_extracts_single_quoted_string() { assert_eq!(quoted_value(": 'hello'"), "hello"); @@ -607,6 +634,7 @@ mod tests { let missing: Vec<_> = core_methods .values() .filter(|method| !registered.contains(*method)) + .filter(|method| !is_compiled_out_method(method)) .cloned() .collect(); @@ -638,6 +666,7 @@ mod tests { let missing: Vec<_> = legacy_aliases() .iter() .filter(|(_, canonical)| !registered.contains(*canonical)) + .filter(|(_, canonical)| !is_compiled_out_method(canonical)) .map(|(legacy, canonical)| format!("{legacy} -> {canonical}")) .collect(); diff --git a/src/openhuman/agent/harness/definition_tests.rs b/src/openhuman/agent/harness/definition_tests.rs index 70ac7f7d68..fde7fb579c 100644 --- a/src/openhuman/agent/harness/definition_tests.rs +++ b/src/openhuman/agent/harness/definition_tests.rs @@ -367,6 +367,10 @@ fn all_builtin_agent_definitions_have_expected_effective_max_iterations() { ("code_executor", 50), ("context_scout", 50), ("integrations_agent", 50), + // `mcp_agent` is compiled out with the `mcp` feature (#4799). + // `mcp_setup` is NOT — only its five tools are gated, so the agent + // definition still loads in both builds. + #[cfg(feature = "mcp")] ("mcp_agent", 50), ("mcp_setup", 50), ("planner", 50), diff --git a/src/openhuman/agent/harness/subagent_runner/tool_prep.rs b/src/openhuman/agent/harness/subagent_runner/tool_prep.rs index 8e1602711a..e0f5309558 100644 --- a/src/openhuman/agent/harness/subagent_runner/tool_prep.rs +++ b/src/openhuman/agent/harness/subagent_runner/tool_prep.rs @@ -252,6 +252,11 @@ mod tests { "schedule_task", "make_presentation", "archive_session", + // `use_mcp_server` is `mcp_agent`'s `delegate_name`; the agent — + // and therefore this delegate tool — is compiled out with the + // `mcp` feature (#4799). `setup_mcp_server` belongs to + // `mcp_setup`, which stays registered in both builds. + #[cfg(feature = "mcp")] "use_mcp_server", "setup_mcp_server", ] { diff --git a/src/openhuman/agent_registry/agents/loader.rs b/src/openhuman/agent_registry/agents/loader.rs index e23fb71d75..a7bf29b682 100644 --- a/src/openhuman/agent_registry/agents/loader.rs +++ b/src/openhuman/agent_registry/agents/loader.rs @@ -257,6 +257,20 @@ pub const BUILTINS: &[BuiltinAgent] = &[ prompt_fn: super::mcp_setup::prompt::build, graph_fn: None, }, + // Connected-server execution specialist. Compiled out with the `mcp` + // feature, which drops the `delegate_use_mcp_server` tool from the + // orchestrator's synthesised belt. + // + // The orchestrator's `agent.toml` still lists `mcp_agent` in `subagents` + // (TOML is data — it cannot be `cfg`'d, and forking it per-feature would + // invite exactly the data drift this gate is meant to avoid). That + // dangling reference is SAFE and already handled: `collect_orchestrator_tools` + // logs a warn and skips subagent ids that are not in the registry, and + // `validate_tier_hierarchy` explicitly `continue`s past unknown ids rather + // than failing the boot. `orchestrator_tolerates_absent_mcp_agent` in the + // test module below pins that contract so a future "strict unknown + // subagent" change cannot silently break the slim build's boot. + #[cfg(feature = "mcp")] BuiltinAgent { id: "mcp_agent", toml: include_str!("mcp_agent/agent.toml"), @@ -1857,6 +1871,71 @@ mod tests { ); } + /// The `mcp` gate's load-bearing safety contract (#4799). + /// + /// `agent.toml` is DATA — it cannot be `#[cfg]`'d, so the orchestrator goes + /// on listing `mcp_agent` in `subagents` even in builds where the `mcp` + /// feature dropped `mcp_agent` from [`BUILTINS`]. That leaves a subagent id + /// that resolves to nothing, and the whole gate rests on the loader + /// TOLERATING it rather than failing the boot. + /// + /// Two independent sites provide that tolerance today: + /// * `orchestrator_tools::collect_orchestrator_tools` warns + skips + /// subagent ids absent from the registry; + /// * [`validate_tier_hierarchy`] `continue`s past unknown ids instead of + /// reporting a tier error. + /// + /// This test pins the second one (the boot-blocking one) from BOTH build + /// configurations, so a future "unknown subagent ids are a hard error" + /// change fails here loudly instead of silently breaking the slim build's + /// boot — the failure mode would otherwise only appear in a + /// `--no-default-features` run, which CI's `cargo check` lane cannot catch. + #[test] + fn orchestrator_tolerates_unresolvable_subagent_id() { + let mut def = find("orchestrator"); + def.subagents.push(SubagentEntry::AgentId( + "definitely_not_a_compiled_in_agent".into(), + )); + + validate_tier_hierarchy(&[def]).expect( + "validate_tier_hierarchy must tolerate an unresolvable subagent id — the `mcp` \ + feature gate relies on it (orchestrator's agent.toml lists `mcp_agent` even in \ + builds that compile `mcp_agent` out)", + ); + } + + /// Companion to the above, asserting the real gated shape rather than a + /// synthetic id: with `mcp` compiled out, `mcp_agent` is genuinely absent + /// from the loaded set while the orchestrator still lists it — and + /// `load_builtins` (which runs `validate_tier_hierarchy` internally) must + /// still succeed, i.e. the core boots. + #[test] + #[cfg(not(feature = "mcp"))] + fn orchestrator_tolerates_absent_mcp_agent() { + let defs = load_builtins().expect( + "load_builtins must succeed with `mcp` compiled out — the orchestrator's dangling \ + `mcp_agent` subagent reference must not fail the boot", + ); + + assert!( + !defs.iter().any(|d| d.id == "mcp_agent"), + "`mcp_agent` must be compiled out when the `mcp` feature is off" + ); + + let orchestrator = defs + .iter() + .find(|d| d.id == "orchestrator") + .expect("orchestrator must still load"); + assert!( + orchestrator.subagents.iter().any(|e| matches!( + e, + SubagentEntry::AgentId(id) if id == "mcp_agent" + )), + "orchestrator.agent.toml is data and still lists `mcp_agent` — this dangling \ + reference is exactly what the loader must tolerate" + ); + } + /// The orchestrator gets lightweight MCP discovery (`mcp_registry_status`, /// like `composio_list_connections`) but must NOT carry the per-server /// enumerate/execute tools — those belong to `mcp_agent`, keeping the @@ -1887,7 +1966,11 @@ mod tests { /// the discover + call surface and a stable `use_mcp_server` delegate name, /// but must NOT hold the secret-handling install/uninstall tools (those are /// `mcp_setup`'s) or any shell/file/network capability. + /// + /// Gated: `find` panics on a missing id, and the `mcp` feature drops + /// `mcp_agent` from [`BUILTINS`] entirely. #[test] + #[cfg(feature = "mcp")] fn mcp_agent_drives_connected_servers_without_install_or_shell() { let def = find("mcp_agent"); assert_eq!(def.agent_tier, AgentTier::Worker); diff --git a/src/openhuman/agent_registry/agents/mod.rs b/src/openhuman/agent_registry/agents/mod.rs index aa30640cbc..4d584f1e0f 100644 --- a/src/openhuman/agent_registry/agents/mod.rs +++ b/src/openhuman/agent_registry/agents/mod.rs @@ -16,6 +16,7 @@ pub mod help; pub mod image_agent; pub mod integrations_agent; pub mod markets_agent; +#[cfg(feature = "mcp")] pub mod mcp_agent; pub mod mcp_setup; pub mod morning_briefing; diff --git a/src/openhuman/mcp_audit/mod.rs b/src/openhuman/mcp_audit/mod.rs index 648d618752..5cb47a1d0f 100644 --- a/src/openhuman/mcp_audit/mod.rs +++ b/src/openhuman/mcp_audit/mod.rs @@ -3,15 +3,39 @@ //! The audit table is stored in the existing memory-tree SQLite database so //! writes and their query surface reuse the same local workspace persistence. +//! ## Compile-time gate (`mcp` feature) +//! +//! `pub mod mcp_audit;` is ALWAYS compiled — it is a facade. The SQLite store +//! and RPC surface are gated behind the default-ON `mcp` Cargo feature; when +//! it is off, [`stub`] mirrors the consumed surface with no-op / empty bodies. +//! +//! [`types`] stays UNGATED: it is inert serde data (`serde` + `serde_json` +//! only), so both builds share the one real definition and cannot drift. + +#[cfg(feature = "mcp")] mod schemas; +#[cfg(feature = "mcp")] pub mod store; + +// Inert serde types — always compiled (see the module note above). pub mod types; +#[cfg(feature = "mcp")] pub use schemas::{ all_controller_schemas as all_mcp_audit_controller_schemas, all_internal_controllers as all_mcp_audit_internal_controllers, all_registered_controllers as all_mcp_audit_registered_controllers, schemas as mcp_audit_schemas, }; +#[cfg(feature = "mcp")] pub use store::{list_writes, record_write}; pub use types::{McpWriteListQuery, McpWriteRecord, NewMcpWriteRecord}; + +// --------------------------------------------------------------------------- +// Disabled facade — compiled only when the `mcp` feature is OFF. +// --------------------------------------------------------------------------- + +#[cfg(not(feature = "mcp"))] +mod stub; +#[cfg(not(feature = "mcp"))] +pub use stub::*; diff --git a/src/openhuman/mcp_audit/stub.rs b/src/openhuman/mcp_audit/stub.rs new file mode 100644 index 0000000000..f2103cbfbe --- /dev/null +++ b/src/openhuman/mcp_audit/stub.rs @@ -0,0 +1,58 @@ +//! Disabled-MCP facade for [`super`] (the MCP write-audit log). +//! +//! Compiled only when the `mcp` Cargo feature is OFF (see the gate in +//! [`super`]). The audit log records calls made *through the MCP server*, so +//! with MCP compiled out there is no writer and nothing to read back — the +//! disabled surface is naturally empty rather than an error. +//! +//! As in the sibling `mcp_registry` stub, [`super::types`] is ungated, so this +//! module defines no data types — only behaviour. The signatures MUST match +//! the real ones exactly; the disabled build +//! (`cargo check --no-default-features --features tokenjuice-treesitter`) is +//! the only thing that catches drift. + +use anyhow::Result; + +use crate::openhuman::config::Config; + +use super::types::{McpWriteListQuery, McpWriteRecord, NewMcpWriteRecord}; + +/// Error/log text shared by every disabled-path operation. Owned locally +/// (mirroring the sibling `mcp_registry` / `mcp_server` stubs) so the module +/// stays self-contained rather than importing a private const across modules. +const DISABLED_MSG: &str = "mcp feature disabled at compile time"; + +// --------------------------------------------------------------------------- +// Controller registration (mirrors `schemas::all_internal_controllers`) +// --------------------------------------------------------------------------- + +/// No controllers: the internal `mcp_audit` list method is unregistered, so +/// the desktop UI/CLI sees an unknown method rather than an empty history. +/// +/// `src/core/all.rs` pushes this straight into its internal-controller vec +/// with no `#[cfg]` of its own — the empty vec keeps that file untouched. +pub fn all_mcp_audit_internal_controllers() -> Vec { + log::debug!("[mcp_audit] {DISABLED_MSG} — no internal controllers registered"); + Vec::new() +} + +// --------------------------------------------------------------------------- +// Store surface (mirrors `store::{record_write, list_writes}`) +// --------------------------------------------------------------------------- + +/// No-op that reports a synthetic row id: nothing can call an MCP write tool +/// in this build, so this is unreachable in practice. Returns `Ok` rather than +/// `Err` because the real callers treat a failure here as a logged anomaly — +/// an "audit write failed" warning would be actively misleading when the +/// audited subsystem does not exist. +pub fn record_write(_config: &Config, _record: NewMcpWriteRecord) -> Result { + log::debug!("[mcp_audit] {DISABLED_MSG} — record_write is a no-op (no MCP writer exists)"); + Ok(0) +} + +/// Empty history: no MCP writes can have occurred in this build. `Ok(vec![])` +/// (not `Err`) keeps the shape honest — the query succeeded, the log is empty. +pub fn list_writes(_config: &Config, _query: &McpWriteListQuery) -> Result> { + log::debug!("[mcp_audit] {DISABLED_MSG} — list_writes returning empty history"); + Ok(Vec::new()) +} diff --git a/src/openhuman/mcp_client/mod.rs b/src/openhuman/mcp_client/mod.rs index 5826b1bbd7..68adb5e12b 100644 --- a/src/openhuman/mcp_client/mod.rs +++ b/src/openhuman/mcp_client/mod.rs @@ -36,13 +36,46 @@ //! - `registry` — [`McpServerRegistry`] built from //! [`crate::openhuman::config::McpClientConfig`] +//! ## Compile-time gate (`mcp` feature) — SPLIT facade +//! +//! Unlike the sibling MCP modules, this one is NOT gated wholesale, because +//! the `mcp_client` directory does not match the real dependency graph. Two of +//! its submodules are **mis-housed shared utilities** with live consumers that +//! have nothing to do with the MCP subsystem, so they stay ALWAYS COMPILED: +//! +//! * [`sanitize`] — the orchestrator prompt builder runs *skill* descriptions +//! through `sanitize::sanitize_for_llm`. Nothing to do with MCP; stubbing it +//! would silently corrupt the orchestrator prompt in slim builds. +//! * [`client`] — the bespoke `gitbooks` docs tool dials [`McpHttpClient`] + +//! [`redact_endpoint`] directly (GitBook is modelled as a legacy MCP server). +//! Stubbing it would break a docs tool that users reach in slim builds. +//! Keeping it compiled also keeps [`McpUnauthorizedError`] — and therefore +//! the `McpServerNeedsAuth` classifier coupling test in +//! `core::observability` — always compiled, with no `#[cfg]` and no +//! wording-drift leak. +//! +//! Gated behind the default-ON `mcp` feature: [`registry`] (the static, +//! config-declared server set), `stdio`, `spawn_env`, and `setup_agent` — the +//! parts that genuinely constitute MCP-subsystem behaviour. +//! +//! In short: **the gate follows the real dependency graph, not the directory +//! name.** Relocating `sanitize` + `client` out of `mcp_client` is worthwhile +//! follow-up, but is deliberately out of scope here. +//! +//! There is no `stub` module: every gated item's consumers are themselves +//! gated, so nothing needs a disabled mirror. + mod client; +#[cfg(feature = "mcp")] mod registry; pub mod sanitize; +#[cfg(feature = "mcp")] pub mod setup_agent; -#[cfg(test)] +#[cfg(all(test, feature = "mcp"))] mod setup_agent_integration_test; +#[cfg(feature = "mcp")] mod spawn_env; +#[cfg(feature = "mcp")] mod stdio; pub use client::{ @@ -50,6 +83,9 @@ pub use client::{ McpHttpClient, McpInitializeResult, McpRemoteTool, McpServerToolResult, McpSseEvent, McpUnauthorizedError, ProtectedResourceMetadata, }; +#[cfg(feature = "mcp")] pub(crate) use registry::apply_safety_filter; +#[cfg(feature = "mcp")] pub use registry::{McpRegistrySource, McpServerDefinition, McpServerRegistry, McpTransportClient}; +#[cfg(feature = "mcp")] pub use stdio::McpStdioClient; diff --git a/src/openhuman/mcp_registry/connections.rs b/src/openhuman/mcp_registry/connections.rs index 6e41e84cf3..3ef5b0b72f 100644 --- a/src/openhuman/mcp_registry/connections.rs +++ b/src/openhuman/mcp_registry/connections.rs @@ -179,21 +179,12 @@ impl Connection { } } -/// One connected server's identity + advertised tools, for prompt-surface -/// discovery (the orchestrator's "## Connected MCP Servers" block). Sourced -/// entirely from the live connection map — no `Config`, no store read. -#[derive(Debug, Clone)] -pub struct ConnectedServerOverview { - pub server_id: String, - pub qualified_name: String, - pub display_name: String, - /// Short registry description — the primary capability hint surfaced in - /// the orchestrator prompt (mirrors Composio's per-toolkit description). - pub description: Option, - /// Advertised tools — retained for a tool-count fallback when a server - /// has no description, and for any caller that wants the full list. - pub tools: Vec, -} +/// Re-exported from [`super::types`] so the long-standing +/// `mcp_registry::connections::ConnectedServerOverview` path keeps resolving. +/// The definition moved to `types.rs` (an inert, dep-free module that survives +/// the `mcp` feature gate) because the always-compiled orchestrator prompt +/// builder consumes this type while `connections` itself is gated. +pub use super::types::ConnectedServerOverview; // ── Global registry ────────────────────────────────────────────────────────── diff --git a/src/openhuman/mcp_registry/mod.rs b/src/openhuman/mcp_registry/mod.rs index 5a481c276b..e241c73303 100644 --- a/src/openhuman/mcp_registry/mod.rs +++ b/src/openhuman/mcp_registry/mod.rs @@ -50,22 +50,55 @@ //! backwards compatibility with existing frontend code and on-disk state. //! The Rust module path is `mcp_registry`. +//! ## Compile-time gate (`mcp` feature) +//! +//! `pub mod mcp_registry;` is ALWAYS compiled — it is a facade. Everything +//! that carries behaviour (Smithery HTTP client, SQLite store, live +//! connection map, boot spawn, supervisor, OAuth, RPC surface) is gated +//! behind the default-ON `mcp` Cargo feature; when the feature is off, +//! [`stub`] mirrors the subset of the surface that always-compiled callers +//! depend on with no-op / `Err` / empty-`Vec` bodies, so those callers need no +//! `#[cfg]` of their own. +//! +//! [`types`] stays UNGATED on purpose: it is inert serde data (`serde` + +//! `serde_json` only) consumed by the always-compiled orchestrator prompt +//! builder. Sharing the one real definition across both builds means the +//! disabled build cannot drift from the enabled one — the stub carries +//! behaviour, never duplicated types. + +#[cfg(feature = "mcp")] pub mod boot; +#[cfg(feature = "mcp")] pub mod bus; +#[cfg(feature = "mcp")] pub mod connections; +#[cfg(feature = "mcp")] mod curation; +#[cfg(feature = "mcp")] pub mod oauth; +#[cfg(feature = "mcp")] pub mod ops; +#[cfg(feature = "mcp")] mod registries; +#[cfg(feature = "mcp")] mod registry; +#[cfg(feature = "mcp")] mod schemas; +#[cfg(feature = "mcp")] pub mod setup; +#[cfg(feature = "mcp")] pub mod setup_ops; +#[cfg(feature = "mcp")] pub mod store; +#[cfg(feature = "mcp")] pub mod supervisor; +#[cfg(feature = "mcp")] pub mod tools; + +// Inert serde types — always compiled (see the module note above). pub mod types; +#[cfg(feature = "mcp")] pub use schemas::{ all_controller_schemas as all_mcp_registry_controller_schemas, all_registered_controllers as all_mcp_registry_registered_controllers, @@ -73,3 +106,12 @@ pub use schemas::{ }; pub use types::{ConnStatus, InstalledServer, McpTool}; + +// --------------------------------------------------------------------------- +// Disabled facade — compiled only when the `mcp` feature is OFF. +// --------------------------------------------------------------------------- + +#[cfg(not(feature = "mcp"))] +mod stub; +#[cfg(not(feature = "mcp"))] +pub use stub::*; diff --git a/src/openhuman/mcp_registry/stub.rs b/src/openhuman/mcp_registry/stub.rs new file mode 100644 index 0000000000..9fe9f72b5c --- /dev/null +++ b/src/openhuman/mcp_registry/stub.rs @@ -0,0 +1,127 @@ +//! Disabled-MCP facade for [`super`] (the dynamic Smithery registry). +//! +//! Compiled only when the `mcp` Cargo feature is OFF (see the gate in +//! [`super`]). It mirrors the subset of the real `mcp_registry` public surface +//! that always-compiled callers depend on, with no-op / disabled-error / +//! empty-collection bodies so the crate still compiles, boots, and serves +//! `/rpc` without the MCP domains. +//! +//! Note what is NOT here: [`super::types`] is ungated, so this module defines +//! no data types at all — it carries behaviour only. Every type an always-on +//! caller names ([`ConnectedServerOverview`], [`McpTool`]) is the *real* one, +//! which is why the two builds cannot drift in shape. +//! +//! The signatures here MUST match the real ones exactly (return types and +//! async-ness included). The disabled build +//! (`cargo check --no-default-features --features tokenjuice-treesitter`) is +//! the only thing that catches drift — if a real signature changes, update the +//! mirror below until that build is green again. + +use crate::openhuman::config::Config; + +/// Error text returned by every disabled-path operation that must yield a +/// `Result`. Shared so callers / log-greps see one stable string. +const DISABLED_MSG: &str = "mcp feature disabled at compile time"; + +// --------------------------------------------------------------------------- +// Controller registration (mirrors `schemas::all_registered_controllers`) +// --------------------------------------------------------------------------- + +/// No controllers: the `mcp_clients` RPC namespace does not exist in this +/// build, so every `openhuman.mcp_clients_*` method is an unknown method over +/// `/rpc` and absent from `/schema`. +/// +/// `src/core/all.rs` pushes this straight into its controller vec with no +/// `#[cfg]` of its own — the empty vec is what keeps that (very hot, +/// multi-agent) file untouched by this gate. +pub fn all_mcp_registry_registered_controllers() -> Vec { + Vec::new() +} + +// --------------------------------------------------------------------------- +// Boot / lifecycle (mirrors `boot`, `bus`, `supervisor`) +// --------------------------------------------------------------------------- + +/// Boot-time spawn of installed local MCP servers. +pub mod boot { + use super::*; + + /// No-op: no installed-server store exists in this build, so there is + /// nothing to spawn. The real one already treats every per-server failure + /// as non-fatal and never blocks boot, so doing nothing is shape-identical + /// to "every server failed to spawn". + pub async fn spawn_installed_servers(_config: &Config) { + log::debug!("[mcp_registry] {DISABLED_MSG} — skipping installed-server spawn"); + } +} + +/// DomainEvent subscriber for MCP lifecycle logging. +pub mod bus { + use super::*; + + /// No-op: with no connection lifecycle there are no events to subscribe to. + pub fn init() { + log::debug!("[mcp_registry] {DISABLED_MSG} — event subscriber not registered"); + } +} + +/// Reconnect supervisor for installed local-spawn servers. +pub mod supervisor { + use super::*; + + /// Returns immediately instead of running the reconnect tick loop. + /// + /// The real `run` never returns (it is an infinite `interval` loop), and + /// its caller in `core/runtime/services.rs` spawns it as a background + /// task and does not await completion — so returning at once simply means + /// that task finishes rather than idling forever. + pub async fn run(_config: Config) { + log::debug!("[mcp_registry] {DISABLED_MSG} — supervisor not started"); + } +} + +// --------------------------------------------------------------------------- +// OAuth callback (mirrors `oauth::complete`) +// --------------------------------------------------------------------------- + +/// OAuth authorization-code completion for HTTP-remote MCP servers. +pub mod oauth { + use super::*; + + /// Always errors: no pending-authorization map exists in this build, so a + /// callback can only be a stale/blind hit. The real function returns the + /// same `Err(String)` shape for an unknown state, and its `core/jsonrpc.rs` + /// caller already renders that as an error page. + pub async fn complete(_config: &Config, _state: &str, _code: &str) -> Result { + Err(DISABLED_MSG.to_string()) + } +} + +// --------------------------------------------------------------------------- +// Live connection map (mirrors `connections`) +// --------------------------------------------------------------------------- + +/// Global in-process registry of connected MCP servers. +pub mod connections { + /// Re-exported from the ungated `types` module — the SAME type the enabled + /// build uses, not a mirrored copy, so the orchestrator prompt builder's + /// field access can never drift between builds. + pub use super::super::types::ConnectedServerOverview; + use super::super::types::McpTool; + + /// Empty: nothing can be connected when the registry is compiled out. + /// + /// Always-on callers already handle this shape with zero `#[cfg]` — the + /// orchestrator prompt skips its "## Connected MCP Servers" block on an + /// empty slice, and the turn loop seeds an empty announced-server set. + pub async fn connected_overview() -> Vec { + Vec::new() + } + + /// Empty: no connected servers means no advertised tools. `tool_registry` + /// folds this into its entry map, which simply gains no `mcp-client::*` + /// entries. + pub async fn all_connected_tools() -> Vec<(String, String, McpTool)> { + Vec::new() + } +} diff --git a/src/openhuman/mcp_registry/types.rs b/src/openhuman/mcp_registry/types.rs index 7aae8a567a..c41de76a86 100644 --- a/src/openhuman/mcp_registry/types.rs +++ b/src/openhuman/mcp_registry/types.rs @@ -170,6 +170,33 @@ pub struct McpTool { pub input_schema: Value, } +// ── ConnectedServerOverview ───────────────────────────────────────────────── + +/// One connected server's identity + advertised tools, for prompt-surface +/// discovery (the orchestrator's "## Connected MCP Servers" block). Sourced +/// entirely from the live connection map — no `Config`, no store read. +/// +/// Lives here (rather than beside the live connection map in [`super::connections`], +/// where it was originally defined) because it is an inert, dependency-free +/// data type consumed by the ALWAYS-COMPILED orchestrator prompt builder. The +/// `mcp` Cargo feature gates `connections` — which owns the tokio/stdio +/// machinery — but this type must survive that gate. Keeping the single +/// definition here and re-exporting it from `connections` means the disabled +/// build reuses the real type instead of a hand-mirrored stub copy, so the +/// struct's fields can never drift between the two builds. +#[derive(Debug, Clone)] +pub struct ConnectedServerOverview { + pub server_id: String, + pub qualified_name: String, + pub display_name: String, + /// Short registry description — the primary capability hint surfaced in + /// the orchestrator prompt (mirrors Composio's per-toolkit description). + pub description: Option, + /// Advertised tools — retained for a tool-count fallback when a server + /// has no description, and for any caller that wants the full list. + pub tools: Vec, +} + // ── ConnStatus ────────────────────────────────────────────────────────────── /// Connection status summary for one installed server. diff --git a/src/openhuman/mcp_server/mod.rs b/src/openhuman/mcp_server/mod.rs index c9a8d89e1c..ed2ae49723 100644 --- a/src/openhuman/mcp_server/mod.rs +++ b/src/openhuman/mcp_server/mod.rs @@ -10,18 +10,58 @@ //! and is advertised to clients via MCP tool annotations //! (`readOnlyHint: false`, `destructiveHint: true`). +//! ## Compile-time gate (`mcp` feature) +//! +//! `pub mod mcp_server;` is ALWAYS compiled — it is a facade. The protocol, +//! transports, session, and dispatch machinery are gated behind the default-ON +//! `mcp` Cargo feature; when it is off, [`stub`] mirrors the surface that +//! always-compiled callers reach (`run_stdio_from_cli`, `ensure_local_http`, +//! `LocalMcpEndpoint`, `tool_specs`) with disabled-error / empty bodies. +//! +//! `tools::types` stays UNGATED so [`McpToolSpec`] — an inert `&'static str` + +//! `Value` record consumed by the always-compiled `tool_registry` — is the +//! same real type in both builds and cannot drift. + +#[cfg(feature = "mcp")] mod http; +#[cfg(feature = "mcp")] mod local; +#[cfg(feature = "mcp")] mod protocol; +#[cfg(feature = "mcp")] mod resources; +#[cfg(feature = "mcp")] mod session; +#[cfg(feature = "mcp")] mod stdio; +#[cfg(feature = "mcp")] mod subagent_depth; -mod tools; +#[cfg(feature = "mcp")] mod write_dispatch; +// Facade: gates its own behavioural submodules but always compiles `types` +// so `McpToolSpec` survives the gate (see the module note above). +mod tools; + +#[cfg(feature = "mcp")] pub use http::{run_http, run_http_reporting, HttpServerConfig}; +#[cfg(feature = "mcp")] pub use local::{ensure_local_http, LocalMcpEndpoint}; +#[cfg(feature = "mcp")] pub use stdio::run_stdio_from_cli; +#[cfg(feature = "mcp")] pub use subagent_depth::{current_depth as current_subagent_depth, HEADER_SUBAGENT_DEPTH}; -pub use tools::{tool_specs, McpToolSpec}; +#[cfg(feature = "mcp")] +pub use tools::tool_specs; + +// Inert tool-spec type — always compiled (see the module note above). +pub use tools::McpToolSpec; + +// --------------------------------------------------------------------------- +// Disabled facade — compiled only when the `mcp` feature is OFF. +// --------------------------------------------------------------------------- + +#[cfg(not(feature = "mcp"))] +mod stub; +#[cfg(not(feature = "mcp"))] +pub use stub::*; diff --git a/src/openhuman/mcp_server/stub.rs b/src/openhuman/mcp_server/stub.rs new file mode 100644 index 0000000000..364ab94787 --- /dev/null +++ b/src/openhuman/mcp_server/stub.rs @@ -0,0 +1,90 @@ +//! Disabled-MCP facade for [`super`] (the OpenHuman-as-an-MCP-server surface). +//! +//! Compiled only when the `mcp` Cargo feature is OFF (see the gate in +//! [`super`]). It mirrors the subset of the real `mcp_server` public surface +//! that always-compiled callers reach, with disabled-error / empty bodies. +//! +//! [`super::tools::types`] is ungated, so [`McpToolSpec`] here is the real +//! type, not a mirrored copy — this module carries behaviour only. +//! +//! The signatures MUST match the real ones exactly (return types and +//! async-ness included). The disabled build +//! (`cargo check --no-default-features --features tokenjuice-treesitter`) is +//! the only thing that catches drift. + +use super::tools::McpToolSpec; + +/// Error text returned by every disabled-path operation that must yield a +/// `Result`. Shared so callers / log-greps see one stable string. +const DISABLED_MSG: &str = "mcp feature disabled at compile time"; + +// --------------------------------------------------------------------------- +// CLI entry point (mirrors `stdio::run_stdio_from_cli`) +// --------------------------------------------------------------------------- + +/// Fails with a build-fact diagnostic instead of serving MCP over stdio. +/// +/// This is deliberately a stub rather than a `#[cfg]` on the `"mcp"` match arm +/// in `src/core/cli.rs`. Deleting the arm is the naive move and is WRONG: the +/// `mcp` token would fall through to generic namespace resolution and die with +/// `unknown namespace: mcp`, 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: +/// +/// * an MCP host (Claude Desktop, Cursor, …) that spawns `openhuman mcp` gets +/// a non-zero exit and a one-line stderr diagnostic naming the fix, instead +/// of hanging forever on an stdout stream that never speaks JSON-RPC; +/// * `cli.rs` needs no `#[cfg]` at all, so 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_stdio_from_cli(_args: &[String]) -> anyhow::Result<()> { + log::warn!( + "[mcp_server] {DISABLED_MSG} — `openhuman mcp` rejected; rebuild with `--features mcp`" + ); + anyhow::bail!( + "{DISABLED_MSG}: this build was compiled without the `mcp` feature, so the MCP stdio \ + server is unavailable. Rebuild with `--features mcp`." + ) +} + +// --------------------------------------------------------------------------- +// In-process local HTTP server (mirrors `local::{ensure_local_http, LocalMcpEndpoint}`) +// --------------------------------------------------------------------------- + +/// Address + bearer token of the in-process MCP HTTP server. +/// +/// Mirrors [`super::local::LocalMcpEndpoint`] (real build). No value of this +/// type is ever constructed here — [`ensure_local_http`] always errors — but +/// the type must stay nameable for call sites that bind its `Ok` variant. +#[derive(Debug, Clone)] +pub struct LocalMcpEndpoint { + pub addr: std::net::SocketAddr, + pub token: String, +} + +/// Always errors: there is no MCP server to stand up in this build. +/// +/// The sole always-on caller (`inference::provider::claude_code::driver`) +/// already handles the `Err` arm by logging "…CC running without OpenHuman MCP +/// tools" and continuing — so Claude Code still runs, just without our tool +/// surface injected. That call site needs no `#[cfg]`. +pub async fn ensure_local_http() -> anyhow::Result { + log::debug!("[mcp_server] {DISABLED_MSG} — local HTTP endpoint not stood up"); + Err(anyhow::anyhow!(DISABLED_MSG)) +} + +// --------------------------------------------------------------------------- +// Tool catalog (mirrors `tools::tool_specs`) +// --------------------------------------------------------------------------- + +/// Empty catalog: this build advertises no tools over MCP. +/// +/// `tool_registry::ops::registry_entries` folds this into a `BTreeMap`, so an +/// empty vec simply means the registry gains no `mcp_stdio`-transport entries. +/// No `#[cfg]` needed at that call site. +pub fn tool_specs() -> Vec { + log::debug!("[mcp_server] {DISABLED_MSG} — advertising empty MCP tool catalog"); + Vec::new() +} diff --git a/src/openhuman/mcp_server/tools/mod.rs b/src/openhuman/mcp_server/tools/mod.rs index da91c54dcc..3377ae216f 100644 --- a/src/openhuman/mcp_server/tools/mod.rs +++ b/src/openhuman/mcp_server/tools/mod.rs @@ -6,31 +6,48 @@ //! - `params` — argument parsing and RPC param construction //! - `dispatch` — `call_tool`, `list_tools_result`, agent/subagent handlers +//! ## Compile-time gate (`mcp` feature) +//! +//! `types` is ALWAYS compiled: [`McpToolSpec`] is inert data (`&'static str` + +//! `Value`, no deps beyond `serde_json`) that the always-compiled +//! `tool_registry` names. The behavioural siblings — which reach into the RPC +//! surface, security policy, and every gated domain — are gated. + +#[cfg(feature = "mcp")] mod dispatch; +#[cfg(feature = "mcp")] mod params; +#[cfg(feature = "mcp")] mod specs; + +// Inert type module — always compiled (see the module note above). mod types; // Public API consumed by the rest of `mcp_server` +#[cfg(feature = "mcp")] pub use dispatch::{call_tool, list_tools_result, tool_error, tool_success}; +#[cfg(feature = "mcp")] pub use specs::tool_specs; -pub use types::{McpToolSpec, ToolCallError}; +#[cfg(feature = "mcp")] +pub use types::ToolCallError; + +pub use types::McpToolSpec; // Re-exports needed by the companion test module via `use super::*`. // Guarded by `#[cfg(test)]` so they do not pollute the production namespace. -#[cfg(test)] +#[cfg(all(test, feature = "mcp"))] pub use crate::core::all; -#[cfg(test)] +#[cfg(all(test, feature = "mcp"))] pub use crate::openhuman::config::rpc as config_rpc; -#[cfg(test)] +#[cfg(all(test, feature = "mcp"))] pub use crate::openhuman::tools::SEARXNG_MAX_RESULTS; -#[cfg(test)] +#[cfg(all(test, feature = "mcp"))] pub use params::{build_rpc_params, slug_from}; -#[cfg(test)] +#[cfg(all(test, feature = "mcp"))] pub use serde_json::{json, Value}; -#[cfg(test)] +#[cfg(all(test, feature = "mcp"))] pub use types::{DEFAULT_LIMIT, MAX_LIMIT, TREE_TAG_MAX_TAGS, TREE_TAG_MAX_TAG_LENGTH}; -#[cfg(test)] +#[cfg(all(test, feature = "mcp"))] #[path = "../tools_tests.rs"] mod tests; diff --git a/src/openhuman/tool_registry/ops_tests.rs b/src/openhuman/tool_registry/ops_tests.rs index be2b96da08..6287a527cd 100644 --- a/src/openhuman/tool_registry/ops_tests.rs +++ b/src/openhuman/tool_registry/ops_tests.rs @@ -8,13 +8,30 @@ use crate::openhuman::config::schema::{ fn registry_entries_include_mcp_and_controller_tools() { let entries = registry_entries(); - let memory_search = entries - .iter() - .find(|entry| entry.tool_id == "memory.search") - .expect("memory.search mcp tool"); - assert_eq!(memory_search.transport, ToolRegistryTransport::McpStdio); - assert_eq!(memory_search.route["method"], json!("tools/call")); - assert_eq!(memory_search.health, ToolRegistryHealth::Available); + // The MCP-transport half of the inventory is sourced from + // `mcp_server::tool_specs()`, which the `mcp` feature compiles out (the + // stub returns an empty catalog). Only this half is gated — the + // controller half below must keep its coverage in BOTH builds. + #[cfg(feature = "mcp")] + { + let memory_search = entries + .iter() + .find(|entry| entry.tool_id == "memory.search") + .expect("memory.search mcp tool"); + assert_eq!(memory_search.transport, ToolRegistryTransport::McpStdio); + assert_eq!(memory_search.route["method"], json!("tools/call")); + assert_eq!(memory_search.health, ToolRegistryHealth::Available); + } + + // With `mcp` compiled out the registry must contain NO MCP-transport + // entries at all — the inventory degrades to controller tools only. + #[cfg(not(feature = "mcp"))] + assert!( + !entries + .iter() + .any(|entry| entry.transport == ToolRegistryTransport::McpStdio), + "no MCP-transport tools may be registered when the `mcp` feature is off" + ); let web_search = entries .iter() @@ -48,7 +65,12 @@ fn diagnostics_reports_inventory_and_policy_surfaces() { assert!(outcome.value.total_tools > 0); assert_eq!(outcome.value.total_tools, outcome.value.enabled_tools); + // MCP-transport tools only exist when the `mcp` feature is compiled in; + // with it off the count is legitimately zero (see `mcp_server::stub`). + #[cfg(feature = "mcp")] assert!(outcome.value.mcp_stdio_tools > 0); + #[cfg(not(feature = "mcp"))] + assert_eq!(outcome.value.mcp_stdio_tools, 0); assert!(outcome.value.json_rpc_tools > 0); assert!(outcome .value @@ -207,8 +229,17 @@ fn insert_registry_entry_skips_duplicate_tool_id() { #[test] fn get_tool_trims_and_returns_exact_entry() { - let outcome = get_tool(" memory.search ").expect("registry lookup"); - assert_eq!(outcome.value.tool_id, "memory.search"); + // `memory.search` is an MCP-transport entry, so it is absent when the `mcp` + // feature is compiled out. The behaviour under test here is id *trimming*, + // not MCP — so fall back to a controller-transport entry rather than gating + // the whole test away and losing that coverage in slim builds. + #[cfg(feature = "mcp")] + let tool_id = "memory.search"; + #[cfg(not(feature = "mcp"))] + let tool_id = "tools.web_search"; + + let outcome = get_tool(&format!(" {tool_id} ")).expect("registry lookup"); + assert_eq!(outcome.value.tool_id, tool_id); } #[test] diff --git a/src/openhuman/tool_registry/schemas.rs b/src/openhuman/tool_registry/schemas.rs index ca90d57505..a8fcae6f09 100644 --- a/src/openhuman/tool_registry/schemas.rs +++ b/src/openhuman/tool_registry/schemas.rs @@ -192,9 +192,19 @@ mod tests { .get("tools") .and_then(Value::as_array) .expect("tools array"); + + // `memory.search` is an MCP-transport entry, absent when the `mcp` + // feature is compiled out. The behaviour under test is that `list` + // returns a populated registry object, so assert against an entry that + // exists in the build at hand rather than gating the test away. + #[cfg(feature = "mcp")] + let expected = "memory.search"; + #[cfg(not(feature = "mcp"))] + let expected = "tools.web_search"; + assert!(tools .iter() - .any(|tool| { tool.get("tool_id").and_then(Value::as_str) == Some("memory.search") })); + .any(|tool| { tool.get("tool_id").and_then(Value::as_str) == Some(expected) })); } #[tokio::test] diff --git a/src/openhuman/tools/impl/network/mod.rs b/src/openhuman/tools/impl/network/mod.rs index 76db69598b..5d89269a4a 100644 --- a/src/openhuman/tools/impl/network/mod.rs +++ b/src/openhuman/tools/impl/network/mod.rs @@ -3,7 +3,13 @@ mod curl; mod gitbooks; mod gmail_unsubscribe; mod http_request; +// Leaf-gated: the only consumers of these two are the `#[cfg(feature = "mcp")]` +// blocks in `tools/ops.rs`, so no stub is needed — nothing names them when the +// feature is off. (`gitbooks` is deliberately NOT gated: it dials `McpHttpClient` +// but is a docs tool, not MCP-subsystem code. See `mcp_client`'s split facade.) +#[cfg(feature = "mcp")] mod mcp; +#[cfg(feature = "mcp")] mod mcp_setup; mod polymarket; mod polymarket_orders; @@ -14,7 +20,9 @@ pub use curl::CurlTool; pub use gitbooks::{GitbooksGetPageTool, GitbooksSearchTool}; pub use gmail_unsubscribe::GmailUnsubscribeTool; pub use http_request::HttpRequestTool; +#[cfg(feature = "mcp")] pub use mcp::{McpCallTool, McpListServersTool, McpListToolsTool}; +#[cfg(feature = "mcp")] pub use mcp_setup::{ McpSetupGetTool, McpSetupInstallAndConnectTool, McpSetupRequestSecretTool, McpSetupSearchTool, McpSetupTestConnectionTool, diff --git a/src/openhuman/tools/mod.rs b/src/openhuman/tools/mod.rs index 994fd344b5..9326370ef5 100644 --- a/src/openhuman/tools/mod.rs +++ b/src/openhuman/tools/mod.rs @@ -35,6 +35,7 @@ pub use crate::openhuman::flows::tools::*; pub use crate::openhuman::health::tools::*; pub use crate::openhuman::integrations::tools::*; pub use crate::openhuman::learning::tools::*; +#[cfg(feature = "mcp")] pub use crate::openhuman::mcp_registry::tools::*; pub use crate::openhuman::memory::tools::*; pub use crate::openhuman::memory_diff::tools::*; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 7696ceac4b..a92b314c80 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -700,16 +700,30 @@ pub fn all_tools_with_runtime( Box::new(ScreenGlobeStopTool), Box::new(ScreenRequestPermissionsTool), Box::new(ScreenRequestPermissionTool), + // MCP registry (dynamic, user-installed servers) — compiled out with + // the `mcp` feature. Per-element attrs inside the `vec![]` mirror the + // voice idiom used earlier in this same literal. + #[cfg(feature = "mcp")] Box::new(McpRegistrySearchTool::new(config.clone())), + #[cfg(feature = "mcp")] Box::new(McpRegistryGetTool::new(config.clone())), + #[cfg(feature = "mcp")] Box::new(McpRegistryInstalledListTool::new(config.clone())), + #[cfg(feature = "mcp")] Box::new(McpRegistryStatusTool::new(config.clone())), + #[cfg(feature = "mcp")] Box::new(McpRegistryListToolsTool), + #[cfg(feature = "mcp")] Box::new(McpRegistryConnectTool::new(config.clone())), + #[cfg(feature = "mcp")] Box::new(McpRegistryDisconnectTool), + #[cfg(feature = "mcp")] Box::new(McpRegistryToolCallTool), + #[cfg(feature = "mcp")] Box::new(McpRegistryConfigAssistTool::new(config.clone())), + #[cfg(feature = "mcp")] Box::new(McpRegistryInstallTool::new(config.clone())), + #[cfg(feature = "mcp")] Box::new(McpRegistryUninstallTool::new(config.clone())), Box::new(WorkspaceReadPersonaTool::new(config.clone())), Box::new(WorkspaceUpdatePersonaTool::new(config.clone())), @@ -899,6 +913,8 @@ pub fn all_tools_with_runtime( // Registered unconditionally — the `mcp_setup` sub-agent filters to just // these via its `[tools] named = [...]` allowlist, and the host agent's // own tool list is wide enough that the extra five entries are negligible. + // Compiled out entirely with the `mcp` feature. + #[cfg(feature = "mcp")] { let cfg = Arc::new(root_config.clone()); tools.push(Box::new(McpSetupSearchTool::new(Arc::clone(&cfg)))); @@ -912,28 +928,36 @@ pub fn all_tools_with_runtime( // Generic remote MCP bridge tools. These let the agent enumerate // named MCP servers and forward `tools/call` through the core // instead of hardcoding one bespoke MCP integration per server. - let mcp_registry = { - let base = crate::openhuman::mcp_client::McpServerRegistry::from_config(root_config); - // Scope the MCP surface to the active profile's allowlist. `None` keeps - // every configured server; `Some(&[])` yields an empty registry. - match mcp_allowlist { - Some(allowed) => Arc::new(base.retaining_servers(allowed)), - None => Arc::new(base), + // + // Backed by the STATIC, config-declared server set (`[[mcp_client.servers]]` + // in TOML) — despite the local binding's name, this is NOT the dynamic + // `mcp_registry` domain gated above. Both are compiled out by the `mcp` + // feature; see the static-vs-dynamic note in AGENTS.md. + #[cfg(feature = "mcp")] + { + let mcp_registry = { + let base = crate::openhuman::mcp_client::McpServerRegistry::from_config(root_config); + // Scope the MCP surface to the active profile's allowlist. `None` keeps + // every configured server; `Some(&[])` yields an empty registry. + match mcp_allowlist { + Some(allowed) => Arc::new(base.retaining_servers(allowed)), + None => Arc::new(base), + } + }; + if !mcp_registry.is_empty() { + tools.push(Box::new(McpListServersTool::new(Arc::clone(&mcp_registry)))); + tools.push(Box::new(McpListToolsTool::new(Arc::clone(&mcp_registry)))); + tools.push(Box::new(McpCallTool::new( + Arc::clone(&mcp_registry), + security.clone(), + ))); + tracing::debug!( + count = mcp_registry.list().len(), + "[mcp_client] registered generic MCP bridge tools" + ); + } else { + tracing::debug!("[mcp_client] no MCP servers registered — bridge tools skipped"); } - }; - if !mcp_registry.is_empty() { - tools.push(Box::new(McpListServersTool::new(Arc::clone(&mcp_registry)))); - tools.push(Box::new(McpListToolsTool::new(Arc::clone(&mcp_registry)))); - tools.push(Box::new(McpCallTool::new( - Arc::clone(&mcp_registry), - security.clone(), - ))); - tracing::debug!( - count = mcp_registry.list().len(), - "[mcp_client] registered generic MCP bridge tools" - ); - } else { - tracing::debug!("[mcp_client] no MCP servers registered — bridge tools skipped"); } tools.extend(crate::openhuman::search::build_search_tools(root_config)); diff --git a/src/openhuman/tools/ops_tests.rs b/src/openhuman/tools/ops_tests.rs index 96d74a2363..bd1b0d7ae2 100644 --- a/src/openhuman/tools/ops_tests.rs +++ b/src/openhuman/tools/ops_tests.rs @@ -332,6 +332,11 @@ fn all_tools_registers_gitbooks_when_enabled() { } #[test] +// Wholly about the static MCP bridge surface, which the `mcp` feature compiles +// out — no meaningful residue to assert in the disabled build (the +// "no MCP tools registered" direction is covered by +// `all_tools_omits_mcp_tools_when_gate_off` below). +#[cfg(feature = "mcp")] fn all_tools_registers_generic_mcp_bridge_tools_when_servers_exist() { let tmp = TempDir::new().unwrap(); let mut cfg = test_config(&tmp); @@ -361,6 +366,50 @@ fn all_tools_registers_generic_mcp_bridge_tools_when_servers_exist() { ); } +/// The disabled direction of the `mcp` gate (#4799): even with MCP servers +/// declared in config, a build without the `mcp` feature registers NO MCP tool +/// of any family — neither the static bridge (`mcp_*`), the dynamic registry +/// (`mcp_registry_*`), nor the setup-agent surface (`mcp_setup_*`). +/// +/// Deliberately asserts by prefix rather than naming the ~19 tools: a new MCP +/// tool added later must not be able to leak into slim builds just because +/// nobody remembered to extend a hardcoded list here. +#[test] +#[cfg(not(feature = "mcp"))] +fn all_tools_omits_mcp_tools_when_gate_off() { + let tmp = TempDir::new().unwrap(); + let mut cfg = test_config(&tmp); + cfg.gitbooks.enabled = false; + cfg.mcp_client + .servers + .push(crate::openhuman::config::McpServerConfig { + name: "docs".into(), + endpoint: "https://example.com/mcp".into(), + command: String::new(), + args: Vec::new(), + env: std::collections::HashMap::new(), + cwd: None, + description: Some("Example docs MCP".into()), + enabled: true, + allowed_tools: Vec::new(), + disallowed_tools: Vec::new(), + timeout_secs: 30, + auth: crate::openhuman::config::McpAuthConfig::None, + }); + + let names = tool_names(&integration_tools_for_config(&tmp, &cfg)); + let leaked: Vec<&String> = names + .iter() + .filter(|n| n.starts_with("mcp_") || n.starts_with("mcp_registry_")) + .collect(); + + assert!( + leaked.is_empty(), + "no MCP tool may be registered when the `mcp` feature is compiled out, \ + even with `[[mcp_client.servers]]` declared in config; leaked: {leaked:?}" + ); +} + #[test] fn all_tools_skips_gitbooks_when_disabled() { let tmp = TempDir::new().unwrap(); @@ -2126,15 +2175,29 @@ const DESKTOP_TOOLS: &[&str] = &[ "screen_intelligence_globe_listener_stop", "screen_intelligence_request_permissions", "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 + // the three tests below away wholesale — the non-MCP desktop tools must + // keep their coverage in both builds. + #[cfg(feature = "mcp")] "mcp_registry_search", + #[cfg(feature = "mcp")] "mcp_registry_get", + #[cfg(feature = "mcp")] "mcp_registry_installed_list", + #[cfg(feature = "mcp")] "mcp_registry_status", + #[cfg(feature = "mcp")] "mcp_registry_connect", + #[cfg(feature = "mcp")] "mcp_registry_disconnect", + #[cfg(feature = "mcp")] "mcp_registry_tool_call", + #[cfg(feature = "mcp")] "mcp_registry_config_assist", + #[cfg(feature = "mcp")] "mcp_registry_install", + #[cfg(feature = "mcp")] "mcp_registry_uninstall", "workspace_read_persona", "workspace_update_persona", @@ -2145,7 +2208,9 @@ const DESKTOP_TOOLS: &[&str] = &[ const DESKTOP_DEFAULT_OFF: &[&str] = &[ "screen_intelligence_request_permissions", "screen_intelligence_request_permission", + #[cfg(feature = "mcp")] "mcp_registry_install", + #[cfg(feature = "mcp")] "mcp_registry_uninstall", "workspace_update_persona", "workspace_reset_persona", @@ -2155,8 +2220,11 @@ const DESKTOP_DEFAULT_OFF: &[&str] = &[ const DESKTOP_ALWAYS_ON: &[&str] = &[ "screen_intelligence_status", "screen_intelligence_capture_now", + #[cfg(feature = "mcp")] "mcp_registry_search", + #[cfg(feature = "mcp")] "mcp_registry_tool_call", + #[cfg(feature = "mcp")] "mcp_registry_connect", "workspace_read_persona", ]; diff --git a/tests/mcp_registry_e2e.rs b/tests/mcp_registry_e2e.rs index 4e8d2dd8de..e5aaa2b228 100644 --- a/tests/mcp_registry_e2e.rs +++ b/tests/mcp_registry_e2e.rs @@ -7,6 +7,12 @@ //! → `connections::disconnect` round-trips correctly through the unified //! `mcp_client::McpStdioClient` transport. +// Exercises the gated `mcp_registry` / `mcp_client` surface, so the whole suite +// is compiled only when the `mcp` feature is on. Without this gate the slim +// build's `cargo test --no-default-features --features tokenjuice-treesitter +// --tests` fails to compile against the removed APIs (#4799). +#![cfg(feature = "mcp")] + use openhuman_core::openhuman::config::Config; use openhuman_core::openhuman::mcp_registry::connections; use openhuman_core::openhuman::mcp_registry::store; diff --git a/tests/mcp_registry_multi_server.rs b/tests/mcp_registry_multi_server.rs index 129e6f4821..5bba039c52 100644 --- a/tests/mcp_registry_multi_server.rs +++ b/tests/mcp_registry_multi_server.rs @@ -12,6 +12,12 @@ //! temp-dir workspace so they do not share SQLite state with each other //! or with `mcp_registry_e2e.rs`. +// Exercises the gated `mcp_registry` surface, so the whole suite is compiled +// only when the `mcp` feature is on — otherwise the slim build's +// `cargo test --no-default-features --features tokenjuice-treesitter --tests` +// fails to compile against the removed APIs (#4799). +#![cfg(feature = "mcp")] + use openhuman_core::openhuman::config::Config; use openhuman_core::openhuman::mcp_registry::types::{CommandKind, InstalledServer, Transport}; use openhuman_core::openhuman::mcp_registry::{connections, ops, store}; diff --git a/tests/mcp_setup_e2e.rs b/tests/mcp_setup_e2e.rs index b2e34f8985..caf4d34427 100644 --- a/tests/mcp_setup_e2e.rs +++ b/tests/mcp_setup_e2e.rs @@ -7,6 +7,13 @@ //! `registry::registry_get`. The transport itself is the same //! `test-mcp-stub` binary used by `mcp_registry_e2e.rs`. +// Exercises the gated `mcp_registry::setup` + `mcp_client` surface, so the +// whole suite is compiled only when the `mcp` feature is on — otherwise the +// slim build's `cargo test --no-default-features --features +// tokenjuice-treesitter --tests` fails to compile against the removed APIs +// (#4799). +#![cfg(feature = "mcp")] + use std::collections::HashMap; use std::time::Duration; diff --git a/tests/mcp_stdio_integration.rs b/tests/mcp_stdio_integration.rs index 684a289757..742bb2800d 100644 --- a/tests/mcp_stdio_integration.rs +++ b/tests/mcp_stdio_integration.rs @@ -4,6 +4,12 @@ //! the test graph and exposes it through `CARGO_BIN_EXE_openhuman-core`. Running //! a nested `cargo build` from a lib unit test is prone to CI disk exhaustion. +// Exercises the gated `mcp_client::McpStdioClient` transport, so the whole +// suite is compiled only when the `mcp` feature is on — otherwise the slim +// build's `cargo test --no-default-features --features tokenjuice-treesitter +// --tests` fails to compile against the removed API (#4799). +#![cfg(feature = "mcp")] + use openhuman_core::openhuman::config::McpClientIdentityConfig; use openhuman_core::openhuman::mcp_client::McpStdioClient; use std::path::PathBuf; From c74fbf0ba67afbb15ac8f1483cb78c4bd3b1dca2 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:10:39 +0530 Subject: [PATCH 56/86] fix(flows): close Composio tool_call tier-gate bypass (C1) (#5000) --- src/openhuman/flows/ops.rs | 65 +++++++++++++++++++-- src/openhuman/flows/ops_tests.rs | 71 +++++++++++++++++++++++ src/openhuman/tinyflows/caps.rs | 89 +++++++++++++++++++++-------- src/openhuman/tinyflows/tests.rs | 7 ++- tests/json_rpc_e2e.rs | 97 ++++++++++++++++++++++++++++++++ 5 files changed, 300 insertions(+), 29 deletions(-) diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index 2075d20722..1ee18d0563 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -366,6 +366,28 @@ pub(crate) fn graph_has_outbound_side_effect(graph: &WorkflowGraph) -> bool { }) } +/// Shared Rule 2 enforcement (issue B29, and its `flows_update` compound-bypass +/// closure): forces `require_approval` to `true` when `graph` contains an +/// outbound side-effect node, no matter what the caller asked for. Used by both +/// [`flows_create`] and [`flows_update`] so a flow can never persist +/// `require_approval: false` alongside a `tool_call` / `http_request` / `code` +/// node — on create OR on a later edit that *adds* such a node to a +/// previously-read-only graph. +/// +/// Returns `(effective_require_approval, was_forced)`: `was_forced` is `true` +/// only when the caller's own toggle was `false` but a side-effect node +/// required the override — callers use it to decide whether to emit the +/// loud "forced to true" log/result note. +pub(crate) fn enforce_side_effect_approval( + graph: &WorkflowGraph, + caller_require_approval: bool, +) -> (bool, bool) { + let has_side_effect = graph_has_outbound_side_effect(graph); + let effective_require_approval = caller_require_approval || has_side_effect; + let was_forced = has_side_effect && !caller_require_approval; + (effective_require_approval, was_forced) +} + /// Whether `graph` has anything for [`flows_run`] to actually *do* — i.e. at /// least one non-`trigger` node **reachable from the trigger** by following /// directed edges. A graph made of nothing but a bare `trigger` node (or a @@ -2178,9 +2200,9 @@ pub async fn flows_create( // Rule 2: any outbound side-effect node forces require_approval, no // matter what the caller asked for. - let has_side_effect = graph_has_outbound_side_effect(&graph); - let effective_require_approval = require_approval || has_side_effect; - if has_side_effect && !require_approval { + let (effective_require_approval, side_effect_forced) = + enforce_side_effect_approval(&graph, require_approval); + if side_effect_forced { tracing::info!( target: "flows", %name, @@ -2218,7 +2240,7 @@ pub async fn flows_create( Enable it explicitly (flows_set_enabled) when you are ready for it to fire." )); } - if effective_require_approval && !require_approval { + if side_effect_forced { logs.push( "require_approval forced to true because the graph contains outbound side-effect \ nodes (tool_call / http_request / code)." @@ -2638,7 +2660,31 @@ pub async fn flows_update( "[flows] flows_update: auto-trigger disarm decision inputs" ); - tracing::debug!(target: "flows", flow_id = %id, has_expected = expected_version.is_some(), "[flows] flows_update: persisting changes"); + // Rule 2 analogue (compound-bypass closure): re-apply the same outbound + // side-effect check `flows_create` applies on save — via the shared + // [`enforce_side_effect_approval`] helper — so an update that *adds* a + // tool_call/http_request/code node to a previously read-only graph can + // never persist `require_approval: false` just because the update path + // trusted the caller's toggle unconditionally. + let (effective_require_approval, side_effect_forced) = + enforce_side_effect_approval(&graph, new_require_approval); + if side_effect_forced { + tracing::info!( + target: "flows", + flow_id = %id, + "[flows] flows_update: forcing require_approval=true — graph contains outbound \ + side-effect node(s) (tool_call / http_request / code)" + ); + } + + tracing::debug!( + target: "flows", + flow_id = %id, + has_expected = expected_version.is_some(), + require_approval = effective_require_approval, + side_effect_forced, + "[flows] flows_update: persisting changes" + ); // `enabled_override` is threaded into the same guarded UPDATE as the // graph/name/require_approval write (see `store::update_flow_graph`) // rather than a follow-up `flows_set_enabled` call, so the disarm can @@ -2648,7 +2694,7 @@ pub async fn flows_update( id, new_name, graph, - new_require_approval, + effective_require_approval, enabled_override, expected_version.as_deref(), ) @@ -2683,6 +2729,13 @@ pub async fn flows_update( .to_string(), ); } + if side_effect_forced { + logs.push( + "require_approval forced to true because the graph contains outbound side-effect \ + nodes (tool_call / http_request / code)." + .to_string(), + ); + } Ok(RpcOutcome::new(updated, logs)) } diff --git a/src/openhuman/flows/ops_tests.rs b/src/openhuman/flows/ops_tests.rs index 09a9e9db5d..2cd76a7081 100644 --- a/src/openhuman/flows/ops_tests.rs +++ b/src/openhuman/flows/ops_tests.rs @@ -4337,6 +4337,77 @@ async fn flows_create_schedule_outbound_creates_disabled_and_approval() { ); } +#[tokio::test] +async fn flows_update_forces_require_approval_when_adding_side_effect_nodes() { + // Compound bypass fix, half 2: `flows_create`'s Rule 2 (force + // require_approval when the graph gains an outbound side-effect node) + // must also re-apply on `flows_update` — a flow that starts read-only and + // is later edited to add a Composio/http_request/code node must not be + // able to keep require_approval=false just because the update path never + // re-checked. + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let created = flows_create(&config, "demo".to_string(), trigger_only_graph(), false) + .await + .unwrap(); + assert!( + !created.value.require_approval, + "a trigger-only graph must not force require_approval on create" + ); + + let updated = flows_update( + &config, + &created.value.id, + None, + Some(tool_call_graph()), + Some(false), + None, + ) + .await + .unwrap(); + + assert!( + updated.value.require_approval, + "flows_update must force require_approval when the replacement graph adds an outbound \ + side-effect node (tool_call), even though the caller passed false" + ); + assert!( + updated + .logs + .iter() + .any(|l| l.contains("require_approval forced to true")), + "flows_update must loudly log the forced require_approval: {:?}", + updated.logs + ); +} + +#[tokio::test] +async fn flows_update_does_not_force_require_approval_on_readonly_graph() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let created = flows_create(&config, "demo".to_string(), trigger_only_graph(), false) + .await + .unwrap(); + assert!(!created.value.require_approval); + + // Name-only update — no graph change, no side-effect nodes. + let updated = flows_update( + &config, + &created.value.id, + Some("renamed".to_string()), + None, + None, + None, + ) + .await + .unwrap(); + + assert!( + !updated.value.require_approval, + "a name-only update to a read-only graph must not force require_approval" + ); +} + // ── graph_has_outbound_side_effect / trigger_is_automatic helper tests ──── #[test] diff --git a/src/openhuman/tinyflows/caps.rs b/src/openhuman/tinyflows/caps.rs index 6e2470b06d..2d38d66bd5 100644 --- a/src/openhuman/tinyflows/caps.rs +++ b/src/openhuman/tinyflows/caps.rs @@ -1616,6 +1616,7 @@ async fn resolve_composio_account( /// // approval when a trigger-driven run's tool/http config contains `=`-exprs. pub struct OpenHumanTools { pub config: Arc, + pub security: Arc, } /// Prefix marking a `tool_call` node's slug as a NATIVE OpenHuman tool (the @@ -2562,18 +2563,13 @@ impl ToolInvoker for OpenHumanTools { )); } - let security = SecurityPolicy::from_config( - &self.config.autonomy, - &self.config.workspace_dir, - &self.config.action_dir, - ); let class = crate::openhuman::runtime_node::ops::classify_tool_call( &self.config, tool_name, &args, ) .map_err(EngineError::Capability)?; - let tier_decision = enforce_node_tier_gate(&security, class, "tool_call")?; + let tier_decision = enforce_node_tier_gate(&self.security, class, "tool_call")?; let summary = crate::openhuman::approval::summarize_action(tool_name, &args); let redacted = crate::openhuman::approval::redact_args(&args); let (outcome, _request_id) = @@ -2601,6 +2597,23 @@ impl ToolInvoker for OpenHumanTools { }); } + // Autonomy-tier gate (Phase 2): a Composio action reaches a + // third-party API over the network, so it is Network-class — same + // class as `http_request`. A read-only run `Block`s here and never + // reaches curation, the preflight, or the approval gate; + // Supervised/Full fall through to `gate_call_for_tier` below. Runs + // BEFORE the curation check (unlike the pre-fix behavior) so a + // read-only tier can never even probe which slugs are curated. + let tier_decision = + enforce_node_tier_gate(&self.security, CommandClass::Network, "tool_call")?; + tracing::debug!( + target: "flows", + %slug, + ?tier_decision, + tier = ?self.security.autonomy, + "[flows] tool_call: composio node tier gate evaluated" + ); + // Curation + scope gate — hard allowlist (see [`is_curated_flow_tool`]'s // doc for why this differs from the general agent tool-call path). // Runs before anything else — a rejected slug never reaches the @@ -2623,22 +2636,24 @@ impl ToolInvoker for OpenHumanTools { // provider or asks the user to approve a call that cannot succeed. preflight_composio_args(&self.config, slug, &args).await?; - // Approval gate (see the struct doc). Mirrors - // `tinyagents/middleware.rs::ApprovalSecurityMiddleware::wrap_tool`'s - // shape exactly: compute summary/redacted args only when a gate is - // installed, deny short-circuits before any composio call, allow - // records an audit id to close out after the call resolves. - let mut audit_id: Option = None; - if let Some(gate) = crate::openhuman::approval::ApprovalGate::try_global() { - let summary = crate::openhuman::approval::summarize_action(slug, &args); - let redacted = crate::openhuman::approval::redact_args(&args); - let (outcome, request_id) = gate.intercept_audited(slug, &summary, redacted).await; - match outcome { - crate::openhuman::approval::GateOutcome::Deny { reason } => { - return Err(EngineError::Capability(reason)); - } - crate::openhuman::approval::GateOutcome::Allow => audit_id = request_id, - } + // Approval gate (see the struct doc). Mirrors `OpenHumanHttp::request`'s + // shape exactly: `gate_call_for_tier` is what actually performs the + // `Prompt` round-trip — it escalates a Supervised `Prompt` decision + // into a forced approval regardless of the flow's own + // `require_approval` toggle (Codex P1), same as the `http_request` and + // `code` node paths. Deny short-circuits before any composio call; + // Allow records an audit id to close out after the call resolves. + let summary = crate::openhuman::approval::summarize_action(slug, &args); + let redacted = crate::openhuman::approval::redact_args(&args); + let (outcome, audit_id) = gate_call_for_tier(tier_decision, slug, &summary, redacted).await; + if let crate::openhuman::approval::GateOutcome::Deny { reason } = outcome { + tracing::warn!( + target: "flows", + %slug, + ?tier_decision, + "[flows] tool_call: approval gate denied before Composio dispatch" + ); + return Err(EngineError::Capability(reason)); } let kind = create_composio_client(&self.config) @@ -3277,6 +3292,7 @@ pub fn build_capabilities(config: Arc, state_namespace: impl Into) -> OpenHumanTools { - OpenHumanTools { config } + let security = Arc::new(SecurityPolicy::from_config( + &config.autonomy, + &config.workspace_dir, + &config.action_dir, + )); + OpenHumanTools { config, security } } #[tokio::test] diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index e374fa9184..a1463bb2e6 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -12862,6 +12862,103 @@ async fn json_rpc_flows_lifecycle_round_trip() { rpc_join.abort(); } +/// JSON-RPC regression for the Rule 2 compound-bypass fix (C1): `flows_update` +/// must re-derive `require_approval` from the *effective* graph, not trust the +/// caller's raw toggle. This is the RPC-layer counterpart to the direct-API +/// tests `flows_update_forces_require_approval_when_adding_side_effect_nodes` +/// / `flows_update_does_not_force_require_approval_on_readonly_graph` in +/// `src/openhuman/flows/ops_tests.rs` — same rule, exercised through the +/// `openhuman.flows_update` controller (schema + handler wiring), not just +/// the `ops::flows_update` fn directly. +#[cfg(feature = "flows")] +#[tokio::test] +async fn json_rpc_flows_update_forces_require_approval_on_side_effect_graph() { + let _env_lock = json_rpc_e2e_env_lock(); + let (rpc_base, _tmp, api_join, rpc_join, _guards) = boot_flows_rpc_env().await; + + // 1. Create a trigger-only (read-only) flow with require_approval: false. + let create = post_json_rpc( + &rpc_base, + 9401, + "openhuman.flows_create", + json!({ + "name": "rpc-rule2-demo", + "graph": { + "name": "trigger-only", + "nodes": [ { "id": "t", "kind": "trigger", "name": "Trigger" } ], + "edges": [] + }, + "require_approval": false + }), + ) + .await; + let flow = peel_logs_envelope(assert_no_jsonrpc_error(&create, "flows_create")); + let flow_id = flow + .get("id") + .and_then(Value::as_str) + .expect("flow id in create result") + .to_string(); + assert_eq!( + flow.get("require_approval").and_then(Value::as_bool), + Some(false), + "a trigger-only graph must not force require_approval on create" + ); + + // 2. Update to a graph with an outbound Composio tool_call node, still + // passing require_approval: false — the RPC handler must force it to + // true rather than trust the caller's toggle. + let update = post_json_rpc( + &rpc_base, + 9402, + "openhuman.flows_update", + json!({ + "id": flow_id, + "graph": { + "name": "with-tool-call", + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Trigger" }, + { + "id": "post", + "kind": "tool_call", + "name": "Post", + "config": { "slug": "SLACK_SEND_MESSAGE", "args": { "channel": "general" } } + } + ], + "edges": [ { "from_node": "t", "to_node": "post" } ] + }, + "require_approval": false + }), + ) + .await; + let updated = peel_logs_envelope(assert_no_jsonrpc_error(&update, "flows_update")); + assert_eq!( + updated.get("require_approval").and_then(Value::as_bool), + Some(true), + "flows_update over JSON-RPC must force require_approval=true when the \ + replacement graph adds an outbound side-effect node, even though the \ + caller passed false" + ); + + // 3. The forced value must also be what's persisted, not just what's + // echoed back in the update response. + let get = post_json_rpc( + &rpc_base, + 9403, + "openhuman.flows_get", + json!({ "id": flow_id }), + ) + .await; + let persisted = peel_logs_envelope(assert_no_jsonrpc_error(&get, "flows_get")); + assert_eq!( + persisted.get("require_approval").and_then(Value::as_bool), + Some(true), + "the forced require_approval must be persisted, not just returned" + ); + + api_join.abort(); + rpc_join.abort(); +} + /// Flow Scout suggestion-lifecycle methods over JSON-RPC (no LLM involved): /// `flows_list_suggestions` starts empty, and `flows_dismiss_suggestion` / /// `flows_mark_suggestion_built` on an unknown id resolve cleanly and report From a56cd261db61827c0f7d6ed7e5fc49caaa20e983 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:04:36 +0530 Subject: [PATCH 57/86] fix(flows): preserve canvas viewport and skip redundant remount on save (#4999) --- .../flows/canvas/EditableFlowCanvas.tsx | 60 ++++++++- .../components/flows/canvas/FlowCanvas.tsx | 21 ++- app/src/pages/FlowCanvasPage.tsx | 96 ++++++++++++-- .../pages/__tests__/FlowCanvasPage.test.tsx | 122 ++++++++++++++++++ 4 files changed, 285 insertions(+), 14 deletions(-) diff --git a/app/src/components/flows/canvas/EditableFlowCanvas.tsx b/app/src/components/flows/canvas/EditableFlowCanvas.tsx index 16ca19c730..05855c4564 100644 --- a/app/src/components/flows/canvas/EditableFlowCanvas.tsx +++ b/app/src/components/flows/canvas/EditableFlowCanvas.tsx @@ -29,6 +29,7 @@ import { type ReactFlowInstance, useEdgesState, useNodesState, + type Viewport, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import createDebug from 'debug'; @@ -186,6 +187,20 @@ export interface EditableFlowCanvasProps { * header (the canvas keeps only undo/redo). Fires whenever any field changes. */ onSaveMetaChange?: (meta: EditorSaveMeta) => void; + /** + * The viewport (pan/zoom) to restore on mount, captured from a previous + * mount of this same logical canvas via `onViewportChange` (F4/F5 fix). The + * host (`FlowCanvasPage`) keeps this in a ref that survives the `canvasVersion` + * remounts Save/Accept/Reject trigger, so a remount can restore the user's + * pan/zoom instead of `fitView` silently resetting it. `null`/absent means no + * prior viewport is known (first-ever mount) — `fitView` runs normally. + */ + savedViewport?: Viewport | null; + /** + * Fired on every viewport change (pan/zoom) so the host can capture the + * latest value for `savedViewport` on the next remount (F4/F5 fix). + */ + onViewportChange?: (viewport: Viewport) => void; } /** Save/Discard state the host header needs to render + gate its own buttons. */ @@ -199,6 +214,20 @@ export interface EditorSaveMeta { export interface EditableFlowCanvasHandle { save: () => void; discard: () => void; + /** + * Clear the forced-dirty flag and advance the baseline to the CURRENT live + * graph, without going through `save()` / `onSave` (the host has already + * persisted the graph itself — see `handleAcceptProposal` in + * `FlowCanvasPage.tsx`). Needed for the Accept path: it calls the page's + * `handleSave` directly (bypassing this canvas's own `save()`, whose ref is + * stale mid-remount) and only remounts a SECOND time when the server + * actually normalized the graph. When it doesn't (the common "echoed back + * unchanged" case), this already-mounted instance's `forcedDirty` — seeded + * `true` by Accept's own remount, before the persist resolved — would + * otherwise never clear, since only this canvas's own `save()`/`discard()` + * do that. Call this after such an out-of-band persist succeeds to sync it. + */ + clearForcedDirty: () => void; } const EMPTY_ID_SET: ReadonlySet = new Set(); @@ -219,6 +248,8 @@ function EditableFlowCanvas( initialDirty = false, showPalette = true, onSaveMetaChange, + savedViewport = null, + onViewportChange, }: EditableFlowCanvasProps, ref: ForwardedRef ) { @@ -227,6 +258,23 @@ function EditableFlowCanvas( const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); const rfRef = useRef | null>(null); + // F4/F5 fix diagnostic: log once per mount whether this instance restores a + // caller-supplied viewport (`savedViewport`, threaded through from + // `FlowCanvasPage`'s `viewportRef`) or falls back to React Flow's own + // `fitView` (first-ever mount, or a host that doesn't track viewport). + useEffect(() => { + log( + 'mount: viewport %s x=%s y=%s zoom=%s', + savedViewport ? 'restored' : 'fitView-fallback', + savedViewport?.x, + savedViewport?.y, + savedViewport?.zoom + ); + // Mount-only — a later `savedViewport` prop change (from panning) must + // not re-log; only a fresh mount (new instance) should. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // ── Undo / redo history ─────────────────────────────────────────────────── // A bounded past/future stack of {nodes, edges} snapshots so structural edits // (add / connect / delete / move) and config edits are recoverable without @@ -598,8 +646,13 @@ function EditableFlowCanvas( if (!dirty || saving) return; handleDiscard(); }, + clearForcedDirty: () => { + log('clearForcedDirty: host persisted externally — syncing baseline'); + setBaseline({ nodes, edges }); + setForcedDirty(false); + }, }), - [dirty, hasErrors, saving, saveDisabled, handleSave, handleDiscard] + [dirty, hasErrors, saving, saveDisabled, handleSave, handleDiscard, nodes, edges] ); // Mirror the Save-button state up so the header can render + gate its buttons. @@ -712,6 +765,7 @@ function EditableFlowCanvas( className="flow-canvas relative h-full w-full" data-testid="flow-canvas" data-editable="true" + data-viewport-restored={savedViewport ? 'true' : 'false'} onDrop={handleDrop} onDragOver={handleDragOver} onKeyDown={handleCanvasKeyDown}> @@ -790,7 +844,9 @@ function EditableFlowCanvas( nodesDraggable nodesConnectable elementsSelectable - fitView + fitView={!savedViewport} + defaultViewport={savedViewport ?? undefined} + onViewportChange={onViewportChange} panOnScroll zoomOnScroll> diff --git a/app/src/components/flows/canvas/FlowCanvas.tsx b/app/src/components/flows/canvas/FlowCanvas.tsx index 47d31029c0..baf645840a 100644 --- a/app/src/components/flows/canvas/FlowCanvas.tsx +++ b/app/src/components/flows/canvas/FlowCanvas.tsx @@ -12,7 +12,14 @@ * The `editable` prop defaults to `false` so every existing read-only consumer * (the `/flows/:id` viewer) keeps its exact behavior — only the builder opts in. */ -import { Background, BackgroundVariant, Controls, MiniMap, ReactFlow } from '@xyflow/react'; +import { + Background, + BackgroundVariant, + Controls, + MiniMap, + ReactFlow, + type Viewport, +} from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import { forwardRef, memo, useMemo } from 'react'; @@ -70,6 +77,14 @@ export interface FlowCanvasProps { showPalette?: boolean; /** Reports Save-button state up so the host header can render Save/Discard. */ onSaveMetaChange?: (meta: EditorSaveMeta) => void; + /** + * The viewport (pan/zoom) to restore on mount (editable only, F4/F5 fix) — + * see `EditableFlowCanvas`'s `savedViewport` doc comment. Not threaded to + * the read-only viewer, which keeps its unconditional `fitView`. + */ + savedViewport?: Viewport | null; + /** Fired on every viewport change (editable only, F4/F5 fix). */ + onViewportChange?: (viewport: Viewport) => void; } const NODE_TYPES = { [FLOW_NODE_TYPE]: FlowNodeComponent }; @@ -130,6 +145,8 @@ const FlowCanvas = forwardRef( initialDirty, showPalette = true, onSaveMetaChange, + savedViewport, + onViewportChange, }: FlowCanvasProps, ref ) => { @@ -150,6 +167,8 @@ const FlowCanvas = forwardRef( initialDirty={initialDirty} showPalette={showPalette} onSaveMetaChange={onSaveMetaChange} + savedViewport={savedViewport} + onViewportChange={onViewportChange} /> ); } diff --git a/app/src/pages/FlowCanvasPage.tsx b/app/src/pages/FlowCanvasPage.tsx index e12d12e8e7..1839901969 100644 --- a/app/src/pages/FlowCanvasPage.tsx +++ b/app/src/pages/FlowCanvasPage.tsx @@ -17,6 +17,7 @@ * mounts a `HashRouter`, so full `useBlocker` interception isn't available — * the Back button is this page's only in-app navigation affordance.) */ +import type { Viewport } from '@xyflow/react'; import createDebug from 'debug'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; @@ -277,6 +278,22 @@ function FlowEditor({ // Save/Discard now live in the page header; the canvas reports its Save state // up and exposes save()/discard() via this handle. const canvasRef = useRef(null); + // F4/F5 fix: the editable canvas is remounted on every Save/Accept/Reject + // (keyed on `canvasVersion` below), which previously wiped BOTH the + // pan/zoom viewport (React Flow's `fitView` refits on every mount) and the + // undo history. This ref lives on `FlowEditor` — keyed on `flow.id`, not + // `canvasVersion` — so it survives those remounts and can seed the fresh + // canvas's `defaultViewport`, skipping `fitView` and preserving pan/zoom + // across a Save. (The undo-history wipe is a known, accepted limitation — + // Save/Accept are commit points, so resetting undo there is semantically + // fine; see the PR description.) + const viewportRef = useRef(null); + const handleViewportChange = useCallback((vp: Viewport) => { + viewportRef.current = vp; + // Grep-friendly, numeric-only (no PII) — fires on every pan/zoom, so kept + // to plain numbers rather than the full `Viewport` object. + log('viewport capture: x=%d y=%d zoom=%d', vp.x, vp.y, vp.zoom); + }, []); const [saveMeta, setSaveMeta] = useState({ dirty: false, hasErrors: false, @@ -429,14 +446,21 @@ function FlowEditor({ // (schema migration, id defaults, port normalization, etc.) before // persisting, so the canonical saved shape can differ from what the client // sent. Re-sync the canvas draft from the RESPONSE (not just the just-sent - // `next`) and bump `canvasVersion` so the editable canvas re-seeds from the - // canonical persisted graph immediately — matching what a navigate-away- - // and-back remount would show, without requiring one. + // `next`) always; only bump `canvasVersion` — remounting the editable canvas + // to re-seed from the canonical persisted graph — when that response + // actually DIFFERS from what was sent (F4/F5 fix: an unconditional bump + // here wiped the canvas's undo history and viewport on every Save even when + // the server echoed the graph back unchanged, and double-remounted on + // Accept, which already bumps once for its own preview→draft transition). // // Declared ahead of `handleAcceptProposal` (below), which calls it directly // to persist an accepted proposal immediately. const handleSave = useCallback( - async (next: WorkflowGraph, overrideName?: string, overrideRequireApproval?: boolean) => { + async ( + next: WorkflowGraph, + overrideName?: string, + overrideRequireApproval?: boolean + ): Promise<{ remounted: 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 @@ -463,7 +487,9 @@ function FlowEditor({ const created = await createFlow(effectiveName, next, effectiveRequireApproval); log('save: draft persisted as flow id=%s', created.id); navigate(`/flows/${created.id}`, { replace: true }); - return; + // 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 }; } // Only include `name` / `requireApproval` in the update payload when // they actually diverge from what's already persisted (a manual @@ -486,6 +512,10 @@ function FlowEditor({ ...(requireApprovalChanged ? { requireApproval: effectiveRequireApproval } : {}), }); const persisted = updated.graph as WorkflowGraph; + // `persistedGraphRef`/`setDraftGraph` run UNCONDITIONALLY regardless of + // whether a remount fires below — the dirty diff (`initialDirty`, B21) + // is computed against `persistedGraphRef`, not the canvas's own mount + // baseline, so it stays correct either way. Only the remount is gated. persistedGraphRef.current = persisted; persistedNameRef.current = updated.name; setDraftGraph(persisted); @@ -496,17 +526,42 @@ function FlowEditor({ setName(updated.name); setTitleDraft(updated.name); } - setCanvasVersion(v => v + 1); + // F4/F5 fix: only remount the canvas (wiping its undo history and, + // pre-Fix-A, its viewport) when the server actually normalized the + // graph into something different from what was sent (issue B21 — schema + // migration, id defaults, port normalization, etc.). When the response + // is a byte-for-byte echo of `next` (the common case), re-seeding from + // it would be a no-op remount — most visibly on Accept, whose own + // preview→draft transition already bumps `canvasVersion` once, so a + // guaranteed-normalized-away Save bump right after it was a pure + // double-remount (undo wipe + dirty flash) with no behavioral upside. + const graphChanged = JSON.stringify(persisted) !== JSON.stringify(next); + if (graphChanged) { + setCanvasVersion(v => v + 1); + } log( - 'save: flow id=%s persisted — canvas re-synced from response nodes=%d edges=%d', + 'save: flow id=%s persisted — canvas re-synced from response nodes=%d edges=%d graphChanged=%s', flowId, persisted.nodes.length, - persisted.edges.length + persisted.edges.length, + graphChanged ); + return { remounted: graphChanged }; }, [isDraft, flowId, name, requireApproval, navigate] ); + // Adapter for the canvas's own `onSave` prop, whose type (`void | + // Promise`) is shared with the read-only viewer and every other + // consumer — `handleSave`'s richer `{ remounted }` return (needed by + // `handleAcceptProposal` below) isn't part of that contract. + const onCanvasSave = useCallback( + async (next: WorkflowGraph) => { + await handleSave(next); + }, + [handleSave] + ); + const handleGraphChange = useCallback( (next: WorkflowGraph) => { // Freeze the draft while a proposal is under review — the preview graph @@ -590,8 +645,25 @@ function FlowEditor({ // `canvasVersion` bump above) so the ref's imperative handle is stale; // call `handleSave` directly with the known-good proposed graph. try { - await handleSave(proposedGraph, overrideName, proposal.requireApproval); - log('copilot proposal accepted: persisted'); + const { remounted } = await handleSave( + proposedGraph, + overrideName, + proposal.requireApproval + ); + // The canvas remounted once already (this handler's own bump above) + // with `forcedDirty` seeded `true` — correct pre-persist, but that + // instance's `forcedDirty` is only ever cleared by ITS OWN save()/ + // discard(), neither of which fires here (we persisted directly, + // above). A second remount (`remounted === true`, server actually + // normalized the graph) reseeds a fresh instance with the now-correct + // `initialDirty`, so nothing else to do. Otherwise (the common + // echoed-back-unchanged case) explicitly sync the still-current + // instance so it doesn't read dirty forever (see + // `EditableFlowCanvasHandle.clearForcedDirty`'s doc comment). + if (!remounted) { + canvasRef.current?.clearForcedDirty(); + } + log('copilot proposal accepted: persisted remounted=%s', remounted); } catch (err) { log('copilot proposal accepted: save failed err=%o', err); // Rethrow: the draft above is already applied unconditionally, so no @@ -881,7 +953,7 @@ function FlowEditor({ nodes={nodes} edges={edges} meta={meta} - onSave={handleSave} + onSave={onCanvasSave} onDirtyChange={setDirty} onSaveMetaChange={setSaveMeta} activeRunId={activeRunId} @@ -891,6 +963,8 @@ function FlowEditor({ saveDisabled={preview !== null} initialDirty={initialDirty} showPalette={sidePanel === 'legend'} + savedViewport={viewportRef.current} + onViewportChange={handleViewportChange} /> {runError && ( diff --git a/app/src/pages/__tests__/FlowCanvasPage.test.tsx b/app/src/pages/__tests__/FlowCanvasPage.test.tsx index b46c3bd8b3..6ef32d83dd 100644 --- a/app/src/pages/__tests__/FlowCanvasPage.test.tsx +++ b/app/src/pages/__tests__/FlowCanvasPage.test.tsx @@ -288,6 +288,128 @@ describe('FlowCanvasPage', () => { expect(screen.getByTestId('flow-canvas-page')).toBeInTheDocument(); }); + // --------------------------------------------------------------------------- + // F4/F5 fix: Save/Accept/Reject bump `canvasVersion`, remounting the editable + // canvas — which previously always reset both the pan/zoom viewport + // (`fitView` refits on every mount) and the undo history. Fix A threads a + // `savedViewport` ref (captured via `onViewportChange`, survives the + // remount) through so a remount can restore pan/zoom instead of losing it; + // `EditableFlowCanvas` exposes `data-viewport-restored` on its root so this + // is observable without reaching into React Flow internals. Fix B stops the + // *redundant* second bump `handleSave` fired on top of Accept's own bump + // whenever the server echoed the graph back unchanged. + // --------------------------------------------------------------------------- + describe('F4/F5: canvas viewport preserved + no redundant remount', () => { + it('reads as no-viewport-restored on the very first mount', async () => { + getFlow.mockResolvedValue(makeFlow()); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + expect(screen.getByTestId('flow-canvas')).toHaveAttribute('data-viewport-restored', 'false'); + }); + + it('restores the captured viewport across a remount triggered by a server-normalized save (B21)', async () => { + getFlow.mockResolvedValue(makeFlow()); + // Same server-normalization shape as the B21 test above: the response + // legitimately differs from what was sent, so Fix B still lets the + // remount-triggering bump through — this test asserts that when a + // remount DOES happen, the captured viewport survives it via Fix A. + updateFlow.mockResolvedValue( + makeFlow({ + graph: { + schema_version: 1, + id: 'test-id', + name: 'Daily digest', + nodes: [ + { + id: 't', + kind: 'trigger', + name: 'Start (normalized)', + config: {}, + ports: [], + position: { x: 0, y: 0 }, + }, + { + id: 'new-agent-0', + kind: 'agent', + name: 'New agent', + config: {}, + ports: [], + position: { x: 80, y: 80 }, + }, + { + id: 'server-added', + kind: 'transform', + name: 'Server-added node', + config: {}, + ports: [], + position: { x: 160, y: 160 }, + }, + ], + edges: [], + }, + }) + ); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + expect(screen.getByTestId('flow-canvas')).toHaveAttribute('data-viewport-restored', 'false'); + + // Pan the canvas — React Flow's `onViewportChange` fires off a real + // wheel event on the pane (panOnScroll is on), which is what the host + // page's `handleViewportChange` captures into the ref that survives the + // upcoming remount. Wait for the pane's own `.react-flow__viewport` + // transform to actually change rather than a fixed sleep (scheduler- + // dependent — React Flow's viewport update isn't synchronous with the + // wheel event) so the test isn't flaky under slow CI runners. + const pane = document.querySelector('.react-flow__pane'); + expect(pane).not.toBeNull(); + const viewportEl = document.querySelector('.react-flow__viewport') as HTMLElement | null; + expect(viewportEl).not.toBeNull(); + const transformBeforePan = viewportEl?.style.transform; + fireEvent.wheel(pane as Element, { deltaY: -50, deltaX: 0, clientX: 200, clientY: 200 }); + await waitFor(() => { + expect(viewportEl?.style.transform).not.toBe(transformBeforePan); + }); + + fireEvent.click(screen.getByTestId('flow-canvas-legend-toggle')); + fireEvent.click(screen.getByTestId('flow-palette-item-agent')); + fireEvent.click(screen.getByTestId('flow-editor-save')); + fireEvent.click(screen.getByTestId('flow-action-confirm-accept')); + await waitFor(() => expect(updateFlow).toHaveBeenCalledTimes(1)); + + // The server-normalized response differs from what was sent, so the + // canvas remounts (3 nodes, matching the B21 behavior) — and the + // freshly mounted canvas reads the captured viewport back. + await waitFor(() => expect(screen.getAllByTestId('flow-node')).toHaveLength(3)); + expect(screen.getByTestId('flow-canvas')).toHaveAttribute('data-viewport-restored', 'true'); + }); + + it('accepting a proposal the server echoes back unchanged does not double-remount (no redundant Save-triggered bump)', async () => { + getFlow.mockResolvedValue(makeFlow({ name: 'Daily digest' })); + const proposal = makeProposal(); + // The server persists the proposed graph verbatim — no normalization — + // so `handleSave`'s own bump must be skipped; only Accept's single bump + // (preview → draft) should remount the canvas. + updateFlow.mockResolvedValue(makeFlow({ name: 'Daily digest', graph: proposal.graph })); + copilotPanelProps.current = null; + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + await waitFor(() => expect(listFlowConnections).toHaveBeenCalledTimes(1)); + + await act(async () => { + await (copilotPanelProps.current?.onAccept as (p: WorkflowProposal) => Promise)( + proposal + ); + }); + + await waitFor(() => expect(updateFlow).toHaveBeenCalledTimes(1)); + // Exactly one extra mount for Accept's own preview→draft bump — Fix B's + // gate on `handleSave`'s bump means the accept-triggered save did NOT + // fire a second one on top of it. + expect(listFlowConnections).toHaveBeenCalledTimes(2); + }); + }); + it('does not prompt when navigating Back with no unsaved changes', async () => { getFlow.mockResolvedValue(makeFlow()); renderEditor(); From 4422254ba36e59c451f5ca9a5459158a436a97dd Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:17:33 +0530 Subject: [PATCH 58/86] fix(tinyplace): handle DM to a non-contact gracefully (Closes #4921) (#4951) --- .../pages/MessagingSection.test.tsx | 61 ++++++++ app/src/agentworld/pages/MessagingSection.tsx | 146 +++++++++++++++--- app/src/lib/i18n/ar.ts | 7 + app/src/lib/i18n/bn.ts | 7 + app/src/lib/i18n/de.ts | 8 + app/src/lib/i18n/en.ts | 8 + app/src/lib/i18n/es.ts | 8 + app/src/lib/i18n/fr.ts | 8 + app/src/lib/i18n/hi.ts | 8 + app/src/lib/i18n/id.ts | 8 + app/src/lib/i18n/it.ts | 8 + app/src/lib/i18n/ko.ts | 8 + app/src/lib/i18n/pl.ts | 8 + app/src/lib/i18n/pt.ts | 8 + app/src/lib/i18n/ru.ts | 8 + app/src/lib/i18n/zh-CN.ts | 6 + 16 files changed, 294 insertions(+), 21 deletions(-) diff --git a/app/src/agentworld/pages/MessagingSection.test.tsx b/app/src/agentworld/pages/MessagingSection.test.tsx index 7be92840a1..cc0de32992 100644 --- a/app/src/agentworld/pages/MessagingSection.test.tsx +++ b/app/src/agentworld/pages/MessagingSection.test.tsx @@ -135,6 +135,17 @@ vi.mock('../AgentWorldShell', () => ({ stop: vi.fn().mockResolvedValue(undefined), list: vi.fn().mockResolvedValue({ streams: [] }), }, + contacts: { + request: vi + .fn() + .mockResolvedValue({ + agentId: 'resolved-crypto-id', + requester: 'test-agent', + status: 'pending', + }), + accept: vi.fn().mockResolvedValue(undefined), + block: vi.fn().mockResolvedValue(undefined), + }, }, })); @@ -304,6 +315,56 @@ describe('DMs panel (E2E enabled)', () => { expect(screen.queryByText(/\/keys\//)).not.toBeInTheDocument(); }); + test('surfaces a friendly not_a_contact message instead of the raw 403 body', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.messages.list).mockResolvedValue({ messages: [] }); + vi.mocked(apiClient.signal.sendMessage).mockRejectedValueOnce( + new Error('CoreRpcError: HTTP 403: HTTP 403: /messages/send: not_a_contact') + ); + + render(); + const peerInput = screen.getByPlaceholderText(/Recipient @handle/); + await user.type(peerInput, 'peer456'); + await user.click(screen.getByRole('button', { name: 'Open DM' })); + + const composeInput = await screen.findByPlaceholderText(/Type a message/); + await user.type(composeInput, 'secret'); + await user.click(screen.getByRole('button', { name: 'Send' })); + + // Friendly, actionable copy — not the raw relay body. + expect(await screen.findByText(/until they're a contact/i)).toBeInTheDocument(); + expect(screen.queryByText(/not_a_contact/)).not.toBeInTheDocument(); + expect(screen.queryByText(/HTTP 403/)).not.toBeInTheDocument(); + // And the actionable affordance is offered. + expect(screen.getByTestId('dm-contact-request')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Send contact request' })).toBeInTheDocument(); + }); + + test('the not_a_contact affordance sends a contact request and confirms it', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.messages.list).mockResolvedValue({ messages: [] }); + vi.mocked(apiClient.signal.sendMessage).mockRejectedValueOnce( + new Error('CoreRpcError: HTTP 403: HTTP 403: /messages/send: not_a_contact') + ); + + render(); + const peerInput = screen.getByPlaceholderText(/Recipient @handle/); + await user.type(peerInput, 'peer456'); + await user.click(screen.getByRole('button', { name: 'Open DM' })); + + const composeInput = await screen.findByPlaceholderText(/Type a message/); + await user.type(composeInput, 'secret'); + await user.click(screen.getByRole('button', { name: 'Send' })); + + const requestButton = await screen.findByRole('button', { name: 'Send contact request' }); + await user.click(requestButton); + + // Requests the edge for the RESOLVED crypto id, then confirms in-place. + expect(vi.mocked(apiClient.contacts.request)).toHaveBeenCalledWith('resolved-crypto-id'); + expect(await screen.findByText(/Contact request sent/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Send contact request' })).not.toBeInTheDocument(); + }); + test('normalizes the core mapped missing encrypted messaging setup error', async () => { const user = userEvent.setup(); vi.mocked(apiClient.messages.list).mockResolvedValue({ messages: [] }); diff --git a/app/src/agentworld/pages/MessagingSection.tsx b/app/src/agentworld/pages/MessagingSection.tsx index 9326dfb80a..e96f1dc87d 100644 --- a/app/src/agentworld/pages/MessagingSection.tsx +++ b/app/src/agentworld/pages/MessagingSection.tsx @@ -10,7 +10,7 @@ * E2E_MESSAGING_ENABLED gate so users can set up keys before 0C ships. */ import debug from 'debug'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import ChipTabs from '../../components/layout/ChipTabs'; import Button from '../../components/ui/Button'; @@ -1018,29 +1018,59 @@ interface DecryptedMessage { outgoing?: boolean; } -function formatDirectMessageError(err: unknown, missingBundleMessage: string): string { - const message = String(err); - const rawBundle404 = /http\s*404/i.test(message) && /\/keys\/[^/\s]+\/bundle\b/i.test(message); +/** + * Kind of DM send/refresh failure, so the UI can render an actionable + * affordance (e.g. a "Send contact request" button) instead of only a message. + * `generic` means we surfaced the raw backend string with no friendly mapping. + */ +type DmErrorKind = 'missing_bundle' | 'not_a_contact' | 'generic'; + +interface DmErrorMessages { + missingBundle: string; + notAContact: string; +} + +/** + * Classify a DM error into a friendly, translated message plus a `kind` the UI + * can branch on. Both the raw-`404 /keys/…/bundle` case and the core-mapped + * "recipient has not set up encrypted messaging" case fold into `missing_bundle`. + * A relay `403 … not_a_contact` (see `map_err` in + * `src/openhuman/tinyplace/ops.rs`) is an *expected* rejection — the peer edge + * has to be accepted first — so we treat it like `wallet-not-configured`: a + * known, actionable state, never a raw error dump. + */ +function classifyDirectMessageError( + err: unknown, + messages: DmErrorMessages +): { message: string; kind: DmErrorKind } { + const raw = String(err); + const rawBundle404 = /http\s*404/i.test(raw) && /\/keys\/[^/\s]+\/bundle\b/i.test(raw); const mappedMissingBundle = - /recipient/i.test(message) && - /not\s+set\s+up/i.test(message) && - /encrypted\s+messaging/i.test(message); + /recipient/i.test(raw) && /not\s+set\s+up/i.test(raw) && /encrypted\s+messaging/i.test(raw); if (rawBundle404 || mappedMissingBundle) { log('[agentworld:dm] recipient key bundle missing'); - return missingBundleMessage; + return { message: messages.missingBundle, kind: 'missing_bundle' }; + } + // Relay surfaces the reason token as `…: not_a_contact`; also tolerate a + // spaced "not a contact" phrasing in case the body is ever humanized. + if (/not[_\s]?a[_\s]?contact/i.test(raw)) { + log('[agentworld:dm] send blocked: recipient is not a contact'); + return { message: messages.notAContact, kind: 'not_a_contact' }; } - return message; + return { message: raw, kind: 'generic' }; } -function useDirectMessages(peerId: string, missingBundleMessage: string) { +function useDirectMessages(peerId: string, errorMessages: DmErrorMessages) { const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [errorKind, setErrorKind] = useState(null); const [sending, setSending] = useState(false); const refresh = useCallback(async () => { setLoading(true); setError(null); + setErrorKind(null); try { const response = await apiClient.messages.list({ limit: 50 }); const fromPeer = response.messages.filter(m => m.from === peerId); @@ -1069,16 +1099,19 @@ function useDirectMessages(peerId: string, missingBundleMessage: string) { return merged.sort((a, b) => a.timestamp.localeCompare(b.timestamp)); }); } catch (err) { - setError(formatDirectMessageError(err, missingBundleMessage)); + const classified = classifyDirectMessageError(err, errorMessages); + setError(classified.message); + setErrorKind(classified.kind); } finally { setLoading(false); } - }, [missingBundleMessage, peerId]); + }, [errorMessages, peerId]); const send = useCallback( async (plaintext: string) => { setSending(true); setError(null); + setErrorKind(null); try { const result = await apiClient.signal.sendMessage({ recipient: peerId, plaintext }); // Optimistically show our own message: the relay won't return it and we @@ -1096,19 +1129,21 @@ function useDirectMessages(peerId: string, missingBundleMessage: string) { ); await refresh(); } catch (err) { - setError(formatDirectMessageError(err, missingBundleMessage)); + const classified = classifyDirectMessageError(err, errorMessages); + setError(classified.message); + setErrorKind(classified.kind); } finally { setSending(false); } }, - [missingBundleMessage, peerId, refresh] + [errorMessages, peerId, refresh] ); useEffect(() => { void refresh(); }, [refresh]); - return { messages, loading, error, sending, send, refresh }; + return { messages, loading, error, errorKind, sending, send, refresh }; } function ActiveDmView({ @@ -1123,15 +1158,50 @@ function ActiveDmView({ setComposeText: (v: string) => void; }) { const { t } = useT(); - const missingBundleMessage = t( - 'agentworld.messaging.missingSignalBundle', - "This user hasn't enabled encrypted messaging yet. Ask them to open Agent World and enable secure DMs before sending a message." + // Memoized so its identity is stable across renders — `useDirectMessages` + // threads it into `refresh`/`send` (and the mount effect), so a fresh object + // every render would retrigger the refresh loop. + const errorMessages = useMemo( + () => ({ + missingBundle: t( + 'agentworld.messaging.missingSignalBundle', + "This user hasn't enabled encrypted messaging yet. Ask them to open Agent World and enable secure DMs before sending a message." + ), + notAContact: t( + 'agentworld.messaging.notAContact', + "You can't message this person until they're a contact. Send a contact request and try again once they accept." + ), + }), + [t] ); - const { messages, loading, error, sending, send } = useDirectMessages( + const { messages, loading, error, errorKind, sending, send } = useDirectMessages( peerId, - missingBundleMessage + errorMessages ); + // Inline "Send contact request" affordance shown when a send is rejected + // because the recipient isn't an accepted contact yet. + const [contactRequest, setContactRequest] = useState<'idle' | 'sending' | 'sent' | 'failed'>( + 'idle' + ); + // Reset the affordance when the error clears or we switch peers, so a stale + // "sent" state never lingers over a fresh conversation. + useEffect(() => { + if (errorKind !== 'not_a_contact') setContactRequest('idle'); + }, [errorKind, peerId]); + const sendContactRequest = useCallback(async () => { + setContactRequest('sending'); + try { + log('[agentworld:dm] sending contact request to recipient'); + await apiClient.contacts.request(peerId); + log('[agentworld:dm] contact request sent'); + setContactRequest('sent'); + } catch (err) { + log('[agentworld:dm] contact request failed: %s', String(err)); + setContactRequest('failed'); + } + }, [peerId]); + const handleSend = useCallback(async () => { if (!composeText.trim()) return; await send(composeText.trim()); @@ -1168,7 +1238,41 @@ function ActiveDmView({ {loading && messages.length === 0 ? (

Loading encrypted messages...

) : null} - {error ?

{error}

: null} + {error ? ( +
+

{error}

+ {errorKind === 'not_a_contact' ? ( +
+ {contactRequest === 'sent' ? ( +

+ {t( + 'agentworld.messaging.contactRequestSent', + 'Contact request sent. You can message them once they accept.' + )} +

+ ) : ( + + )} + {contactRequest === 'failed' ? ( +

+ {t( + 'agentworld.messaging.contactRequestFailed', + "Couldn't send the contact request. Please try again." + )} +

+ ) : null} +
+ ) : null} +
+ ) : null} {!loading && !error && messages.length === 0 ? (
Date: Fri, 17 Jul 2026 14:17:46 +0530 Subject: [PATCH 59/86] fix(tinyplace): paginate the Tiny Place ledger listing (#4952) --- .../agentworld/pages/LedgerSection.test.tsx | 121 ++++++++++++++ app/src/agentworld/pages/LedgerSection.tsx | 149 ++++++++++++++++-- app/src/lib/i18n/ar.ts | 3 + app/src/lib/i18n/bn.ts | 3 + app/src/lib/i18n/de.ts | 4 + app/src/lib/i18n/en.ts | 3 + app/src/lib/i18n/es.ts | 3 + app/src/lib/i18n/fr.ts | 3 + app/src/lib/i18n/hi.ts | 3 + app/src/lib/i18n/id.ts | 3 + app/src/lib/i18n/it.ts | 3 + app/src/lib/i18n/ko.ts | 3 + app/src/lib/i18n/pl.ts | 4 + app/src/lib/i18n/pt.ts | 3 + app/src/lib/i18n/ru.ts | 3 + app/src/lib/i18n/zh-CN.ts | 3 + 16 files changed, 298 insertions(+), 16 deletions(-) diff --git a/app/src/agentworld/pages/LedgerSection.test.tsx b/app/src/agentworld/pages/LedgerSection.test.tsx index 20113a7f52..3ad3249cb7 100644 --- a/app/src/agentworld/pages/LedgerSection.test.tsx +++ b/app/src/agentworld/pages/LedgerSection.test.tsx @@ -17,6 +17,7 @@ import LedgerSection, { abbreviateAddress, formatAmount, formatLedgerAmount, + LEDGER_PAGE_SIZE, StatusBadge, } from './LedgerSection'; @@ -43,6 +44,15 @@ const sampleTransaction: GqlLedgerTransaction = { metadata: { identity: '@test-agent' }, }; +/** Build `n` distinct SALE rows with sequential ids, starting at `start`. */ +function buildPage(n: number, start = 0): Array { + return Array.from({ length: n }, (_, i) => ({ + ...sampleTransaction, + txId: `tx-${String(start + i).padStart(4, '0')}`, + type: 'SALE', + })); +} + beforeEach(() => { vi.clearAllMocks(); vi.mocked(apiClient.graphql.ledgerTransactions).mockResolvedValue({ transactions: [], count: 0 }); @@ -103,6 +113,117 @@ describe('Ledger list', () => { }); }); +// ── Pagination ──────────────────────────────────────────────────────────────── + +describe('Ledger pagination', () => { + test('requests the first page with limit + offset 0', async () => { + vi.mocked(apiClient.graphql.ledgerTransactions).mockResolvedValue({ + transactions: [sampleTransaction], + count: 1, + }); + render(); + await waitFor(() => { + expect(screen.getByText('REGISTRATION')).toBeInTheDocument(); + }); + expect(apiClient.graphql.ledgerTransactions).toHaveBeenNthCalledWith(1, { + limit: LEDGER_PAGE_SIZE, + offset: 0, + }); + }); + + test('hides Load more when the first page is shorter than a full page', async () => { + vi.mocked(apiClient.graphql.ledgerTransactions).mockResolvedValue({ + transactions: buildPage(3), + count: 3, + }); + render(); + await waitFor(() => { + expect(screen.getAllByText('SALE')).toHaveLength(3); + }); + expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument(); + }); + + test('shows Load more when the first page fills the page size', async () => { + vi.mocked(apiClient.graphql.ledgerTransactions).mockResolvedValue({ + transactions: buildPage(LEDGER_PAGE_SIZE), + count: LEDGER_PAGE_SIZE, + }); + render(); + await waitFor(() => { + expect(screen.getByRole('button', { name: /load more/i })).toBeInTheDocument(); + }); + }); + + test('clicking Load more fetches the next offset, appends rows, then stops', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.graphql.ledgerTransactions) + .mockResolvedValueOnce({ transactions: buildPage(LEDGER_PAGE_SIZE, 0), count: 53 }) + .mockResolvedValueOnce({ transactions: buildPage(3, LEDGER_PAGE_SIZE), count: 53 }); + + render(); + await waitFor(() => { + expect(screen.getAllByText('SALE')).toHaveLength(LEDGER_PAGE_SIZE); + }); + + await user.click(screen.getByRole('button', { name: /load more/i })); + + // Second page appended (50 + 3 = 53 rows) and the control disappears because + // the short page signals the ledger is exhausted. + await waitFor(() => { + expect(screen.getAllByText('SALE')).toHaveLength(LEDGER_PAGE_SIZE + 3); + }); + expect(apiClient.graphql.ledgerTransactions).toHaveBeenNthCalledWith(2, { + limit: LEDGER_PAGE_SIZE, + offset: LEDGER_PAGE_SIZE, + }); + expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument(); + }); + + test('deduplicates overlapping rows across pages', async () => { + const user = userEvent.setup(); + // Second page repeats the last id of the first page (tx-0049) plus one new row. + vi.mocked(apiClient.graphql.ledgerTransactions) + .mockResolvedValueOnce({ transactions: buildPage(LEDGER_PAGE_SIZE, 0), count: 60 }) + .mockResolvedValueOnce({ + transactions: buildPage(LEDGER_PAGE_SIZE, LEDGER_PAGE_SIZE - 1), + count: 60, + }); + + render(); + await waitFor(() => { + expect(screen.getAllByText('SALE')).toHaveLength(LEDGER_PAGE_SIZE); + }); + + await user.click(screen.getByRole('button', { name: /load more/i })); + + // 50 initial + 50 returned − 1 overlapping (tx-0049) = 99 unique rows. + await waitFor(() => { + expect(screen.getAllByText('SALE')).toHaveLength(2 * LEDGER_PAGE_SIZE - 1); + }); + }); + + test('keeps rows and surfaces an error when Load more fails', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.graphql.ledgerTransactions) + .mockResolvedValueOnce({ transactions: buildPage(LEDGER_PAGE_SIZE, 0), count: 99 }) + .mockRejectedValueOnce(new Error('network failure')); + + render(); + await waitFor(() => { + expect(screen.getByRole('button', { name: /load more/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /load more/i })); + + // Existing rows stay; an error message appears; the control remains for retry. + await waitFor(() => { + expect(screen.getByText(/could not load more transactions/i)).toBeInTheDocument(); + }); + expect(screen.getAllByText('SALE')).toHaveLength(LEDGER_PAGE_SIZE); + expect(screen.getByRole('button', { name: /load more/i })).toBeInTheDocument(); + }); +}); + // ── StatusBadge ─────────────────────────────────────────────────────────────── describe('StatusBadge colors', () => { diff --git a/app/src/agentworld/pages/LedgerSection.tsx b/app/src/agentworld/pages/LedgerSection.tsx index efb5397a7e..c6e2fdff2c 100644 --- a/app/src/agentworld/pages/LedgerSection.tsx +++ b/app/src/agentworld/pages/LedgerSection.tsx @@ -8,23 +8,50 @@ * * Pattern mirrors FeedSection: useState + useEffect fetch, PanelScaffold * wrapper, StatusBlock for loading/error/empty states. + * + * Pagination: the backend/SDK `ledgerTransactions` call accepts `limit`/`offset` + * (LedgerListParams) and returns a `count`, so the list is fetched a page at a + * time and extended via an offset-based "Load more" control. A page shorter than + * {@link LEDGER_PAGE_SIZE} means the ledger is exhausted (`hasMore=false`). */ -import { useEffect, useState } from 'react'; +import debug from 'debug'; +import { useCallback, useEffect, useRef, useState } from 'react'; import PanelScaffold from '../../components/layout/PanelScaffold'; +import Button from '../../components/ui/Button'; import { type GqlLedgerTransaction } from '../../lib/agentworld/invokeApiClient'; +import { useT } from '../../lib/i18n/I18nContext'; import { apiClient } from '../AgentWorldShell'; import { decimalsForAsset, resolveAssetSymbol } from '../assets'; import { formatUnits, friendlyNetwork } from '../components/X402ConfirmDialog'; import { explorerTxUrl } from '../hooks/useX402Buy'; import { relativeTime } from './relativeTime'; +const log = debug('agentworld:ledger'); + +/** Ledger rows fetched per page (also the initial page size). */ +export const LEDGER_PAGE_SIZE = 50; + // ── State types ─────────────────────────────────────────────────────────────── type LedgerState = | { status: 'loading' } | { status: 'error'; message: string } - | { status: 'ok'; transactions: GqlLedgerTransaction[] }; + | { + status: 'ok'; + transactions: GqlLedgerTransaction[]; + // Server-side cursor, in request units: how many rows to skip on the next + // page. Advances by LEDGER_PAGE_SIZE per fetch, decoupled from the client + // row count so dedupe never desyncs the offset. + nextOffset: number; + // A full page came back, so more rows may exist. + hasMore: boolean; + // A "Load more" fetch is in flight. + loadingMore: boolean; + // Non-null when the most recent "Load more" fetch failed (existing rows + // stay visible; the user can retry). + moreError: string | null; + }; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -298,24 +325,46 @@ function TransactionRow({ // ── LedgerSection (main export) ─────────────────────────────────────────────── export default function LedgerSection() { + const { t } = useT(); const [ledgerState, setLedgerState] = useState({ status: 'loading' }); const [expandedTxId, setExpandedTxId] = useState(null); - // ── Fetch ledger transactions ────────────────────────────────────────────── + // Guards async setState after unmount (the initial useEffect uses its own + // `cancelled` flag; "Load more" fetches outlive no single effect, so they read + // this ref instead). + const mountedRef = useRef(true); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + // ── Fetch first page of ledger transactions ──────────────────────────────── useEffect(() => { let cancelled = false; setLedgerState({ status: 'loading' }); + log('loading first ledger page', { limit: LEDGER_PAGE_SIZE }); - // TODO(phase-2-follow-up): implement pagination with offset or cursor. void apiClient.graphql - .ledgerTransactions({ limit: 50 }) + .ledgerTransactions({ limit: LEDGER_PAGE_SIZE, offset: 0 }) .then(result => { if (cancelled) return; const transactions = Array.isArray(result?.transactions) ? result.transactions : []; - setLedgerState({ status: 'ok', transactions }); + const hasMore = transactions.length >= LEDGER_PAGE_SIZE; + log('loaded first ledger page', { received: transactions.length, hasMore }); + setLedgerState({ + status: 'ok', + transactions, + nextOffset: LEDGER_PAGE_SIZE, + hasMore, + loadingMore: false, + moreError: null, + }); }) .catch((err: unknown) => { if (cancelled) return; + log('first ledger page failed', { error: String(err) }); setLedgerState({ status: 'error', message: String(err) }); }); @@ -324,6 +373,53 @@ export default function LedgerSection() { }; }, []); + // ── Fetch the next page and append it ────────────────────────────────────── + // `offset` is passed in from the rendered 'ok' state so the cursor stays a + // pure function of pages requested. Reentry is prevented by disabling the + // button while `loadingMore` is set. + const loadMore = useCallback((offset: number) => { + log('loading more ledger rows', { offset, limit: LEDGER_PAGE_SIZE }); + setLedgerState(prev => + prev.status === 'ok' ? { ...prev, loadingMore: true, moreError: null } : prev + ); + + void apiClient.graphql + .ledgerTransactions({ limit: LEDGER_PAGE_SIZE, offset }) + .then(result => { + if (!mountedRef.current) return; + const page = Array.isArray(result?.transactions) ? result.transactions : []; + const hasMore = page.length >= LEDGER_PAGE_SIZE; + setLedgerState(prev => { + if (prev.status !== 'ok') return prev; + // Dedupe by txId: if rows shifted between page fetches the overlap must + // not produce duplicate React keys or double-counted entries. + const seen = new Set(prev.transactions.map(tx => tx.txId)); + const fresh = page.filter(tx => !seen.has(tx.txId)); + log('appended ledger rows', { + received: page.length, + fresh: fresh.length, + total: prev.transactions.length + fresh.length, + hasMore, + }); + return { + status: 'ok', + transactions: [...prev.transactions, ...fresh], + nextOffset: offset + LEDGER_PAGE_SIZE, + hasMore, + loadingMore: false, + moreError: null, + }; + }); + }) + .catch((err: unknown) => { + if (!mountedRef.current) return; + log('load more failed', { error: String(err) }); + setLedgerState(prev => + prev.status === 'ok' ? { ...prev, loadingMore: false, moreError: String(err) } : prev + ); + }); + }, []); + // ── Render ───────────────────────────────────────────────────────────────── let body: React.ReactNode; @@ -351,17 +447,38 @@ export default function LedgerSection() { /> ); } else { + const { transactions, hasMore, loadingMore, moreError, nextOffset } = ledgerState; body = ( -
- {ledgerState.transactions.map(tx => ( - setExpandedTxId(prev => (prev === tx.txId ? null : tx.txId))} - /> - ))} -
+ <> +
+ {transactions.map(tx => ( + setExpandedTxId(prev => (prev === tx.txId ? null : tx.txId))} + /> + ))} +
+ + {moreError && ( +

+ {t('agentWorld.ledger.loadMoreError')} +

+ )} + + {hasMore && ( +
+ +
+ )} + ); } diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index c07639795d..c3c55272fd 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -440,6 +440,9 @@ const messages: TranslationMap = { 'agentWorld.world.rooms.outside.description': 'ساحة مفتوحة كبيرة تحيط بها المباني.', 'agentWorld.feed': 'التغذية', 'agentWorld.ledger': 'السجل', + 'agentWorld.ledger.loadMore': 'تحميل المزيد', + 'agentWorld.ledger.loadingMore': 'جارٍ تحميل المزيد…', + 'agentWorld.ledger.loadMoreError': 'تعذّر تحميل المزيد من المعاملات. حاول مرة أخرى.', 'agentWorld.jobs': 'الوظائف', 'agentWorld.bounties': 'مكافآت', 'agentWorld.explore': 'استكشاف', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index a70c9b1231..867299d247 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -456,6 +456,9 @@ const messages: TranslationMap = { 'agentWorld.world.rooms.outside.description': 'ভবনঘেরা বড় খোলা প্লাজা।', 'agentWorld.feed': 'ফিড', 'agentWorld.ledger': 'লেজার', + 'agentWorld.ledger.loadMore': 'আরও লোড করুন', + 'agentWorld.ledger.loadingMore': 'আরও লোড হচ্ছে…', + 'agentWorld.ledger.loadMoreError': 'আরও লেনদেন লোড করা যায়নি। আবার চেষ্টা করুন।', 'agentWorld.jobs': 'চাকরি', 'agentWorld.bounties': 'পুরস্কার', 'agentWorld.explore': 'অন্বেষণ করুন', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 951cd39549..58d708c918 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -480,6 +480,10 @@ const messages: TranslationMap = { 'agentWorld.world.rooms.outside.description': 'Ein großer offener Platz mit Gebäuden ringsum.', 'agentWorld.feed': 'Feed', 'agentWorld.ledger': 'Kontobuch', + 'agentWorld.ledger.loadMore': 'Mehr laden', + 'agentWorld.ledger.loadingMore': 'Wird geladen…', + 'agentWorld.ledger.loadMoreError': + 'Weitere Transaktionen konnten nicht geladen werden. Bitte erneut versuchen.', 'agentWorld.jobs': 'Aufträge', 'agentWorld.bounties': 'Prämien', 'agentWorld.explore': 'Entdecken', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index bd932e3ab6..a44e669419 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -185,6 +185,9 @@ const en: TranslationMap = { 'agentWorld.world.rooms.outside.description': 'A large open plaza ringed with buildings.', 'agentWorld.feed': 'Feed', 'agentWorld.ledger': 'Ledger', + 'agentWorld.ledger.loadMore': 'Load more', + 'agentWorld.ledger.loadingMore': 'Loading more…', + 'agentWorld.ledger.loadMoreError': 'Could not load more transactions. Try again.', 'agentWorld.jobs': 'Jobs', 'agentWorld.bounties': 'Bounties', 'agentWorld.explore': 'Explore', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 578a467733..453fa5c581 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -466,6 +466,9 @@ const messages: TranslationMap = { 'agentWorld.world.rooms.outside.description': 'Una gran plaza abierta rodeada de edificios.', 'agentWorld.feed': 'Noticias', 'agentWorld.ledger': 'Libro mayor', + 'agentWorld.ledger.loadMore': 'Cargar más', + 'agentWorld.ledger.loadingMore': 'Cargando más…', + 'agentWorld.ledger.loadMoreError': 'No se pudieron cargar más transacciones. Inténtalo de nuevo.', 'agentWorld.jobs': 'Trabajos', 'agentWorld.bounties': 'Recompensas', 'agentWorld.explore': 'Explorar', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 21f2645b53..2944fbebfb 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -476,6 +476,9 @@ const messages: TranslationMap = { 'agentWorld.world.rooms.outside.description': 'Une grande place ouverte entourée de bâtiments.', 'agentWorld.feed': 'Fil', 'agentWorld.ledger': 'Grand livre', + 'agentWorld.ledger.loadMore': 'Charger plus', + 'agentWorld.ledger.loadingMore': 'Chargement…', + 'agentWorld.ledger.loadMoreError': 'Impossible de charger plus de transactions. Réessayez.', 'agentWorld.jobs': 'Missions', 'agentWorld.bounties': 'Primes', 'agentWorld.explore': 'Explorer', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 9e0f6e5df0..2d4a3d3b35 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -456,6 +456,9 @@ const messages: TranslationMap = { 'agentWorld.world.rooms.outside.description': 'इमारतों से घिरा बड़ा खुला प्लाजा।', 'agentWorld.feed': 'फ़ीड', 'agentWorld.ledger': 'खाता बही', + 'agentWorld.ledger.loadMore': 'और लोड करें', + 'agentWorld.ledger.loadingMore': 'और लोड हो रहा है…', + 'agentWorld.ledger.loadMoreError': 'अधिक लेन-देन लोड नहीं हो सके। फिर से प्रयास करें।', 'agentWorld.jobs': 'कार्य', 'agentWorld.bounties': 'इनाम', 'agentWorld.explore': 'एक्सप्लोर करें', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 4e6db58797..c84ddbf8b5 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -462,6 +462,9 @@ const messages: TranslationMap = { 'agentWorld.world.rooms.outside.description': 'Plaza terbuka besar yang dikelilingi gedung.', 'agentWorld.feed': 'Feed', 'agentWorld.ledger': 'Buku Besar', + 'agentWorld.ledger.loadMore': 'Muat lebih banyak', + 'agentWorld.ledger.loadingMore': 'Memuat lagi…', + 'agentWorld.ledger.loadMoreError': 'Tidak dapat memuat transaksi lainnya. Coba lagi.', 'agentWorld.jobs': 'Pekerjaan', 'agentWorld.bounties': 'Hadiah', 'agentWorld.explore': 'Jelajahi', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index f1f195218d..406b6eb430 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -469,6 +469,9 @@ const messages: TranslationMap = { 'agentWorld.world.rooms.outside.description': 'Una grande piazza aperta circondata da edifici.', 'agentWorld.feed': 'Feed', 'agentWorld.ledger': 'Registro', + 'agentWorld.ledger.loadMore': 'Carica altro', + 'agentWorld.ledger.loadingMore': 'Caricamento…', + 'agentWorld.ledger.loadMoreError': 'Impossibile caricare altre transazioni. Riprova.', 'agentWorld.jobs': 'Lavori', 'agentWorld.bounties': 'Ricompense', 'agentWorld.explore': 'Esplora', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index f9746c6e93..acb9e186d4 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -449,6 +449,9 @@ const messages: TranslationMap = { 'agentWorld.world.rooms.outside.description': '건물로 둘러싸인 넓은 열린 광장.', 'agentWorld.feed': '피드', 'agentWorld.ledger': '원장', + 'agentWorld.ledger.loadMore': '더 보기', + 'agentWorld.ledger.loadingMore': '더 불러오는 중…', + 'agentWorld.ledger.loadMoreError': '거래를 더 불러오지 못했습니다. 다시 시도하세요.', 'agentWorld.jobs': '채용', 'agentWorld.bounties': '현상금', 'agentWorld.explore': '탐색', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 2c840e5ade..aefd8560e6 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -467,6 +467,10 @@ const messages: TranslationMap = { 'agentWorld.world.rooms.outside.description': 'Duży otwarty plac otoczony budynkami.', 'agentWorld.feed': 'Kanał', 'agentWorld.ledger': 'Księga', + 'agentWorld.ledger.loadMore': 'Załaduj więcej', + 'agentWorld.ledger.loadingMore': 'Ładowanie…', + 'agentWorld.ledger.loadMoreError': + 'Nie udało się załadować kolejnych transakcji. Spróbuj ponownie.', 'agentWorld.jobs': 'Zlecenia', 'agentWorld.bounties': 'Nagrody', 'agentWorld.explore': 'Eksploruj', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index f7973d5d9f..dc84c0bf54 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -461,6 +461,9 @@ const messages: TranslationMap = { 'agentWorld.world.rooms.outside.description': 'Uma grande praça aberta cercada por edifícios.', 'agentWorld.feed': 'Feed', 'agentWorld.ledger': 'Livro-razão', + 'agentWorld.ledger.loadMore': 'Carregar mais', + 'agentWorld.ledger.loadingMore': 'Carregando mais…', + 'agentWorld.ledger.loadMoreError': 'Não foi possível carregar mais transações. Tente novamente.', 'agentWorld.jobs': 'Trabalhos', 'agentWorld.bounties': 'Recompensas', 'agentWorld.explore': 'Explorar', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 381232f9fd..c1d7222ff1 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -461,6 +461,9 @@ const messages: TranslationMap = { 'agentWorld.world.rooms.outside.description': 'Большая открытая площадь, окруженная зданиями.', 'agentWorld.feed': 'Лента', 'agentWorld.ledger': 'Реестр', + 'agentWorld.ledger.loadMore': 'Загрузить ещё', + 'agentWorld.ledger.loadingMore': 'Загрузка…', + 'agentWorld.ledger.loadMoreError': 'Не удалось загрузить больше транзакций. Попробуйте ещё раз.', 'agentWorld.jobs': 'Вакансии', 'agentWorld.bounties': 'Награды', 'agentWorld.explore': 'Исследовать', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 396cbf12c4..337097b899 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -425,6 +425,9 @@ const messages: TranslationMap = { 'agentWorld.world.rooms.outside.description': '一座被建筑环绕的大型开放广场。', 'agentWorld.feed': '动态', 'agentWorld.ledger': '账本', + 'agentWorld.ledger.loadMore': '加载更多', + 'agentWorld.ledger.loadingMore': '正在加载…', + 'agentWorld.ledger.loadMoreError': '无法加载更多交易,请重试。', 'agentWorld.jobs': '工作', 'agentWorld.bounties': '赏金', 'agentWorld.explore': '探索', From 542b7f84c65a80f7c270b9a35c831085bba0cdfe Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:18:01 +0530 Subject: [PATCH 60/86] fix(memory): use stable document_id as sync upsert key (fixes #4947 Bug 2 secret-guard sync failure) (#4953) --- src/openhuman/memory_store/client.rs | 26 +++++++- src/openhuman/memory_store/client_tests.rs | 71 ++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/src/openhuman/memory_store/client.rs b/src/openhuman/memory_store/client.rs index 9cc318f885..8fa0139f11 100644 --- a/src/openhuman/memory_store/client.rs +++ b/src/openhuman/memory_store/client.rs @@ -254,9 +254,33 @@ impl MemoryClient { document_id: Option, ) -> Result<(), String> { let namespace = format!("skill-{}", skill_id.trim()); + // The upsert dedup key must be a stable, opaque identifier — never + // free-form human text. Sync providers pass a `{toolkit}:{id}` + // `document_id` (e.g. `gmail:`); use it as the key so the + // provider's human-readable title (an email subject can legitimately + // contain verification codes, token-rotation notices, or other + // secret-/PII-looking strings) is never run through the + // `upsert_document` secret/PII namespace/key guard. A single such + // subject would otherwise abort the whole non-tolerant provider sync + // with `document namespace/key cannot contain secrets` (see #4947). + // Callers without a stable id (e.g. LinkedIn enrichment) keep the + // title as the key, unchanged. + let stable_id = document_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()); + let key = match stable_id { + Some(id) => id.to_string(), + None => title.to_string(), + }; + tracing::debug!( + namespace = %namespace, + key_from_document_id = stable_id.is_some(), + "[memory_store] store_skill_sync: upserting synchronized document" + ); let input = NamespaceDocumentInput { namespace, - key: title.to_string(), + key, title: title.to_string(), content: content.to_string(), source_type: source_type.unwrap_or_else(|| "doc".to_string()), diff --git a/src/openhuman/memory_store/client_tests.rs b/src/openhuman/memory_store/client_tests.rs index cce95553f6..32c9855e6c 100644 --- a/src/openhuman/memory_store/client_tests.rs +++ b/src/openhuman/memory_store/client_tests.rs @@ -109,6 +109,77 @@ async fn clear_namespace_removes_all_docs_in_namespace() { assert!(docs_arr.is_empty()); } +#[tokio::test] +async fn store_skill_sync_with_secret_like_title_uses_stable_document_id_as_key() { + // Regression for #4947 (Bug 2): a Composio provider sync passes the + // provider's human-readable title as the document title, but the upsert + // *key* must be the stable opaque `document_id` (`gmail:`), + // NOT the title. Some email subjects legitimately look secret-like + // (verification codes, token-rotation notices), and `upsert_document` + // rejects any secret-like namespace/key. Because gmail's tinycortex + // pipeline does not tolerate scope errors, one such subject would abort + // the entire scheduled sync with `document namespace/key cannot contain + // secrets`, leaving the source stale ("Last synced 17d ago"). + let (_tmp, client) = make_client(); + + // A subject that trips the secret guard. Assert the precondition so the + // test cannot silently pass if the detector's patterns change. + let secret_like_title = "Security alert: token glpat-aaaaaaaaaaaaaaaaaaaa was created"; + assert!( + crate::openhuman::memory_store::safety::has_likely_secret(secret_like_title), + "test title must trip the secret detector for this regression to be meaningful" + ); + + // With a stable document_id provided, the write must succeed — the key is + // the opaque id, not the secret-like subject. Before the fix the key was + // the title and this returned the secrets error. + client + .store_skill_sync( + "gmail", + "conn-1", + secret_like_title, + "email body", + Some("composio-provider-incremental".into()), + None, + Some("medium".into()), + None, + None, + Some("gmail:19af23bc00112233".into()), + ) + .await + .expect("secret-like subject must not block a stable-id-keyed sync write"); + + // The document is persisted and keyed by the stable id, so a second sync + // of the same message dedups (updates in place) rather than duplicating. + client + .store_skill_sync( + "gmail", + "conn-1", + secret_like_title, + "email body v2", + Some("composio-provider-incremental".into()), + None, + Some("medium".into()), + None, + None, + Some("gmail:19af23bc00112233".into()), + ) + .await + .expect("re-sync of same message id must succeed"); + + let docs = client.list_documents(Some("skill-gmail")).await.unwrap(); + let arr = docs + .get("documents") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + assert_eq!( + arr.len(), + 1, + "stable-id key must dedupe both syncs into a single document" + ); +} + #[tokio::test] async fn clear_skill_memory_targets_prefixed_namespace() { let (_tmp, client) = make_client(); From 36bf306007ab78e6fcc7cdec52f8661e569bae85 Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:18:14 +0530 Subject: [PATCH 61/86] fix(settings): scope Clear App Data reset to the signed-in user (#4950) (#4954) --- .../permissions/allow-core-process.toml | 11 ++ app/src-tauri/src/local_data_reset.rs | 31 ++++- .../utils/__tests__/clearAllAppData.test.ts | 7 + app/src/utils/__tests__/tauriCommands.test.ts | 14 +- app/src/utils/clearAllAppData.ts | 14 +- app/src/utils/tauriCommands/core.ts | 13 +- src/openhuman/config/ops/loader.rs | 128 ++++++++++++++++++ src/openhuman/config/ops/mod.rs | 7 +- src/openhuman/config/schemas/controllers.rs | 58 +++++++- src/openhuman/config/schemas/schema_defs.rs | 5 +- 10 files changed, 268 insertions(+), 20 deletions(-) diff --git a/app/src-tauri/permissions/allow-core-process.toml b/app/src-tauri/permissions/allow-core-process.toml index 8147cb78c0..055f174032 100644 --- a/app/src-tauri/permissions/allow-core-process.toml +++ b/app/src-tauri/permissions/allow-core-process.toml @@ -22,6 +22,17 @@ allow = [ # found" and the boot gate stalls. "start_core_process", "restart_core_process", + # `reset_local_data` is the "Clear App Data" command (Settings → Account, + # and the Welcome-screen decryption-recovery flow). It stops the embedded + # core, deletes the signed-in user's data dir + active markers, then + # restarts the core. It replaced the old two-step + # `callCoreRpc('config_reset_local_data') + restartCoreProcess()` dance + # (which reached Rust via the ACL-permitted `relay_http_rpc`), but the new + # dedicated command was never added here — so the invoke was rejected with + # "reset_local_data not allowed. Command not found" and Clear App Data + # silently did nothing (#4950). Without this allow entry the ACL denies it + # before it reaches Rust. + "reset_local_data", # `restart_app` triggers `app.restart()` so CEF re-initializes against # the active user's `users//cef` profile after an identity flip # (#900). Without this allow entry, the invoke is silently denied by diff --git a/app/src-tauri/src/local_data_reset.rs b/app/src-tauri/src/local_data_reset.rs index c9d7319035..b11b12dc2a 100644 --- a/app/src-tauri/src/local_data_reset.rs +++ b/app/src-tauri/src/local_data_reset.rs @@ -35,8 +35,12 @@ use crate::reset_reboot_schedule; #[tauri::command] pub async fn reset_local_data( state: tauri::State<'_, core_process::CoreProcessHandle>, + user_id: Option, ) -> Result<(), String> { - log::info!("[core] reset_local_data: command invoked from frontend"); + log::info!( + "[core] reset_local_data: command invoked from frontend (explicit_user_id={})", + user_id.is_some() + ); // ── 1. Ask the core for the paths it would remove ──────────────────── // @@ -44,7 +48,15 @@ pub async fn reset_local_data( // loading, the workspace marker, and the staging-vs-prod default-dir // suffix). Resolve while the core is still up so we don't duplicate // that logic here. - let paths = fetch_data_paths().await?; + // + // `user_id` is forwarded so the core resolves the signed-in user's + // `users/` slice directly. The GUI clear flow signs the user out + // (clearing `active_user.toml`) *before* invoking us, so a marker-based + // resolution would fall back to the pre-login `users/local` dir and delete + // an empty directory — leaving the real user's memory/history intact + // (issue #4950). When absent (pre-login recovery), the core falls back to + // the marker as before. + let paths = fetch_data_paths(user_id).await?; log::info!( "[core] reset_local_data: paths resolved current={} default={} workspace_marker={} user_marker={}", paths.current_openhuman_dir.display(), @@ -279,13 +291,24 @@ fn schedule_reboot_delete_or_describe( } /// Call the core's `config_get_data_paths` RPC and parse the response. -async fn fetch_data_paths() -> Result { +/// +/// When `user_id` is `Some`, the core resolves the target dir from that id +/// (`users/`) rather than the active-user marker — see the note on +/// `reset_local_data` and issue #4950. Passing `null` preserves the legacy +/// marker-based resolution for pre-login recovery. +async fn fetch_data_paths(user_id: Option) -> Result { let url = crate::core_rpc::core_rpc_url_value(); + // Only include `user_id` when present so the pre-login recovery path sends + // the exact same empty `params: {}` it always has (marker-based resolution). + let mut params = serde_json::Map::new(); + if let Some(id) = user_id { + params.insert("user_id".to_string(), serde_json::Value::String(id)); + } let body = serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "openhuman.config_get_data_paths", - "params": {} + "params": serde_json::Value::Object(params), }); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) diff --git a/app/src/utils/__tests__/clearAllAppData.test.ts b/app/src/utils/__tests__/clearAllAppData.test.ts index e82ffc3064..b5fff14fbd 100644 --- a/app/src/utils/__tests__/clearAllAppData.test.ts +++ b/app/src/utils/__tests__/clearAllAppData.test.ts @@ -42,6 +42,10 @@ describe('clearAllAppData', () => { expect(mockPurgeCef).toHaveBeenCalledWith('user-1'); expect(clearSession).toHaveBeenCalledTimes(1); expect(mockReset).toHaveBeenCalledTimes(1); + // #4950: the reset must receive the signed-in user id so it deletes the + // right `users/` slice even though clearSession already removed the + // active-user marker. Passing no id here is the bug that left data behind. + expect(mockReset).toHaveBeenCalledWith('user-1'); expect(mockPurge).toHaveBeenCalledTimes(1); // user-1's own keys are gone expect(window.localStorage.getItem('OPENHUMAN_ACTIVE_USER_ID')).toBeNull(); @@ -63,6 +67,9 @@ describe('clearAllAppData', () => { expect(mockPurgeCef).toHaveBeenCalledWith(null); // No clearSession was provided — call sequence still completes. expect(mockReset).toHaveBeenCalledTimes(1); + // Pre-login recovery (Welcome screen) has no user id: the reset falls back + // to marker-based resolution, so `null` is forwarded. + expect(mockReset).toHaveBeenCalledWith(null); expect(mockRestart).toHaveBeenCalledTimes(1); // Without a userId we have no way to scope, so everything is cleared expect(window.localStorage.getItem('user-1:persist:accounts')).toBeNull(); diff --git a/app/src/utils/__tests__/tauriCommands.test.ts b/app/src/utils/__tests__/tauriCommands.test.ts index 830d0cbfae..e923044227 100644 --- a/app/src/utils/__tests__/tauriCommands.test.ts +++ b/app/src/utils/__tests__/tauriCommands.test.ts @@ -78,7 +78,7 @@ describe('tauriCommands', () => { }); test('resetOpenHumanDataAndRestartCore invokes the destructive Tauri command', async () => { - await resetOpenHumanDataAndRestartCore(); + await resetOpenHumanDataAndRestartCore('user-1'); // The helper used to call `openhuman.config_reset_local_data` over // JSON-RPC followed by `restart_core_process`, but the in-process @@ -88,7 +88,17 @@ describe('tauriCommands', () => { // core) behind a single `reset_local_data` command, so no core RPC // call should reach `callCoreRpc` from this helper. expect(mockCallCoreRpc).not.toHaveBeenCalled(); - expect(mockInvoke).toHaveBeenCalledWith('reset_local_data'); + // #4950: the signed-in user id is forwarded so the core deletes the right + // `users/` slice even after the active-user marker was cleared. + expect(mockInvoke).toHaveBeenCalledWith('reset_local_data', { userId: 'user-1' }); + }); + + test('resetOpenHumanDataAndRestartCore forwards null when no user id is given', async () => { + await resetOpenHumanDataAndRestartCore(); + + // Pre-login recovery has no user id; the core falls back to marker-based + // resolution when `userId` is null. + expect(mockInvoke).toHaveBeenCalledWith('reset_local_data', { userId: null }); }); test('resetOpenHumanDataAndRestartCore surfaces invoke failures to the caller', async () => { diff --git a/app/src/utils/clearAllAppData.ts b/app/src/utils/clearAllAppData.ts index 5027a060d2..527a0f19e8 100644 --- a/app/src/utils/clearAllAppData.ts +++ b/app/src/utils/clearAllAppData.ts @@ -100,10 +100,16 @@ export const clearAllAppData = async ({ } } - // 3. Delete workspace folder + restart core. The core RPC removes both the - // active openhuman_dir and the default `~/.openhuman`, then we restart - // the sidecar so it boots from a clean slate. - await resetOpenHumanDataAndRestartCore(); + // 3. Delete the signed-in user's data dir + restart core. We pass `userId` + // explicitly: step 2's `clearSession()` already ran `auth_clear_session`, + // which removes the `active_user.toml` marker. If the reset resolved its + // target from that (now-absent) marker it would fall back to the pre-login + // `users/local` dir and delete an empty directory — leaving the real + // user's memory, sources, conversations, and history under `users/` + // fully intact. That marker/ordering gap is the root cause of #4950 + // ("Clear App Data does nothing"). Passing the id the caller already holds + // pins the deletion to the correct user regardless of marker state. + await resetOpenHumanDataAndRestartCore(userId); // 4. Purge redux-persist + browser storage. `persistor.purge()` wipes the // persisted backend; `clearUserScopedStorage` removes only the active diff --git a/app/src/utils/tauriCommands/core.ts b/app/src/utils/tauriCommands/core.ts index c82b354f19..e320590e10 100644 --- a/app/src/utils/tauriCommands/core.ts +++ b/app/src/utils/tauriCommands/core.ts @@ -260,7 +260,7 @@ export const installAppUpdate = async (): Promise => { console.debug('[app-update] installAppUpdate: returned (install did not relaunch)'); }; -export async function resetOpenHumanDataAndRestartCore(): Promise { +export async function resetOpenHumanDataAndRestartCore(userId?: string | null): Promise { if (!isTauri()) { console.debug('[core] resetOpenHumanDataAndRestartCore: skipped — not running in Tauri'); return; @@ -273,9 +273,16 @@ export async function resetOpenHumanDataAndRestartCore(): Promise { // tokio task — on Windows that hit `ERROR_SHARING_VIOLATION` (os error // 32) because the core still held SQLite / log / Sentry handles open in // the directory it was trying to delete (OPENHUMAN-TAURI-AF). - console.debug('[core] resetOpenHumanDataAndRestartCore: invoking reset_local_data'); + // Forward the signed-in user's id so the core deletes THAT user's + // `users/` slice. The clear flow signs the user out (clearing + // `active_user.toml`) before this runs, so without the explicit id the core + // would fall back to the pre-login dir and leave the real data behind + // (issue #4950). + console.debug('[core] resetOpenHumanDataAndRestartCore: invoking reset_local_data', { + hasUserId: userId != null, + }); try { - await invoke('reset_local_data'); + await invoke('reset_local_data', { userId: userId ?? null }); } catch (err) { console.error('[core] resetOpenHumanDataAndRestartCore: reset_local_data failed', err); throw err; diff --git a/src/openhuman/config/ops/loader.rs b/src/openhuman/config/ops/loader.rs index ad2e6021e2..b3e45b7767 100644 --- a/src/openhuman/config/ops/loader.rs +++ b/src/openhuman/config/ops/loader.rs @@ -634,6 +634,80 @@ pub async fn get_data_paths() -> Result, String> { )) } +/// Like [`get_data_paths`], but resolves the current data dir directly from an +/// explicit `user_id` (`~/.openhuman/users/`) instead of the +/// active-user marker. +/// +/// Root cause of #4950 ("Clear App Data does nothing"): the GUI clear flow +/// signs the user out *before* it asks the Tauri shell which directory to +/// delete. Signing out (`auth_clear_session`) removes `active_user.toml`, so a +/// marker-based resolution here falls back to the pre-login `users/local` dir — +/// the reset then deletes an empty directory and leaves the signed-in user's +/// memory / conversations / cron / thread history under `users/` fully +/// intact. Passing the id the UI already holds pins the deletion to the correct +/// user regardless of marker state, so the clear actually clears. +/// +/// `user_id` is expected to be non-empty and pre-trimmed (the controller +/// enforces this); an empty id would resolve to the bare `users/` parent, which +/// the caller must never delete. +/// +/// **Security:** `user_id` is caller-controlled (it arrives over `/rpc` and via +/// the Tauri `reset_local_data` command, whose renderer runs untrusted webview +/// content), and the returned `current_openhuman_dir` is handed straight to +/// `remove_dir_all`. An absolute id (`/etc`) or one with `..` / separators would +/// let `Path::join` resolve a delete target OUTSIDE `/users/`. We +/// therefore reject anything that isn't a single plain path segment and, as +/// defense in depth, verify the resolved dir is a direct child of `users/`. +pub async fn get_data_paths_for_user( + user_id: &str, +) -> Result, String> { + if !is_plain_user_id(user_id) { + return Err(format!( + "refusing to resolve data paths for unsafe user id {user_id:?}: must be a single path segment with no separators, `.` or `..`" + )); + } + let default_openhuman_dir = default_openhuman_dir(); + let current_openhuman_dir = + crate::openhuman::config::user_openhuman_dir(&default_openhuman_dir, user_id); + // Defense in depth: the resolved user dir MUST be a direct child of + // `/users`. Catches any platform-specific `join` quirk (e.g. a + // Windows drive-relative id) that slipped past the string check above, + // before the path reaches `remove_dir_all`. + let users_root = default_openhuman_dir.join("users"); + if current_openhuman_dir.parent() != Some(users_root.as_path()) { + return Err(format!( + "refusing to resolve data paths: resolved dir {} is not a direct child of {}", + current_openhuman_dir.display(), + users_root.display() + )); + } + let active_workspace_marker = active_workspace_marker_path(&default_openhuman_dir); + let active_user_marker = + crate::openhuman::config::active_user_marker_path(&default_openhuman_dir); + // Content-free logging only: the user id and the user-scoped paths are PII + // (AGENTS.md: never log secrets/PII), so emit a boolean indicator instead of + // the id or the resolved dirs. The paths are still returned in the JSON + // result below for the caller that actually needs them. + log::debug!("[config] get_data_paths_for_user: explicit_user_id=true"); + Ok(RpcOutcome::new( + json!({ + "current_openhuman_dir": current_openhuman_dir.display().to_string(), + "default_openhuman_dir": default_openhuman_dir.display().to_string(), + "active_workspace_marker_path": active_workspace_marker.display().to_string(), + "active_user_marker_path": active_user_marker.display().to_string(), + }), + vec!["data paths resolved (explicit_user_id=true)".to_string()], + )) +} + +/// True when `user_id` is a single plain path segment safe to join onto the +/// `users/` root: non-empty, not `.`/`..`, and free of path separators or NUL. +/// Rejecting everything else keeps [`get_data_paths_for_user`] (and the +/// `remove_dir_all` it feeds) from escaping `/users/`. +fn is_plain_user_id(user_id: &str) -> bool { + !user_id.is_empty() && user_id != "." && user_id != ".." && !user_id.contains(['/', '\\', '\0']) +} + #[cfg(test)] mod model_registry_seed_tests { use super::*; @@ -699,6 +773,60 @@ mod model_registry_seed_tests { mod loader_io_chain_tests { use super::*; + // Regression for #4950 ("Clear App Data does nothing"). The GUI clear flow + // signs the user out — removing `active_user.toml` — *before* it asks which + // directory to delete, so a marker-based resolution falls back to the + // pre-login `users/local` dir and leaves the real user's data behind. + // `get_data_paths_for_user` must pin `current_openhuman_dir` to the explicit + // id's `users/` slice, independent of any marker/env state. + #[tokio::test] + async fn get_data_paths_for_user_scopes_current_dir_to_explicit_id() { + let outcome = get_data_paths_for_user("clear-me-4950").await.unwrap(); + + let current = outcome + .value + .get("current_openhuman_dir") + .and_then(|v| v.as_str()) + .expect("current_openhuman_dir present"); + // Normalize Windows separators so the suffix check is platform-agnostic. + assert!( + current.replace('\\', "/").ends_with("users/clear-me-4950"), + "current dir must be scoped to the explicit user id, got {current}" + ); + + // Resolution must be genuinely user-scoped, not the shared root — the + // reset must never `remove_dir_all` the root that holds sibling users. + let default = outcome + .value + .get("default_openhuman_dir") + .and_then(|v| v.as_str()) + .expect("default_openhuman_dir present"); + assert_ne!( + current, default, + "current dir must differ from the shared root" + ); + let current_norm = current.replace('\\', "/"); + let default_norm = default.replace('\\', "/"); + assert!( + current_norm.starts_with(default_norm.as_str()), + "current dir ({current}) must live under the shared root ({default})" + ); + } + + // #4950 hardening: `user_id` is caller-controlled (arrives over /rpc and via + // the Tauri reset command) and flows into remove_dir_all, so traversal or + // absolute ids must be rejected outright rather than resolving a delete + // target outside `/users/`. + #[tokio::test] + async fn get_data_paths_for_user_rejects_unsafe_ids() { + for bad in ["..", ".", "../escape", "/etc", "a/b", "a\\b", ""] { + assert!( + get_data_paths_for_user(bad).await.is_err(), + "unsafe user id {bad:?} must be rejected" + ); + } + } + // A directory at the config path is corruption, not a transient/denied read: // the read site fails it fast with distinct wording, and the observability // classifier MUST keep paging it (never demote to ConfigReadIoFailure). This diff --git a/src/openhuman/config/ops/mod.rs b/src/openhuman/config/ops/mod.rs index 1a24b834dc..f66e37e050 100644 --- a/src/openhuman/config/ops/mod.rs +++ b/src/openhuman/config/ops/mod.rs @@ -23,9 +23,10 @@ pub use agent::{ pub use loader::{ agent_server_status, client_config_json, core_rpc_url_from_env, get_config_snapshot, - get_dashboard_settings, get_data_paths, get_runtime_flags, load_and_get_client_config_snapshot, - load_and_get_config_snapshot, load_config_with_timeout, reload_config_snapshot_with_timeout, - reset_local_data, set_browser_allow_all, snapshot_config_json, RuntimeFlagsOut, + get_dashboard_settings, get_data_paths, get_data_paths_for_user, get_runtime_flags, + load_and_get_client_config_snapshot, load_and_get_config_snapshot, load_config_with_timeout, + reload_config_snapshot_with_timeout, reset_local_data, set_browser_allow_all, + snapshot_config_json, RuntimeFlagsOut, }; // expose internal helpers needed by tests (ops_tests.rs uses super::*) #[cfg(test)] diff --git a/src/openhuman/config/schemas/controllers.rs b/src/openhuman/config/schemas/controllers.rs index fcbaacaeab..f02c1150c8 100644 --- a/src/openhuman/config/schemas/controllers.rs +++ b/src/openhuman/config/schemas/controllers.rs @@ -808,10 +808,10 @@ fn handle_reset_local_data(_params: Map) -> ControllerFuture { Box::pin(async { to_json(config_rpc::reset_local_data().await?) }) } -fn handle_get_data_paths(_params: Map) -> ControllerFuture { - Box::pin(async { +fn handle_get_data_paths(params: Map) -> ControllerFuture { + Box::pin(async move { log::debug!("[config][rpc] get_data_paths enter"); - match config_rpc::get_data_paths().await { + match resolve_data_paths(params).await { Ok(outcome) => { log::debug!("[config][rpc] get_data_paths ok"); to_json(outcome) @@ -824,6 +824,31 @@ fn handle_get_data_paths(_params: Map) -> ControllerFuture { }) } +/// Resolve the data paths for `get_data_paths`, honoring an optional `user_id` +/// param. The Clear App Data flow passes the signed-in id (#4950) because it +/// removes the active-user marker *before* the reset resolves paths — without +/// the explicit id the core would fall back to the pre-login `users/local` dir +/// and leave the real user's data behind. Absent/blank → marker-based +/// resolution (the default used by the agent tool and diagnostics). +async fn resolve_data_paths( + params: Map, +) -> Result, String> { + let user_id = params + .get("user_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(|id| id.to_string()); + log::debug!( + "[config][rpc] get_data_paths: explicit_user_id={}", + user_id.is_some() + ); + match user_id.as_deref() { + Some(id) => config_rpc::get_data_paths_for_user(id).await, + None => config_rpc::get_data_paths().await, + } +} + pub(super) fn handle_get_agent_paths(_params: Map) -> ControllerFuture { Box::pin(async { log::debug!("[config][rpc] get_agent_paths enter"); @@ -1089,6 +1114,33 @@ fn handle_update_sandbox_settings(params: Map) -> ControllerFutur mod tests { use super::*; + // ── get_data_paths user scoping (#4950) ───────────────────── + + // The Clear App Data flow passes `user_id` so the reset targets the + // signed-in user's `users/` slice even though the active-user marker + // was already removed by the preceding sign-out. Verify the handler parses + // the param and scopes the resolved current dir accordingly. + #[tokio::test] + async fn handle_get_data_paths_scopes_to_explicit_user_id() { + let mut params = Map::new(); + params.insert( + "user_id".to_string(), + Value::String("clear-me-4950".to_string()), + ); + + let value = handle_get_data_paths(params).await.unwrap(); + // `get_data_paths_for_user` attaches a log, so the outcome is wrapped as + // `{ "result": , "logs": [...] }`. + let current = value + .pointer("/result/current_openhuman_dir") + .and_then(Value::as_str) + .expect("current_openhuman_dir present"); + assert!( + current.replace('\\', "/").ends_with("users/clear-me-4950"), + "current dir must be scoped to the explicit user id, got {current}" + ); + } + // ── platform slug validation (finding #6) ─────────────────── #[test] diff --git a/src/openhuman/config/schemas/schema_defs.rs b/src/openhuman/config/schemas/schema_defs.rs index c3ebb9bd45..8a2f71e61c 100644 --- a/src/openhuman/config/schemas/schema_defs.rs +++ b/src/openhuman/config/schemas/schema_defs.rs @@ -703,7 +703,10 @@ pub fn schemas(function: &str) -> ControllerSchema { function: "get_data_paths", description: "Resolve the OpenHuman data directories (current workspace, default ~/.openhuman, active workspace marker) that reset_local_data would remove. Read-only — performs no filesystem changes.", - inputs: vec![], + inputs: vec![optional_string( + "user_id", + "Resolve paths for this specific user id (users/) instead of the active-user marker. Clear App Data passes this because it signs the user out — removing the marker — before deleting the data.", + )], outputs: vec![json_output( "paths", "Resolved data paths: current_openhuman_dir, default_openhuman_dir, active_workspace_marker_path.", From 1bf9c25c27ea298b9d6d602cd82931393c1efe75 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:11:30 +0530 Subject: [PATCH 62/86] fix(conversations): reset agentic task insights override on turn settle (#5008) --- .../components/ToolTimelineBlock.tsx | 23 +++- .../__tests__/ToolTimelineBlock.test.tsx | 113 +++++++++++++++++- 2 files changed, 129 insertions(+), 7 deletions(-) diff --git a/app/src/features/conversations/components/ToolTimelineBlock.tsx b/app/src/features/conversations/components/ToolTimelineBlock.tsx index b78330de51..8c8d1484cb 100644 --- a/app/src/features/conversations/components/ToolTimelineBlock.tsx +++ b/app/src/features/conversations/components/ToolTimelineBlock.tsx @@ -1,5 +1,5 @@ import createDebug from 'debug'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import WorktreeActions from '../../../components/worktree/WorktreeActions'; import { useT } from '../../../lib/i18n/I18nContext'; @@ -499,6 +499,26 @@ export function ToolTimelineBlock({ // turn), so a plain `useState` already survives every turn it needs to. const [userOverrideOpen, setUserOverrideOpen] = useState(null); + // Whether *any* entry is currently running — computed here (ahead of the + // `entries.length === 0` early return below) purely so the reset effect + // that follows is an unconditional hook call every render; order doesn't + // matter for this existence check, unlike `latestRunningEntryId` further + // down, which needs the seq-sorted order to pick a specific "latest" row. + const isRunning = entries.some(entry => entry.status === 'running'); + + // Reset the user's manual open/close override on the running→settled edge + // (a turn just finished) so the auto-collapse applies to the just-settled + // turn. The override only sticks WITHIN a turn — preventing involuntary + // mid-feedback collapse (#4942) — not permanently across turns. + const prevIsRunningRef = useRef(false); + useEffect(() => { + if (prevIsRunningRef.current && !isRunning) { + log('agent-task-insights: turn settled (running→done), resetting user override'); + setUserOverrideOpen(null); + } + prevIsRunningRef.current = isRunning; + }, [isRunning]); + if (entries.length === 0) return null; // The rows + the parent's streaming response — shared by both the collapsible @@ -513,7 +533,6 @@ export function ToolTimelineBlock({ // but sorts earlier (e.g. seq [2, 0, 1]) gets treated as "latest" and the // wrong step stays expanded/linked in compact chat mode. const latestRunningEntryId = [...ordered].reverse().find(entry => entry.status === 'running')?.id; - const isRunning = latestRunningEntryId != null; const titleLabel = ( diff --git a/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx b/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx index 6743380e65..c8ddb1d6d0 100644 --- a/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx +++ b/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx @@ -396,7 +396,7 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { // earlier. These tests simulate that same "new turn's entries land on an // already-mounted block" shape via `rerender` rather than remounting. describe('agentic task insights — sticky user expand/collapse across turns', () => { - it('keeps an explicit user expand across a new turn that starts and settles', () => { + it('resets a user expand when a new turn settles', () => { const turn1Settled: ToolTimelineEntry[] = [ { id: 't1', name: 'web_search', round: 1, seq: 0, status: 'success' }, ]; @@ -408,7 +408,9 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { fireEvent.click(screen.getByText('Agentic task insights')); expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); - // A new turn/feedback starts streaming onto the SAME mounted block. + // A new turn/feedback starts streaming onto the SAME mounted block. The + // override still wins WHILE it runs — the user's choice isn't clobbered + // mid-turn (#4942). const turn2Running: ToolTimelineEntry[] = [ ...turn1Settled, { id: 't2', name: 'file_read', round: 2, seq: 1, status: 'running' }, @@ -420,8 +422,9 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { ); expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); - // ...and settles. Without the fix this is exactly where it would - // involuntarily re-collapse, wiping out the user's choice. + // ...and settles. The override only sticks WITHIN a turn — once this + // turn finishes, the auto-collapse applies to it, so the panel + // collapses instead of permanently overriding every future turn. const turn2Settled: ToolTimelineEntry[] = [ ...turn1Settled, { id: 't2', name: 'file_read', round: 2, seq: 1, status: 'success' }, @@ -431,7 +434,7 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { ); - expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); }); it('leaves the default open-while-running/collapsed-when-settled behaviour unchanged absent any user interaction', () => { @@ -476,6 +479,106 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { ); expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); }); + + it('auto-collapses when a turn finishes even if the user had expanded it', () => { + const turn1Settled: ToolTimelineEntry[] = [ + { id: 't1', name: 'web_search', round: 1, seq: 0, status: 'success' }, + ]; + const { rerender } = renderInStore(); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + + // User expands the settled turn1 panel. + fireEvent.click(screen.getByText('Agentic task insights')); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + // A new turn starts running — stays open (both the override and the + // auto rule agree here). + const turn2Running: ToolTimelineEntry[] = [ + ...turn1Settled, + { id: 't2', name: 'file_read', round: 2, seq: 1, status: 'running' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + // It settles — the override is cleared on this running→settled edge, + // so the panel auto-collapses instead of staying pinned open forever. + const turn2Settled: ToolTimelineEntry[] = [ + ...turn1Settled, + { id: 't2', name: 'file_read', round: 2, seq: 1, status: 'success' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + }); + + it('does not collapse mid-stream when new entries arrive on a running turn', () => { + const turn1Running: ToolTimelineEntry[] = [ + { id: 't1', name: 'web_search', round: 1, seq: 0, status: 'running' }, + ]; + const { rerender } = renderInStore(); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + // More entries stream in while the turn is still running — the + // running→settled edge never fires, so the panel must stay open. + const turn1StillRunning: ToolTimelineEntry[] = [ + ...turn1Running, + { id: 't1b', name: 'file_read', round: 1, seq: 1, status: 'running' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + const turn1MoreRunning: ToolTimelineEntry[] = [ + { id: 't1', name: 'web_search', round: 1, seq: 0, status: 'success' }, + { id: 't1b', name: 'file_read', round: 1, seq: 1, status: 'running' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + }); + + it('respects a manual expand during an active turn, resets on settle', () => { + const turn1Running: ToolTimelineEntry[] = [ + { id: 't1', name: 'web_search', round: 1, seq: 0, status: 'running' }, + ]; + const { rerender } = renderInStore(); + // Auto-open while running (no override yet). + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + // User explicitly collapses it mid-run... + fireEvent.click(screen.getByText('Agentic task insights')); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + + // ...then explicitly re-expands it — a manual expand during the still- + // active turn — and it must stick while the turn keeps running. + fireEvent.click(screen.getByText('Agentic task insights')); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + // The turn settles — the manual override resets on this edge, and + // since the run is done the auto rule collapses the panel. + const turn1Settled: ToolTimelineEntry[] = [ + { id: 't1', name: 'web_search', round: 1, seq: 0, status: 'success' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + }); }); it('renders the tool result output inside the expanded row', () => { From 988b11741def9d321671157d5b9c549337fe70d6 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:23:18 +0530 Subject: [PATCH 63/86] =?UTF-8?q?fix(flows):=20builder=20chat=20hygiene=20?= =?UTF-8?q?=E2=80=94=20run-tool=20honesty=20(Bld=20#4)=20+=20reply=20hygie?= =?UTF-8?q?ne=20(#5007)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agents/workflow_builder/builder_prompt.rs | 152 +++++++++++++++++- .../flows/agents/workflow_builder/prompt.md | 37 ++++- 2 files changed, 178 insertions(+), 11 deletions(-) diff --git a/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs b/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs index 4321049174..b6a2d0f3b2 100644 --- a/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs +++ b/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs @@ -97,8 +97,10 @@ const DIRECTIVE_PROPOSE: &str = const DIRECTIVE_REVISE: &str = "Revise this tinyflows automation and return the revised proposal. Do not save \ unless I explicitly ask you to (when I do, use save_workflow on the saved flow id), and never enable or \ - disable anything. You may run_flow the SAVED flow to test it, but ONLY if I ask and only after you \ - confirm with me first."; + disable anything. If I ask you to run/test the SAVED flow, follow the run_flow capability rule from \ + your standing instructions: only run_flow it if that tool is on your belt and only after you confirm \ + with me first; if it isn't on your belt, point me to the Run control in the Workflows UI instead of \ + offering."; const DIRECTIVE_BUILD_PROPOSE_ONLY: &str = "Build this tinyflows automation END-TO-END and return the workflow \ proposal. The flow already exists (created blank just now) — design the graph and verify it with \ @@ -135,8 +137,9 @@ pub fn render_prompt(req: &BuilderRequest) -> String { if let Some(flow_id) = req.flow_id.as_deref().filter(|s| !s.is_empty()) { lines.push(String::new()); lines.push(format!( - "This workflow is saved with flow id `{flow_id}` — if I ask you to run/test it, you \ - may run_flow that id, but confirm with me first." + "This workflow is saved with flow id `{flow_id}` — if I ask you to run/test it, follow \ + the run_flow capability rule: only run_flow that id if the tool is on your belt and \ + I've confirmed first; otherwise point me to the Run control in the Workflows UI." )); } lines.push(String::new()); @@ -248,6 +251,46 @@ mod tests { assert!(p.contains("add a Slack step")); } + #[test] + fn revise_run_guidance_is_capability_conditional() { + // Regression: the revise-turn directive (and its per-turn flow_id + // note) used to unconditionally assert "you may run_flow" — + // contradicting the standing prompt's capability check (Bld §4), + // which hides run_flow/resume_flow_run/cancel_flow_run on the + // flows_build path (`FLOWS_BUILD_HIDDEN_TOOLS`). Because the + // per-turn brief is appended AFTER the standing prompt, an + // unconditional per-turn assertion would override the standing + // prompt's capability check and reproduce the offer-then-refuse bug + // the standing-prompt fix was meant to close. Both the mode-level + // directive and the flow_id-specific note must defer to the + // capability rule instead of asserting the tool is available. + let mut r = req(BuildMode::Revise); + r.flow_id = Some("flow_77".into()); + let p = render_prompt(&r); + + assert!( + p.contains("run_flow capability rule"), + "revise directive must defer to the run_flow capability rule rather than \ + assert the tool is available" + ); + assert!( + p.contains("Run control in the Workflows UI"), + "revise directive must point to the Workflows UI Run control as the \ + off-the-belt fallback" + ); + + for banned in [ + "You may run_flow the SAVED flow to test it, but ONLY if I ask", + "may run_flow that id, but confirm with me first.", + ] { + assert!( + !p.contains(banned), + "revise directive must not carry the stale unconditional run_flow \ + phrasing `{banned}`" + ); + } + } + #[test] fn build_is_propose_only_and_injects_flow_id_as_context() { // Regression for #4596: the instant-create build turn must NOT @@ -359,6 +402,81 @@ mod tests { created flows are always born disabled (issue #6)" ); + // Positive (Bld §4): run guidance is capability-conditional. `run_flow` + // (and resume/cancel) are hidden on the `flows_build` path, so the + // prompt must NOT unconditionally claim the builder can run a flow — + // it must first check whether the tool is on its belt and, when it is + // not, point the user to the Workflows UI Run control instead of + // offering-then-refusing (the confusing "want me to run it?" → "I + // don't have access" behavior). + assert!( + STANDING_PROMPT.contains("only if the tool is on your belt") + && STANDING_PROMPT.contains("never offer to run the flow") + && STANDING_PROMPT.contains("Workflows UI"), + "standing prompt must make run_flow capability-conditional: never offer to run \ + when the tool is off the belt, and point the user to the Workflows UI Run \ + control instead (Bld §4 offer-then-refuse)" + ); + + // Negative: the pre-fix heading ("ask first!") asserted run_flow was + // simply a confirm-before-use tool, with no capability check at all — + // it must not reappear (that's the exact offer-then-refuse regression + // Bld §4 closed). + assert!( + !STANDING_PROMPT.contains("`run_flow` (ask first!)"), + "standing prompt must not regress to the pre-Bld-§4 unconditional \ + \"ask first!\" run_flow heading" + ); + + // Positive: the run_flow section must explicitly gate the real-run + // instructions behind the capability check, not just mention the + // check somewhere else in the doc — bind the assertion to the two + // halves of the actual contract (off-belt fallback, on-belt usage). + assert!( + STANDING_PROMPT + .contains("If you do **not** have a `run_flow` tool, never offer to run the flow"), + "standing prompt must state the off-belt fallback as a direct consequence \ + of the capability check, not a generic nearby mention" + ); + assert!( + STANDING_PROMPT + .contains("If you **do** have `run_flow`: once the user has **saved** a flow"), + "standing prompt must gate the on-belt run_flow usage behind the same \ + capability check" + ); + + // Positive (CodeRabbit follow-up on Bld §4): `resume_flow_run` / + // `cancel_flow_run` get the identical capability-conditional + // treatment as `run_flow` — both are hidden alongside it on the + // `flows_build` path (`FLOWS_BUILD_HIDDEN_TOOLS`), so a fix that only + // gated `run_flow` while leaving these two unconditional would + // reopen the same offer-then-refuse bug one hop later. + assert!( + STANDING_PROMPT + .contains("those tools are on your belt** — `resume_flow_run` (approval-gated) or"), + "standing prompt must gate resume_flow_run/cancel_flow_run behind the \ + same on-your-belt capability check as run_flow" + ); + assert!( + STANDING_PROMPT.contains("(if they're not available, point the"), + "standing prompt must state the resume/cancel off-belt fallback condition" + ); + assert!( + STANDING_PROMPT + .contains("user to the runs list in the Workflows UI instead of offering)."), + "standing prompt must point resume/cancel's off-belt fallback to the \ + Workflows UI runs list, matching run_flow's UI fallback pattern" + ); + + // Negative: the pre-fix wording offered resume/cancel unconditionally + // right after `edit_workflow`, with no capability check in between — + // must not reappear. + assert!( + !STANDING_PROMPT.contains("patch with `edit_workflow`; `resume_flow_run`"), + "standing prompt must not regress to the pre-fix unconditional \ + resume_flow_run/cancel_flow_run offer" + ); + // Positive: self-DM resolution — the prompt must teach the builder to // wire "DM me" onto the connection's own `platform_user_id`, not a // public channel (the #general/#team-product fallback bug). @@ -464,6 +582,32 @@ mod tests { } } + /// The standing prompt must teach reply hygiene: no deliberation + /// narration, no draft-then-restate, lead with substance. Without these + /// the reasoning-tier model narrates its chain of thought in the visible + /// reply ("let me think… actually wait… let me reconsider") and restates + /// its questions twice in the same message. (The harness already keeps + /// real reasoning blocks out of the visible text — this is the model + /// choosing to narrate in its output, so a prompt rule is the fix.) + #[test] + fn standing_prompt_teaches_reply_hygiene() { + const STANDING_PROMPT: &str = include_str!("prompt.md"); + + for rule in [ + "finished reply", + "No deliberation narration", + "No draft-then-restate", + "Lead with substance", + ] { + assert!( + STANDING_PROMPT.contains(rule), + "standing prompt must teach the reply-hygiene rule `{rule}` — the \ + reply is the finished answer, not a thinking scratchpad (no \ + deliberation narration, no draft-then-restate)" + ); + } + } + #[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 79076686fc..ff8a6fc30a 100644 --- a/src/openhuman/flows/agents/workflow_builder/prompt.md +++ b/src/openhuman/flows/agents/workflow_builder/prompt.md @@ -71,12 +71,20 @@ that already exists (creating one is `create_workflow`'s job, not auto-disable the flow if the graph's trigger just transitioned from manual to automatic on an already-enabled flow; say so if it happens. -## Testing a saved flow: `run_flow` (ask first!) +## Testing a saved flow: `run_flow` (only if the tool is on your belt) -Once the user has **saved** a flow, you can `run_flow { flow_id }` to test it -end-to-end. Unlike `dry_run_workflow`, this is a **real run** — real effects can -fire (the flow's own approval gate still pauses outbound-action nodes, but treat -it as real). Rules: +**First check whether `run_flow` is in your available tools — on some surfaces it +is not.** If you do **not** have a `run_flow` tool, never offer to run the flow +yourself and never say you'll run it: instead tell the user they can run it +themselves from the **Run** control on the flow in the Workflows UI (or by +triggering it however it's configured). The one thing to avoid is offering to run +it and then saying you can't — if you can't run it, don't offer; point to the Run +control up front. + +If you **do** have `run_flow`: once the user has **saved** a flow, you can +`run_flow { flow_id }` to test it end-to-end. Unlike `dry_run_workflow`, this is a +**real run** — real effects can fire (the flow's own approval gate still pauses +outbound-action nodes, but treat it as real). Rules: 1. **Only a saved flow.** `run_flow` needs a `flow_id`; if the graph isn't saved yet, save it first (`save_workflow` when you have the flow id, @@ -173,8 +181,10 @@ You have a machine-readable belt; use it instead of relying on memory: already connected — prefer those; the proposal's `required_connections` enumerates what still needs linking. - **Debug a run:** `list_flow_runs { flow_id }` → find a failing run; - `get_flow_run` → diagnose it; patch with `edit_workflow`; `resume_flow_run` - (approval-gated) or `cancel_flow_run` to progress/stop a run. `get_flow_history` + `get_flow_run` → diagnose it; patch with `edit_workflow`; and — **only if + those tools are on your belt** — `resume_flow_run` (approval-gated) or + `cancel_flow_run` to progress/stop a run (if they're not available, point the + user to the runs list in the Workflows UI instead of offering). `get_flow_history` → prior graph snapshots. - **Persist (only when the user explicitly asks):** `create_workflow` makes a NEW flow (always born disabled); `duplicate_flow` clones one (disabled) for @@ -601,6 +611,19 @@ needs zero questions is still the happy path. Don't let "ask when truly unsure" turn into "ask about everything": most requests carry enough signal to build immediately. +### Reply hygiene + +Every message you send is the **finished reply**, not a thinking scratchpad. + +- **No deliberation narration.** Never write "let me think", "actually wait", + "let me reconsider", "actually, I have several questions", "hold on", or any + stream-of-consciousness preamble. Decide what to say, then say it. +- **No draft-then-restate.** State your questions or your answer exactly once. + Never write a set of questions and then rewrite the same questions "more + concisely" in the same message. +- **Lead with substance.** Open with the answer, the proposal summary, or the + clarifying question — never with a narration of your own reasoning process. + ### The ask-vs-just-build rule Once `get_tool_contract` hands you a node's `required_args`, sort each one From 5f6f510472d642fa9aff21f495eaa7bf97b476b8 Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:41:13 +0530 Subject: [PATCH 64/86] fix(tinyplace): paginate the Agent World feed (Closes #4923) (#4989) --- app/src/agentworld/pages/FeedSection.test.tsx | 122 ++++++++++++- app/src/agentworld/pages/FeedSection.tsx | 162 ++++++++++++++++-- app/src/lib/i18n/ar.ts | 3 + app/src/lib/i18n/bn.ts | 3 + app/src/lib/i18n/de.ts | 4 + app/src/lib/i18n/en.ts | 3 + app/src/lib/i18n/es.ts | 3 + app/src/lib/i18n/fr.ts | 3 + app/src/lib/i18n/hi.ts | 3 + app/src/lib/i18n/id.ts | 3 + app/src/lib/i18n/it.ts | 3 + app/src/lib/i18n/ko.ts | 3 + app/src/lib/i18n/pl.ts | 3 + app/src/lib/i18n/pt.ts | 3 + app/src/lib/i18n/ru.ts | 3 + app/src/lib/i18n/zh-CN.ts | 3 + 16 files changed, 312 insertions(+), 15 deletions(-) diff --git a/app/src/agentworld/pages/FeedSection.test.tsx b/app/src/agentworld/pages/FeedSection.test.tsx index c07027c6fb..9e8d93a529 100644 --- a/app/src/agentworld/pages/FeedSection.test.tsx +++ b/app/src/agentworld/pages/FeedSection.test.tsx @@ -17,7 +17,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { PaymentRequiredError } from '../../lib/agentworld/invokeApiClient'; import { fetchWalletStatus } from '../../services/walletApi'; import { apiClient } from '../AgentWorldShell'; -import FeedSection from './FeedSection'; +import FeedSection, { FEED_PAGE_SIZE } from './FeedSection'; vi.mock('../AgentWorldShell', () => ({ apiClient: { @@ -739,3 +739,123 @@ describe('delete actions', () => { expect(vi.mocked(apiClient.graphql.post)).toHaveBeenCalledTimes(2); }); }); + +// ── Pagination (offset-based "Load more", #4923) ───────────────────────────── + +/** Build a home-feed page of `n` items with sequential ids from `start`. */ +function buildFeedPage(n: number, start = 0) { + const items = Array.from({ length: n }, (_, i) => { + const idx = start + i; + return { + ...sampleFeedItem, + post: { + ...samplePost, + postId: `post-${String(idx).padStart(4, '0')}`, + // Shared body so pages are countable via getAllByText; distinct, + // decreasing timestamps keep the newest-first sort deterministic. + body: 'PAGEDPOST', + createdAt: new Date(Date.UTC(2026, 0, 1) - idx * 60_000).toISOString(), + }, + }; + }); + return { items, count: 1000 }; +} + +describe('Feed pagination', () => { + test('requests the first page with limit + offset 0', async () => { + vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 }); + render(); + await waitFor(() => { + expect(screen.getByText(samplePost.body)).toBeInTheDocument(); + }); + expect(apiClient.graphql.homeFeed).toHaveBeenNthCalledWith(1, { + limit: FEED_PAGE_SIZE, + offset: 0, + includeSelf: true, + }); + }); + + test('hides Load more when the first page is shorter than a full page', async () => { + vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue(buildFeedPage(3)); + render(); + await waitFor(() => { + expect(screen.getAllByText('PAGEDPOST')).toHaveLength(3); + }); + expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument(); + }); + + test('shows Load more when the first page fills the page size', async () => { + vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue(buildFeedPage(FEED_PAGE_SIZE)); + render(); + await waitFor(() => { + expect(screen.getByRole('button', { name: /load more/i })).toBeInTheDocument(); + }); + }); + + test('clicking Load more fetches the next offset, appends items, then stops', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.graphql.homeFeed) + .mockResolvedValueOnce(buildFeedPage(FEED_PAGE_SIZE, 0)) + .mockResolvedValueOnce(buildFeedPage(3, FEED_PAGE_SIZE)); + + render(); + await waitFor(() => { + expect(screen.getAllByText('PAGEDPOST')).toHaveLength(FEED_PAGE_SIZE); + }); + + await user.click(screen.getByRole('button', { name: /load more/i })); + + // Second page appended (50 + 3 = 53) and the control disappears because the + // short page signals the feed is exhausted. + await waitFor(() => { + expect(screen.getAllByText('PAGEDPOST')).toHaveLength(FEED_PAGE_SIZE + 3); + }); + expect(apiClient.graphql.homeFeed).toHaveBeenNthCalledWith(2, { + limit: FEED_PAGE_SIZE, + offset: FEED_PAGE_SIZE, + includeSelf: true, + }); + expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument(); + }); + + test('deduplicates overlapping items across pages', async () => { + const user = userEvent.setup(); + // Second page repeats the last id of the first page (post-0049) plus new rows. + vi.mocked(apiClient.graphql.homeFeed) + .mockResolvedValueOnce(buildFeedPage(FEED_PAGE_SIZE, 0)) + .mockResolvedValueOnce(buildFeedPage(FEED_PAGE_SIZE, FEED_PAGE_SIZE - 1)); + + render(); + await waitFor(() => { + expect(screen.getAllByText('PAGEDPOST')).toHaveLength(FEED_PAGE_SIZE); + }); + + await user.click(screen.getByRole('button', { name: /load more/i })); + + // 50 initial + 50 returned − 1 overlapping (post-0049) = 99 unique items. + await waitFor(() => { + expect(screen.getAllByText('PAGEDPOST')).toHaveLength(2 * FEED_PAGE_SIZE - 1); + }); + }); + + test('keeps items and surfaces an error when Load more fails', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.graphql.homeFeed) + .mockResolvedValueOnce(buildFeedPage(FEED_PAGE_SIZE, 0)) + .mockRejectedValueOnce(new Error('network failure')); + + render(); + await waitFor(() => { + expect(screen.getByRole('button', { name: /load more/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /load more/i })); + + // Existing items stay; an error message appears; the control remains for retry. + await waitFor(() => { + expect(screen.getByText(/could not load more posts/i)).toBeInTheDocument(); + }); + expect(screen.getAllByText('PAGEDPOST')).toHaveLength(FEED_PAGE_SIZE); + expect(screen.getByRole('button', { name: /load more/i })).toBeInTheDocument(); + }); +}); diff --git a/app/src/agentworld/pages/FeedSection.tsx b/app/src/agentworld/pages/FeedSection.tsx index 36ae93cfee..8afa9ac52a 100644 --- a/app/src/agentworld/pages/FeedSection.tsx +++ b/app/src/agentworld/pages/FeedSection.tsx @@ -25,10 +25,12 @@ import Button from '../../components/ui/Button'; import { type GqlComment, type GqlHomeFeedItem, + type GqlHomeFeedResult, type GqlPost, type LikeResult, PaymentRequiredError, } from '../../lib/agentworld/invokeApiClient'; +import { useT } from '../../lib/i18n/I18nContext'; import { fetchWalletStatus } from '../../services/walletApi'; import { apiClient } from '../AgentWorldShell'; import ConfirmDialog from '../components/ConfirmDialog'; @@ -36,6 +38,15 @@ import { relativeTime } from './relativeTime'; const log = debug('agentworld:feed'); +/** + * Home-feed items fetched per page (also the initial page size). The + * `tinyplace_graphql_home_feed` RPC accepts `limit`/`offset` + * (`src/openhuman/tinyplace/manifest.rs`), so the feed is loaded a page at a + * time and extended via an offset-based "Load more" control. A page shorter + * than this size means the feed is exhausted (`hasMore=false`). + */ +export const FEED_PAGE_SIZE = 50; + // ── State types ─────────────────────────────────────────────────────────────── type FeedState = @@ -43,7 +54,41 @@ type FeedState = | { status: 'wallet_unconfigured' } | { status: 'payment_required'; challenge: unknown } | { status: 'error'; message: string } - | { status: 'ok'; items: GqlHomeFeedItem[] }; + | { + status: 'ok'; + items: GqlHomeFeedItem[]; + // Server-side cursor, in request units: how many rows to skip on the next + // page. Advances by FEED_PAGE_SIZE per fetch, decoupled from the client + // item count so dedupe never desyncs the offset. + nextOffset: number; + // A full page came back, so more items may exist. + hasMore: boolean; + // A "Load more" fetch is in flight. + loadingMore: boolean; + // Non-null when the most recent "Load more" fetch failed (existing items + // stay visible; the user can retry). + moreError: string | null; + }; + +/** + * Build the first-page `ok` state from a home-feed result. Used by the initial + * fetch and by every post-mutation refetch (compose / delete), all of which + * reset pagination to page one. `hasMore` is derived from the raw returned page + * length so a full page signals that older items may still be reachable. + */ +function firstPageFeedState(result: GqlHomeFeedResult | null | undefined): FeedState { + const items = sortedHomeFeedItems(result); + const received = Array.isArray(result?.items) ? result.items.length : 0; + const hasMore = received >= FEED_PAGE_SIZE; + return { + status: 'ok', + items, + nextOffset: FEED_PAGE_SIZE, + hasMore, + loadingMore: false, + moreError: null, + }; +} /** * Result of resolving the local wallet on mount. @@ -588,6 +633,7 @@ function CommentRow({ // ── FeedSection (main export) ───────────────────────────────────────────────── export default function FeedSection() { + const { t } = useT(); const [feedState, setFeedState] = useState({ status: 'loading' }); const [followState, setFollowState] = useState>({}); const [followLoading, setFollowLoading] = useState>({}); @@ -599,6 +645,17 @@ export default function FeedSection() { const { agentId: myAgentId, configured: walletConfigured } = useWalletResolution(); + // Guards async setState after unmount. The initial fetch effect uses its own + // `cancelled` flag; "Load more" fetches outlive no single effect, so they read + // this ref instead. + const mountedRef = useRef(true); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + // ── Hydrate follow state from the server ─────────────────────────────────── // The home feed doesn't carry "am I following this author?", so seed the // follow map from the wallet's actual following list. Without this, the @@ -654,18 +711,24 @@ export default function FeedSection() { // one they just created via the composer) never appear. Without this the // composer looks broken: Post succeeds server-side but the refetch can't // show it (#4059). + log('loading first feed page', { limit: FEED_PAGE_SIZE }); void apiClient.graphql - .homeFeed({ limit: 50, includeSelf: true }) + .homeFeed({ limit: FEED_PAGE_SIZE, offset: 0, includeSelf: true }) .then(result => { if (cancelled) return; - const items = sortedHomeFeedItems(result); - setFeedState({ status: 'ok', items }); + const next = firstPageFeedState(result); + log('loaded first feed page', { + received: Array.isArray(result?.items) ? result.items.length : 0, + hasMore: next.status === 'ok' ? next.hasMore : false, + }); + setFeedState(next); }) .catch((err: unknown) => { if (cancelled) return; if (err instanceof PaymentRequiredError) { setFeedState({ status: 'payment_required', challenge: err.challenge }); } else { + log('first feed page failed', { error: String(err) }); setFeedState({ status: 'error', message: String(err) }); } }); @@ -675,6 +738,54 @@ export default function FeedSection() { }; }, [walletConfigured]); + // ── Fetch the next page and append it ────────────────────────────────────── + // `offset` is passed in from the rendered 'ok' state so the cursor stays a + // pure function of pages requested. Reentry is prevented by disabling the + // button while `loadingMore` is set. + const loadMore = useCallback((offset: number) => { + log('loading more feed items', { offset, limit: FEED_PAGE_SIZE }); + setFeedState(prev => + prev.status === 'ok' ? { ...prev, loadingMore: true, moreError: null } : prev + ); + + void apiClient.graphql + .homeFeed({ limit: FEED_PAGE_SIZE, offset, includeSelf: true }) + .then(result => { + if (!mountedRef.current) return; + const page = Array.isArray(result?.items) ? result.items : []; + const hasMore = page.length >= FEED_PAGE_SIZE; + setFeedState(prev => { + if (prev.status !== 'ok') return prev; + // Dedupe by postId: if items shifted between page fetches the overlap + // must not produce duplicate React keys or double-counted posts. + const seen = new Set(prev.items.map(item => item.post.postId)); + const fresh = page.filter(item => !seen.has(item.post.postId)); + const merged = sortedHomeFeedItems({ items: [...prev.items, ...fresh] }); + log('appended feed items', { + received: page.length, + fresh: fresh.length, + total: merged.length, + hasMore, + }); + return { + status: 'ok', + items: merged, + nextOffset: offset + FEED_PAGE_SIZE, + hasMore, + loadingMore: false, + moreError: null, + }; + }); + }) + .catch((err: unknown) => { + if (!mountedRef.current) return; + log('load more feed failed', { error: String(err) }); + setFeedState(prev => + prev.status === 'ok' ? { ...prev, loadingMore: false, moreError: String(err) } : prev + ); + }); + }, []); + // ── Follow / Unfollow ────────────────────────────────────────────────────── const handleToggleFollow = async (cryptoId: string) => { @@ -741,11 +852,13 @@ export default function FeedSection() { .then(({ ok }) => { if (!ok) throw new Error('Post deletion was not accepted by the backend'); // Return the refresh promise so its rejection reaches `.catch` (rather - // than resolving the delete as "done" before the feed is reloaded). - return apiClient.graphql.homeFeed({ limit: 50, includeSelf: true }).then(result => { - const items = sortedHomeFeedItems(result); - setFeedState({ status: 'ok', items }); - }); + // than resolving the delete as "done" before the feed is reloaded). A + // mutation invalidates offsets, so reset pagination to the first page. + return apiClient.graphql + .homeFeed({ limit: FEED_PAGE_SIZE, offset: 0, includeSelf: true }) + .then(result => { + setFeedState(firstPageFeedState(result)); + }); }) .catch(err => console.error('[FeedSection] delete post failed:', err)) .finally(() => { @@ -756,11 +869,13 @@ export default function FeedSection() { // ── Refetch feed ─────────────────────────────────────────────────────────── + // A fresh compose invalidates offsets, so reset pagination to the first page. const refetchFeed = () => { - void apiClient.graphql.homeFeed({ limit: 50, includeSelf: true }).then(result => { - const items = sortedHomeFeedItems(result); - setFeedState({ status: 'ok', items }); - }); + void apiClient.graphql + .homeFeed({ limit: FEED_PAGE_SIZE, offset: 0, includeSelf: true }) + .then(result => { + setFeedState(firstPageFeedState(result)); + }); }; // ── Render ───────────────────────────────────────────────────────────────── @@ -812,9 +927,10 @@ export default function FeedSection() { /> ); } else { + const { items, hasMore, loadingMore, moreError, nextOffset } = feedState; body = (
- {feedState.items.map(item => ( + {items.map(item => ( ))} + + {moreError && ( +

+ {t('agentWorld.feed.loadMoreError')} +

+ )} + + {hasMore && ( +
+ +
+ )}
); } diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index c3c55272fd..8e77d6b435 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -439,6 +439,9 @@ const messages: TranslationMap = { 'agentWorld.world.rooms.outside.name': 'العالم', 'agentWorld.world.rooms.outside.description': 'ساحة مفتوحة كبيرة تحيط بها المباني.', 'agentWorld.feed': 'التغذية', + 'agentWorld.feed.loadMore': 'تحميل المزيد', + 'agentWorld.feed.loadingMore': 'جارٍ تحميل المزيد…', + 'agentWorld.feed.loadMoreError': 'تعذّر تحميل المزيد من المنشورات. حاول مرة أخرى.', 'agentWorld.ledger': 'السجل', 'agentWorld.ledger.loadMore': 'تحميل المزيد', 'agentWorld.ledger.loadingMore': 'جارٍ تحميل المزيد…', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 867299d247..4b5d0d9248 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -455,6 +455,9 @@ const messages: TranslationMap = { 'agentWorld.world.rooms.outside.name': 'বিশ্ব', 'agentWorld.world.rooms.outside.description': 'ভবনঘেরা বড় খোলা প্লাজা।', 'agentWorld.feed': 'ফিড', + 'agentWorld.feed.loadMore': 'আরও লোড করুন', + 'agentWorld.feed.loadingMore': 'আরও লোড হচ্ছে…', + 'agentWorld.feed.loadMoreError': 'আরও পোস্ট লোড করা যায়নি। আবার চেষ্টা করুন।', 'agentWorld.ledger': 'লেজার', 'agentWorld.ledger.loadMore': 'আরও লোড করুন', 'agentWorld.ledger.loadingMore': 'আরও লোড হচ্ছে…', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 58d708c918..eea090bc6a 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -479,6 +479,10 @@ const messages: TranslationMap = { 'agentWorld.world.rooms.outside.name': 'Welt', 'agentWorld.world.rooms.outside.description': 'Ein großer offener Platz mit Gebäuden ringsum.', 'agentWorld.feed': 'Feed', + 'agentWorld.feed.loadMore': 'Mehr laden', + 'agentWorld.feed.loadingMore': 'Wird geladen…', + 'agentWorld.feed.loadMoreError': + 'Weitere Beiträge konnten nicht geladen werden. Bitte erneut versuchen.', 'agentWorld.ledger': 'Kontobuch', 'agentWorld.ledger.loadMore': 'Mehr laden', 'agentWorld.ledger.loadingMore': 'Wird geladen…', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index a44e669419..9682c18cb1 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -184,6 +184,9 @@ const en: TranslationMap = { 'agentWorld.world.rooms.outside.name': 'World', 'agentWorld.world.rooms.outside.description': 'A large open plaza ringed with buildings.', 'agentWorld.feed': 'Feed', + 'agentWorld.feed.loadMore': 'Load more', + 'agentWorld.feed.loadingMore': 'Loading more…', + 'agentWorld.feed.loadMoreError': 'Could not load more posts. Try again.', 'agentWorld.ledger': 'Ledger', 'agentWorld.ledger.loadMore': 'Load more', 'agentWorld.ledger.loadingMore': 'Loading more…', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 453fa5c581..79422861cc 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -465,6 +465,9 @@ const messages: TranslationMap = { 'agentWorld.world.rooms.outside.name': 'Mundo', 'agentWorld.world.rooms.outside.description': 'Una gran plaza abierta rodeada de edificios.', 'agentWorld.feed': 'Noticias', + 'agentWorld.feed.loadMore': 'Cargar más', + 'agentWorld.feed.loadingMore': 'Cargando más…', + 'agentWorld.feed.loadMoreError': 'No se pudieron cargar más publicaciones. Inténtalo de nuevo.', 'agentWorld.ledger': 'Libro mayor', 'agentWorld.ledger.loadMore': 'Cargar más', 'agentWorld.ledger.loadingMore': 'Cargando más…', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 2944fbebfb..fd4745f7c8 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -475,6 +475,9 @@ const messages: TranslationMap = { 'agentWorld.world.rooms.outside.name': 'Monde', 'agentWorld.world.rooms.outside.description': 'Une grande place ouverte entourée de bâtiments.', 'agentWorld.feed': 'Fil', + 'agentWorld.feed.loadMore': 'Charger plus', + 'agentWorld.feed.loadingMore': 'Chargement…', + 'agentWorld.feed.loadMoreError': 'Impossible de charger plus de publications. Réessayez.', 'agentWorld.ledger': 'Grand livre', 'agentWorld.ledger.loadMore': 'Charger plus', 'agentWorld.ledger.loadingMore': 'Chargement…', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 2d4a3d3b35..385652c404 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -455,6 +455,9 @@ const messages: TranslationMap = { 'agentWorld.world.rooms.outside.name': 'दुनिया', 'agentWorld.world.rooms.outside.description': 'इमारतों से घिरा बड़ा खुला प्लाजा।', 'agentWorld.feed': 'फ़ीड', + 'agentWorld.feed.loadMore': 'और लोड करें', + 'agentWorld.feed.loadingMore': 'और लोड हो रहा है…', + 'agentWorld.feed.loadMoreError': 'अधिक पोस्ट लोड नहीं हो सके। फिर से प्रयास करें।', 'agentWorld.ledger': 'खाता बही', 'agentWorld.ledger.loadMore': 'और लोड करें', 'agentWorld.ledger.loadingMore': 'और लोड हो रहा है…', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index c84ddbf8b5..94f410555a 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -461,6 +461,9 @@ const messages: TranslationMap = { 'agentWorld.world.rooms.outside.name': 'Dunia', 'agentWorld.world.rooms.outside.description': 'Plaza terbuka besar yang dikelilingi gedung.', 'agentWorld.feed': 'Feed', + 'agentWorld.feed.loadMore': 'Muat lebih banyak', + 'agentWorld.feed.loadingMore': 'Memuat lagi…', + 'agentWorld.feed.loadMoreError': 'Tidak dapat memuat postingan lainnya. Coba lagi.', 'agentWorld.ledger': 'Buku Besar', 'agentWorld.ledger.loadMore': 'Muat lebih banyak', 'agentWorld.ledger.loadingMore': 'Memuat lagi…', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 406b6eb430..99c30c995b 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -468,6 +468,9 @@ const messages: TranslationMap = { 'agentWorld.world.rooms.outside.name': 'Mondo', 'agentWorld.world.rooms.outside.description': 'Una grande piazza aperta circondata da edifici.', 'agentWorld.feed': 'Feed', + 'agentWorld.feed.loadMore': 'Carica altro', + 'agentWorld.feed.loadingMore': 'Caricamento…', + 'agentWorld.feed.loadMoreError': 'Impossibile caricare altri post. Riprova.', 'agentWorld.ledger': 'Registro', 'agentWorld.ledger.loadMore': 'Carica altro', 'agentWorld.ledger.loadingMore': 'Caricamento…', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index acb9e186d4..eeb6c5b83e 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -448,6 +448,9 @@ const messages: TranslationMap = { 'agentWorld.world.rooms.outside.name': '월드', 'agentWorld.world.rooms.outside.description': '건물로 둘러싸인 넓은 열린 광장.', 'agentWorld.feed': '피드', + 'agentWorld.feed.loadMore': '더 보기', + 'agentWorld.feed.loadingMore': '더 불러오는 중…', + 'agentWorld.feed.loadMoreError': '게시물을 더 불러오지 못했습니다. 다시 시도하세요.', 'agentWorld.ledger': '원장', 'agentWorld.ledger.loadMore': '더 보기', 'agentWorld.ledger.loadingMore': '더 불러오는 중…', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index aefd8560e6..eb2d48eee4 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -466,6 +466,9 @@ const messages: TranslationMap = { 'agentWorld.world.rooms.outside.name': 'Świat', 'agentWorld.world.rooms.outside.description': 'Duży otwarty plac otoczony budynkami.', 'agentWorld.feed': 'Kanał', + 'agentWorld.feed.loadMore': 'Załaduj więcej', + 'agentWorld.feed.loadingMore': 'Ładowanie…', + 'agentWorld.feed.loadMoreError': 'Nie udało się załadować kolejnych postów. Spróbuj ponownie.', 'agentWorld.ledger': 'Księga', 'agentWorld.ledger.loadMore': 'Załaduj więcej', 'agentWorld.ledger.loadingMore': 'Ładowanie…', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index dc84c0bf54..070eef3f13 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -460,6 +460,9 @@ const messages: TranslationMap = { 'agentWorld.world.rooms.outside.name': 'Mundo', 'agentWorld.world.rooms.outside.description': 'Uma grande praça aberta cercada por edifícios.', 'agentWorld.feed': 'Feed', + 'agentWorld.feed.loadMore': 'Carregar mais', + 'agentWorld.feed.loadingMore': 'Carregando mais…', + 'agentWorld.feed.loadMoreError': 'Não foi possível carregar mais publicações. Tente novamente.', 'agentWorld.ledger': 'Livro-razão', 'agentWorld.ledger.loadMore': 'Carregar mais', 'agentWorld.ledger.loadingMore': 'Carregando mais…', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index c1d7222ff1..e2e06a07e9 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -460,6 +460,9 @@ const messages: TranslationMap = { 'agentWorld.world.rooms.outside.name': 'Мир', 'agentWorld.world.rooms.outside.description': 'Большая открытая площадь, окруженная зданиями.', 'agentWorld.feed': 'Лента', + 'agentWorld.feed.loadMore': 'Загрузить ещё', + 'agentWorld.feed.loadingMore': 'Загрузка…', + 'agentWorld.feed.loadMoreError': 'Не удалось загрузить больше публикаций. Попробуйте ещё раз.', 'agentWorld.ledger': 'Реестр', 'agentWorld.ledger.loadMore': 'Загрузить ещё', 'agentWorld.ledger.loadingMore': 'Загрузка…', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 337097b899..68f38625e2 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -424,6 +424,9 @@ const messages: TranslationMap = { 'agentWorld.world.rooms.outside.name': '世界', 'agentWorld.world.rooms.outside.description': '一座被建筑环绕的大型开放广场。', 'agentWorld.feed': '动态', + 'agentWorld.feed.loadMore': '加载更多', + 'agentWorld.feed.loadingMore': '正在加载…', + 'agentWorld.feed.loadMoreError': '无法加载更多帖子,请重试。', 'agentWorld.ledger': '账本', 'agentWorld.ledger.loadMore': '加载更多', 'agentWorld.ledger.loadingMore': '正在加载…', From 8c1b2dd84c7b67eb866a09f6e9b4fda9c9c4a6b8 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:30:51 +0530 Subject: [PATCH 65/86] fix(conversations): drive agentic task insights reset off turn lifecycle, not isRunning (#5010) --- .../components/flows/WorkflowCopilotPanel.tsx | 2 + .../features/conversations/Conversations.tsx | 9 ++ .../components/ToolTimelineBlock.tsx | 59 ++++++-- .../__tests__/ToolTimelineBlock.test.tsx | 142 ++++++++++++++++++ app/src/hooks/useWorkflowBuilderChat.test.ts | 87 ++++++++++- app/src/hooks/useWorkflowBuilderChat.ts | 64 +++++++- 6 files changed, 346 insertions(+), 17 deletions(-) diff --git a/app/src/components/flows/WorkflowCopilotPanel.tsx b/app/src/components/flows/WorkflowCopilotPanel.tsx index 67902ae681..657e6bbb6a 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.tsx @@ -157,6 +157,7 @@ export default function WorkflowCopilotPanel({ const { threadId, sending, + turnActive, proposal, capped, displayMessages, @@ -529,6 +530,7 @@ export default function WorkflowCopilotPanel({
)} diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index aa3e6ef244..a25ff26c85 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -1983,6 +1983,15 @@ const Conversations = ({ entries={selectedThreadToolTimeline} onViewDetails={openScopedDetail} onViewWholeRun={openWholeRunSource} + // Reuse `isSending` rather than a raw `in` membership check on + // `inferenceTurnLifecycleByThread`: that map also carries + // `'interrupted'` entries (a turn that crashed mid-flight in a + // PRIOR core process, written by `hydrateRuntimeFromSnapshot` on + // cold boot) which have no live driver and must NOT read as an + // active turn, or stale disclosure state leaks into a later + // retry. `isSending` already excludes it (only `'started'` / + // `'streaming'`, same as this component's own live-turn checks). + turnActive={isSending} /> ) : ( // Transcript-only turn: reasoning/narration was streamed but no tool diff --git a/app/src/features/conversations/components/ToolTimelineBlock.tsx b/app/src/features/conversations/components/ToolTimelineBlock.tsx index 8c8d1484cb..33249b07f3 100644 --- a/app/src/features/conversations/components/ToolTimelineBlock.tsx +++ b/app/src/features/conversations/components/ToolTimelineBlock.tsx @@ -1,5 +1,5 @@ import createDebug from 'debug'; -import { useEffect, useRef, useState } from 'react'; +import { useState } from 'react'; import WorktreeActions from '../../../components/worktree/WorktreeActions'; import { useT } from '../../../lib/i18n/I18nContext'; @@ -454,6 +454,7 @@ export function ToolTimelineBlock({ onViewWholeRun, expandAllRows = false, liveResponse, + turnActive, }: { entries: ToolTimelineEntry[]; /** Opens the full-transcript drawer for a subagent row. When omitted, @@ -479,6 +480,16 @@ export function ToolTimelineBlock({ * Omitted/empty once the turn settles — the final answer is the message * bubble. */ liveResponse?: string; + /** Whether a turn is in flight on this thread's lifecycle + * (`inferenceTurnLifecycleByThread`), the same signal the chat threads page + * uses. When provided, the sticky `userOverrideOpen` reset fires on THIS + * value's true→false edge — once per USER TURN — instead of on `isRunning`'s + * edge, which flips once PER SUB-AGENT within a single turn (each + * subagent spawn→settle) and made the panel flicker open/closed as + * sub-agents ran (regression from #5008). Falls back to `isRunning` when + * omitted, which is correct for a settled/past-turn render (there is no + * turn left to track). */ + turnActive?: boolean; }) { const { t } = useT(); @@ -500,24 +511,44 @@ export function ToolTimelineBlock({ const [userOverrideOpen, setUserOverrideOpen] = useState(null); // Whether *any* entry is currently running — computed here (ahead of the - // `entries.length === 0` early return below) purely so the reset effect - // that follows is an unconditional hook call every render; order doesn't - // matter for this existence check, unlike `latestRunningEntryId` further - // down, which needs the seq-sorted order to pick a specific "latest" row. + // `entries.length === 0` early return below) purely so the render-time + // reset adjustment that follows runs every render; order doesn't matter for + // this existence check, unlike `latestRunningEntryId` further down, which + // needs the seq-sorted order to pick a specific "latest" row. const isRunning = entries.some(entry => entry.status === 'running'); - // Reset the user's manual open/close override on the running→settled edge - // (a turn just finished) so the auto-collapse applies to the just-settled - // turn. The override only sticks WITHIN a turn — preventing involuntary - // mid-feedback collapse (#4942) — not permanently across turns. - const prevIsRunningRef = useRef(false); - useEffect(() => { - if (prevIsRunningRef.current && !isRunning) { + // The signal the reset below watches for a "turn just settled" edge. Prefer + // the real TURN lifecycle (`turnActive`, sourced from + // `inferenceTurnLifecycleByThread` — the same signal the chat threads page + // uses) when the caller supplies it: it flips true→false exactly once per + // USER TURN. `isRunning` is only a fallback for callers with no turn + // lifecycle to hand (e.g. a settled/past-turn render, where entries never + // change again anyway) — used directly it flips once PER SUB-AGENT within a + // single turn (each subagent spawn→settle), which reset the override (and + // so auto-collapsed the panel) repeatedly within one turn and made it + // flicker open/closed as sub-agents ran (#5008 regression). + const settleSignal = turnActive ?? isRunning; + + // Reset the user's manual open/close override on the settleSignal's + // true→false edge (a turn just finished) so the auto-collapse applies to + // the just-settled turn. The override only sticks WITHIN a turn — + // preventing involuntary mid-feedback collapse (#4942) — not permanently + // across turns. + // + // Done as a render-time adjustment (comparing against `prevSettleSignal` + // state and calling both setters synchronously in the render body), not a + // `useEffect`, per React's documented pattern for resetting state on a prop + // transition: it bails out and re-renders with the reset applied before + // paint, instead of committing a stale (still-collapsed/expanded) frame + // and only correcting it a tick later once the effect runs. + const [prevSettleSignal, setPrevSettleSignal] = useState(settleSignal); + if (prevSettleSignal !== settleSignal) { + if (prevSettleSignal && !settleSignal) { log('agent-task-insights: turn settled (running→done), resetting user override'); setUserOverrideOpen(null); } - prevIsRunningRef.current = isRunning; - }, [isRunning]); + setPrevSettleSignal(settleSignal); + } if (entries.length === 0) return null; diff --git a/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx b/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx index c8ddb1d6d0..a054d60ff5 100644 --- a/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx +++ b/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx @@ -581,6 +581,148 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { }); }); + // #5008 shipped the reset-on-settle fix using the `isRunning` true→false + // edge, but that edge fires once PER SUB-AGENT within a single turn (each + // subagent_spawned/subagent_completed pair toggles `isRunning`), not once + // per turn — so on a multi-sub-agent turn the panel's override reset (and + // its auto-collapse) fired repeatedly, flickering the panel open/closed + // as each sub-agent came and went. `turnActive` — sourced from + // `inferenceTurnLifecycleByThread`, the same lifecycle the chat threads + // page uses for `isSending` — transitions exactly once per USER TURN, so + // passing it in makes the reset track the turn instead of any single + // sub-agent. + describe('with turnActive prop', () => { + it('does not reset the user override while turnActive stays true across multiple isRunning toggles, only resetting (and auto-collapsing) when turnActive itself goes false', () => { + const subagentARunning: ToolTimelineEntry[] = [ + { id: 'a', name: 'subagent:researcher', round: 1, seq: 0, status: 'running' }, + ]; + const { rerender } = renderInStore( + + ); + // Sub-agent A running, turn active → auto-open (no override yet). + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + // Sub-agent A settles: `isRunning` flips true→false, but the whole + // TURN is still active. Pre-fix this alone would have reset the + // (nonexistent, still null) override — here there's nothing to reset, + // but the auto rule alone already collapses it (autoOpen tracks + // `isRunning`, unchanged by this fix). + const subagentASettled: ToolTimelineEntry[] = [ + { id: 'a', name: 'subagent:researcher', round: 1, seq: 0, status: 'success' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + + // The user manually expands it while the turn is still in flight. + fireEvent.click(screen.getByText('Agentic task insights')); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + // Sub-agent B spawns: `isRunning` flips false→true again. Still one + // turn (`turnActive` unchanged) — the user's override must hold. + const subagentBRunning: ToolTimelineEntry[] = [ + ...subagentASettled, + { id: 'b', name: 'subagent:coder', round: 1, seq: 1, status: 'running' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + // Sub-agent B settles: `isRunning` flips true→false a second time + // within the SAME turn. This is exactly the edge that used to reset + // the override and cause the flicker (#5008 regression) — with + // `turnActive` supplied it must NOT reset; the user's expand sticks. + const subagentBSettled: ToolTimelineEntry[] = [ + ...subagentASettled, + { id: 'b', name: 'subagent:coder', round: 1, seq: 1, status: 'success' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + // Only when the TURN itself ends (`turnActive` true→false) does the + // override reset — the panel then auto-collapses since the run is done. + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + }); + + it('keeps a mid-turn manual collapse intact across a sub-agent settling, only reopening per the auto rule once turnActive ends', () => { + const subagentARunning: ToolTimelineEntry[] = [ + { id: 'a', name: 'subagent:researcher', round: 1, seq: 0, status: 'running' }, + ]; + const { rerender } = renderInStore( + + ); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + // The user explicitly collapses it while sub-agent A is still running. + fireEvent.click(screen.getByText('Agentic task insights')); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + + // Sub-agent A settles, sub-agent B spawns and settles too — all within + // the same turn (`turnActive` stays true throughout). None of these + // `isRunning` toggles may reopen the panel against the user's choice. + const afterSubagentB: ToolTimelineEntry[] = [ + { id: 'a', name: 'subagent:researcher', round: 1, seq: 0, status: 'success' }, + { id: 'b', name: 'subagent:coder', round: 1, seq: 1, status: 'running' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + + const bothSettled: ToolTimelineEntry[] = [ + { id: 'a', name: 'subagent:researcher', round: 1, seq: 0, status: 'success' }, + { id: 'b', name: 'subagent:coder', round: 1, seq: 1, status: 'success' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + + // The turn ends — override resets; auto rule (settled, not running) + // keeps it collapsed, same outcome but for the right reason now. + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + + // Both sides of that transition read "collapsed", which a STALE `false` + // override would also produce — prove the override actually reset (not + // just that it happened to still agree with the auto rule) by starting + // a brand-new turn: the auto rule alone (isRunning) should now govern, + // reopening the panel with no further user interaction. + const newTurnRunning: ToolTimelineEntry[] = [ + { id: 'c', name: 'subagent:researcher', round: 2, seq: 0, status: 'running' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + }); + }); + it('renders the tool result output inside the expanded row', () => { const entries: ToolTimelineEntry[] = [ { diff --git a/app/src/hooks/useWorkflowBuilderChat.test.ts b/app/src/hooks/useWorkflowBuilderChat.test.ts index 20cf47c07a..78caf651ff 100644 --- a/app/src/hooks/useWorkflowBuilderChat.test.ts +++ b/app/src/hooks/useWorkflowBuilderChat.test.ts @@ -2,7 +2,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { BuilderTurnResult } from '../services/api/flowsApi'; -import type { WorkflowProposal } from '../store/chatRuntimeSlice'; +import type { InferenceTurnLifecycle, WorkflowProposal } from '../store/chatRuntimeSlice'; import type { ThreadMessage } from '../types/thread'; import { useWorkflowBuilderChat, type WorkflowBuilderSendResult } from './useWorkflowBuilderChat'; @@ -21,6 +21,7 @@ const selectorState = vi.hoisted(() => ({ messagesByThreadId: {} as Record, toolTimelineByThread: {} as Record, streamingAssistantByThread: {} as Record, + inferenceTurnLifecycleByThread: {} as Record, })); vi.mock('../store/hooks', () => ({ useAppDispatch: () => dispatch, @@ -31,6 +32,7 @@ vi.mock('../store/hooks', () => ({ pendingWorkflowProposalsByThread: selectorState.proposals, toolTimelineByThread: selectorState.toolTimelineByThread, streamingAssistantByThread: selectorState.streamingAssistantByThread, + inferenceTurnLifecycleByThread: selectorState.inferenceTurnLifecycleByThread, }, }), })); @@ -43,6 +45,8 @@ vi.mock('../store/threadSlice', () => ({ THREAD_NOT_FOUND_MESSAGE, })); vi.mock('../store/chatRuntimeSlice', () => ({ + beginInferenceTurn: (p: unknown) => ({ type: 'beginInferenceTurn', p }), + endInferenceTurn: (p: unknown) => ({ type: 'endInferenceTurn', p }), clearWorkflowProposalForThread: (p: unknown) => ({ type: 'clearProposal', p }), setWorkflowProposalForThread: (p: unknown) => ({ type: 'setProposal', p }), fetchAndHydrateTurnState: (threadId: string) => ({ type: 'fetchAndHydrateTurnState', threadId }), @@ -68,6 +72,7 @@ describe('useWorkflowBuilderChat', () => { selectorState.messagesByThreadId = {}; selectorState.toolTimelineByThread = {}; selectorState.streamingAssistantByThread = {}; + selectorState.inferenceTurnLifecycleByThread = {}; dispatch.mockReset().mockImplementation((action: { type: string }) => { if (action.type === 'createNewThread') { return { unwrap: () => Promise.resolve({ id: 'builder-1' }) }; @@ -107,6 +112,86 @@ describe('useWorkflowBuilderChat', () => { await waitFor(() => expect(result.current.threadId).toBe('builder-1')); }); + it('seeds and clears the shared turn-lifecycle entry around the blocking flows_build call', async () => { + // Regression test: `turnActive` (derived from `inferenceTurnLifecycleByThread` + // below) must reflect a REAL turn in flight, or `ToolTimelineBlock`'s + // `turnActive ?? isRunning` fallback is defeated — a `false` `turnActive` + // (as opposed to `undefined`) short-circuits nullish coalescing and never + // falls back to `isRunning`, permanently disabling the panel's settle-edge + // override reset for the copilot surface this hook drives. + let sawActiveDuringCall = false; + buildWorkflow.mockImplementation(async () => { + // `beginInferenceTurn` must have been dispatched — and not yet cleared — + // by the time the blocking RPC is in flight. + sawActiveDuringCall = dispatch.mock.calls.some( + ([a]) => (a as { type: string }).type === 'beginInferenceTurn' + ); + return okResult(); + }); + + const { result } = renderHook(() => useWorkflowBuilderChat()); + await act(async () => { + await result.current.send({ + displayText: 'hi', + request: { mode: 'create', instruction: 'x' }, + }); + }); + + expect(sawActiveDuringCall).toBe(true); + const calls = dispatch.mock.calls.map(([a]) => a as { type: string; p?: unknown }); + const beginIndex = calls.findIndex(a => a.type === 'beginInferenceTurn'); + const endIndex = calls.findIndex(a => a.type === 'endInferenceTurn'); + expect(beginIndex).toBeGreaterThanOrEqual(0); + expect(endIndex).toBeGreaterThan(beginIndex); + expect(calls[beginIndex].p).toEqual({ threadId: 'builder-1' }); + expect(calls[endIndex].p).toEqual({ threadId: 'builder-1' }); + }); + + it('clears the turn-lifecycle entry even when the blocking flows_build call throws', async () => { + buildWorkflow.mockRejectedValue(new Error('network blip')); + + const { result } = renderHook(() => useWorkflowBuilderChat()); + await act(async () => { + await result.current.send({ + displayText: 'hi', + request: { mode: 'create', instruction: 'x' }, + }); + }); + + // A failed turn must not leak the lifecycle entry — otherwise `turnActive` + // would stay stuck `true` forever, since no `chat_done` ever arrives for a + // turn that never reached the server. + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'endInferenceTurn', p: { threadId: 'builder-1' } }) + ); + }); + + it("treats an 'interrupted' lifecycle entry as NOT active (no live driver behind it)", () => { + // 'interrupted' is written by `hydrateRuntimeFromSnapshot` for a turn that + // crashed mid-flight in a PRIOR core process (cold-boot rehydrate) — there + // is no live driver streaming for it, so `turnActive` must stay `false`, + // or stale disclosure state (from before the crash) leaks into whatever + // the user does next on this thread. + const interrupted: InferenceTurnLifecycle = 'interrupted'; + selectorState.inferenceTurnLifecycleByThread = { 'builder-1': interrupted }; + + const { result } = renderHook(() => useWorkflowBuilderChat('builder-1')); + + expect(result.current.turnActive).toBe(false); + }); + + it("treats 'started'/'streaming' lifecycle entries as active", () => { + const started: InferenceTurnLifecycle = 'started'; + selectorState.inferenceTurnLifecycleByThread = { 'builder-1': started }; + const { result: startedResult } = renderHook(() => useWorkflowBuilderChat('builder-1')); + expect(startedResult.current.turnActive).toBe(true); + + const streaming: InferenceTurnLifecycle = 'streaming'; + selectorState.inferenceTurnLifecycleByThread = { 'builder-1': streaming }; + const { result: streamingResult } = renderHook(() => useWorkflowBuilderChat('builder-1')); + expect(streamingResult.current.turnActive).toBe(true); + }); + it('surfaces the proposal the builder returned by dispatching it into the store', async () => { const proposal: WorkflowProposal = { name: 'Digest', diff --git a/app/src/hooks/useWorkflowBuilderChat.ts b/app/src/hooks/useWorkflowBuilderChat.ts index fa2710243e..d17bce0f1b 100644 --- a/app/src/hooks/useWorkflowBuilderChat.ts +++ b/app/src/hooks/useWorkflowBuilderChat.ts @@ -27,9 +27,15 @@ import createDebug from 'debug'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { type BuilderTurnRequest, buildWorkflow } from '../services/api/flowsApi'; import { + type BuilderTurnRequest, + type BuilderTurnResult, + buildWorkflow, +} from '../services/api/flowsApi'; +import { + beginInferenceTurn, clearWorkflowProposalForThread, + endInferenceTurn, fetchAndHydrateTurnHistory, fetchAndHydrateTurnState, setWorkflowProposalForThread, @@ -94,6 +100,15 @@ export interface UseWorkflowBuilderChat { threadId: string | null; /** True while a builder turn is in flight on this thread. */ sending: boolean; + /** + * Whether a turn is in flight on this thread per the runtime's + * `inferenceTurnLifecycleByThread` — the same turn-lifecycle signal the main + * chat threads page uses to derive `isSending`. Passed through as + * `ToolTimelineBlock`'s `turnActive` prop so the panel's sticky + * open/collapse override resets once per TURN instead of once per + * sub-agent (see that component's doc for the #5008 flicker this fixes). + */ + turnActive: boolean; /** The latest proposal the agent returned on this thread, or `null`. */ proposal: WorkflowProposal | null; /** @@ -196,6 +211,21 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo const streamingAssistantByThread = useAppSelector( state => state.chatRuntime.streamingAssistantByThread ); + const inferenceTurnLifecycleByThread = useAppSelector( + state => state.chatRuntime.inferenceTurnLifecycleByThread + ); + + // A turn is in flight on this thread iff its lifecycle entry is `'started'` + // or `'streaming'` — NOT `'interrupted'`, which `hydrateRuntimeFromSnapshot` + // (chatRuntimeSlice.ts) writes for a turn that crashed mid-flight in a PRIOR + // core process (cold-boot rehydrate): there is no live driver behind it, so + // treating it as "active" would leak stale disclosure state into a later, + // genuinely new turn on this same thread. Mirrors `Conversations.tsx`'s + // `isSending` derivation (same explicit two-state check, not a broad `in` + // membership test) so the copilot's `ToolTimelineBlock` resets its sticky + // override on the same real turn-settle edge the main chat uses. + const threadLifecycle = threadId != null ? inferenceTurnLifecycleByThread[threadId] : undefined; + const turnActive = threadLifecycle === 'started' || threadLifecycle === 'streaming'; // Prefer the runtime's streamed proposal (populated on this thread by // `ChatRuntimeProvider` as the builder's `propose_workflow`/`revise_workflow` @@ -343,8 +373,37 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo // slices as the turn runs, so this hook must NOT also append the agent // reply (doing so would double it — B26). We still await the blocking // result for its `proposal`/`error` signal. + // + // Seed the shared turn-lifecycle entry for this thread (mirrors + // `Conversations.tsx`'s `beginInferenceTurn` dispatch on send) so + // `turnActive` above (`threadId in inferenceTurnLifecycleByThread`) + // reflects a REAL turn in flight. Without this, nothing ever creates + // an entry for the builder's dedicated thread — `ChatRuntimeProvider` + // only *updates* an existing entry (`markInferenceTurnStreaming` is a + // no-op unless one is already present) — so `turnActive` would stay + // `false` for the entire life of every builder turn. Because it's a + // *boolean* `false` rather than `undefined`, that permanently defeats + // `ToolTimelineBlock`'s `turnActive ?? isRunning` fallback (nullish + // coalescing only falls back on null/undefined, never on `false`), + // so the panel's settle-edge override reset — the entire point of + // this fix — would never fire for the copilot surface it targets. + dispatch(beginInferenceTurn({ threadId: targetThreadId })); log('send: running flows_build thread=%s mode=%s', targetThreadId, request.mode); - const result = await buildWorkflow(request, targetThreadId); + let result: BuilderTurnResult; + try { + result = await buildWorkflow(request, targetThreadId); + } finally { + // The blocking RPC settling (success or error) IS the turn ending — + // clear eagerly here rather than relying solely on + // `ChatRuntimeProvider`'s generic `chat_done` listener (which also + // calls `endInferenceTurn` for this thread — redundant but + // harmless, since it's a plain delete). If the server-side turn + // never reaches `chat_done` (e.g. this call throws before the core + // ever starts one), that listener never fires and the lifecycle + // entry would otherwise leak, stranding `turnActive` — and the + // panel it drives — permanently `true`. + dispatch(endInferenceTurn({ threadId: targetThreadId })); + } // Surface the proposal via the same store slice the streamed path used, // so `WorkflowProposalCard` / the copilot preview render unchanged. This @@ -412,6 +471,7 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo return { threadId, sending, + turnActive, proposal, capped, messages, From 1bb5360456925818030f6d6aab717541b4497e00 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:31:05 +0530 Subject: [PATCH 66/86] fix(flows): dismissible/auto-clearing run-error banner, no longer over undo/redo (#5011) --- app/src/pages/FlowCanvasPage.tsx | 74 +++++++++++- .../pages/__tests__/FlowCanvasPage.test.tsx | 113 ++++++++++++++++++ 2 files changed, 184 insertions(+), 3 deletions(-) diff --git a/app/src/pages/FlowCanvasPage.tsx b/app/src/pages/FlowCanvasPage.tsx index 1839901969..7f8c793c33 100644 --- a/app/src/pages/FlowCanvasPage.tsx +++ b/app/src/pages/FlowCanvasPage.tsx @@ -146,6 +146,9 @@ export function asCopilotRepairSeed(state: unknown): CopilotRepairSeed | null { const log = createDebug('app:flows:canvas'); +/** How long the run-error banner stays up before it auto-dismisses. */ +const RUN_ERROR_AUTO_DISMISS_MS = 12_000; + /** Which panel (if any) the canvas side rail shows. Driven by the header toggle. */ type SidePanel = 'copilot' | 'legend' | null; @@ -159,6 +162,28 @@ function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } +/** + * Run-start failures bubble up through several `thiserror`-wrapped tinyflows + * layers, each re-tagging the one beneath it (e.g. "capability error: graph + * error: capability error: code node exited non-zero (timed_out=false):"). + * The nesting is meaningless to the user — strip repeated " error: " + * wrapper prefixes so only the innermost, actionable tail is shown. Scoped + * to the run-error banner only (`handleRun`'s catch); other error surfaces + * in this file keep the raw message since their shapes differ. Pure and + * exported for direct unit testing without rendering `FlowEditor`. + */ +export function formatRunError(message: string): string { + const wrapperPrefix = /^\w+\s+error:\s*/i; + let rest = message; + let match = wrapperPrefix.exec(rest); + while (match && match[0].length < rest.length) { + rest = rest.slice(match[0].length); + match = wrapperPrefix.exec(rest); + } + const trimmed = rest.replace(/:\s*$/, '').trim(); + return trimmed || message.trim(); +} + /** * True when `title` is "unclaimed" — either blank or exactly the localized * generic placeholder (`t('flows.page.newWorkflow')`, "New workflow") — i.e. @@ -763,6 +788,17 @@ function FlowEditor({ } }, [flowId]); + // Auto-dismiss the run-error banner so a stale failure doesn't linger + // forever over the canvas. Re-runs (and thus restarts the timer) whenever + // `runError` changes — including a new failure replacing an old one, since + // `handleRun` always routes through `setRunError(null)` before setting the + // next message, so a later error is always a distinct effect run. + useEffect(() => { + if (!runError) return; + const timer = setTimeout(() => setRunError(null), RUN_ERROR_AUTO_DISMISS_MS); + return () => clearTimeout(timer); + }, [runError]); + // Return to wherever the user came from rather than always the list. React // Router stamps the initial history entry with key 'default', so when this // page was the first thing loaded (deep link / fresh load) there's nothing to @@ -968,12 +1004,44 @@ function FlowEditor({ /> {runError && ( -
+ // top-14 (not top-3) so this never overlaps the canvas's own + // top-right undo/redo controls, which sit at top-3; max-w-md + // caps how wide a long nested error can grow. When the legend + // palette is open it also docks at top-14/right-3 (w-48), so we + // pull the banner's right edge in past it (right-56) rather than + // centering across the full width, which would let a long + // message reach under the palette and swallow its clicks. +
- {t('flows.editor.runFailed')}: {runError} + className="pointer-events-auto flex w-full max-w-md items-start gap-2 rounded-xl border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300"> + + {t('flows.editor.runFailed')}: {formatRunError(runError)} + +
)} diff --git a/app/src/pages/__tests__/FlowCanvasPage.test.tsx b/app/src/pages/__tests__/FlowCanvasPage.test.tsx index 6ef32d83dd..6cbe4a2ca8 100644 --- a/app/src/pages/__tests__/FlowCanvasPage.test.tsx +++ b/app/src/pages/__tests__/FlowCanvasPage.test.tsx @@ -15,6 +15,7 @@ import FlowCanvasPage, { asCopilotBuildSeed, asCopilotPrefillSeed, FlowCanvasDraftPage, + formatRunError, isPlaceholderTitle, } from '../FlowCanvasPage'; @@ -23,12 +24,14 @@ const updateFlow = vi.hoisted(() => vi.fn()); const createFlow = vi.hoisted(() => vi.fn()); const validateFlow = vi.hoisted(() => vi.fn()); const listFlowConnections = vi.hoisted(() => vi.fn()); +const runFlow = vi.hoisted(() => vi.fn()); vi.mock('../../services/api/flowsApi', () => ({ getFlow, updateFlow, createFlow, validateFlow, listFlowConnections, + runFlow, })); // Stub the copilot panel: it drives the real chat runtime (redux + socket), @@ -95,6 +98,7 @@ describe('FlowCanvasPage', () => { createFlow.mockReset(); validateFlow.mockReset(); listFlowConnections.mockReset(); + runFlow.mockReset(); validateFlow.mockResolvedValue({ valid: true, errors: [], warnings: [] }); listFlowConnections.mockResolvedValue([]); updateFlow.mockResolvedValue(makeFlow()); @@ -146,6 +150,93 @@ describe('FlowCanvasPage', () => { expect(screen.getByText('core unreachable')).toBeInTheDocument(); }); + describe('run-error banner (#flows-canvas-error-banner)', () => { + // Run always goes through the header icon → confirm popup → accept, same + // as a real user flow (see `handleRun` wiring around `confirmAction`). + // Two `fireEvent.click`s in a row: the first (Run) synchronously opens + // the confirm popup via a plain `useState` setter, so no flush is needed + // between them. + function clickRun() { + fireEvent.click(screen.getByTestId('flow-canvas-run')); + fireEvent.click(screen.getByTestId('flow-action-confirm-accept')); + } + + it('renders the run-error banner (trimmed) without covering undo/redo, and lets it be dismissed', async () => { + getFlow.mockResolvedValue(makeFlow()); + runFlow.mockRejectedValue( + new Error( + 'capability error: graph error: capability error: code node exited non-zero (timed_out=false):' + ) + ); + renderAtFlowId('test-id'); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + clickRun(); + + const banner = await screen.findByTestId('flow-canvas-run-error'); + // The raw nested "capability error: graph error: capability error: " + // wrapper prefixes are stripped — only the innermost tail remains. + expect(banner).toHaveTextContent('code node exited non-zero (timed_out=false)'); + expect(banner).not.toHaveTextContent('graph error'); + + // Regression for the overlap bug: the banner sits at top-14, well + // below the canvas's own top-3 undo/redo controls, and both remain + // present/interactive rather than one covering the other. + expect(banner.parentElement).toHaveClass('top-14'); + expect(screen.getByTestId('flow-editor-undo')).toBeInTheDocument(); + expect(screen.getByTestId('flow-editor-redo')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('flow-canvas-run-error-dismiss')); + expect(screen.queryByTestId('flow-canvas-run-error')).not.toBeInTheDocument(); + }); + + it('auto-dismisses the run-error banner after the timeout, and restarts the timer on a new error', async () => { + getFlow.mockResolvedValue(makeFlow()); + runFlow.mockRejectedValue(new Error('capability error: boom')); + renderAtFlowId('test-id'); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + // Switch to fake timers only now — the load above already went through + // `waitFor` (real timers); the run/timeout portion below only needs + // fake macrotasks plus microtask flushes for the rejected `runFlow` + // promise, matching the FlowRunsDrawer.test.tsx fake-timer precedent. + vi.useFakeTimers(); + try { + clickRun(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByTestId('flow-canvas-run-error')).toBeInTheDocument(); + + // Not yet at the 12s mark — banner is still up. + await act(async () => { + vi.advanceTimersByTime(11_000); + }); + expect(screen.getByTestId('flow-canvas-run-error')).toBeInTheDocument(); + + // A new failure before the old timer fires resets the clock rather + // than stacking on top of it. + clickRun(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(11_000); + }); + expect(screen.getByTestId('flow-canvas-run-error')).toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(2_000); + }); + expect(screen.queryByTestId('flow-canvas-run-error')).not.toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + }); + it('ignores a stale response for a superseded id after navigating to a new one', async () => { // Deferred promises so the test controls resolution order precisely: the // first (old-id) fetch resolves AFTER the second (new-id) one, mimicking @@ -529,6 +620,28 @@ describe('isPlaceholderTitle', () => { }); }); +describe('formatRunError', () => { + it('strips repeated nested " error: " wrapper prefixes down to the innermost tail', () => { + expect( + formatRunError( + 'capability error: graph error: capability error: code node exited non-zero (timed_out=false):' + ) + ).toBe('code node exited non-zero (timed_out=false)'); + }); + + it('leaves a message with no wrapper prefix unchanged', () => { + expect(formatRunError('flow has no trigger node')).toBe('flow has no trigger node'); + }); + + it('drops a bare trailing colon left over from a single, unstripped wrapper label', () => { + expect(formatRunError('capability error:')).toBe('capability error'); + }); + + it('falls back to the original message rather than returning an empty string', () => { + expect(formatRunError(':')).toBe(':'); + }); +}); + // ----------------------------------------------------------------------------- // Copilot proposal name adoption — accepting a `propose_workflow` proposal // carries a top-level `name` the canvas previously dropped, leaving the flow From 4fa8475de9acf3409fd4495f5f6dc62b0f357dda Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:31:13 +0530 Subject: [PATCH 67/86] =?UTF-8?q?fix(flows):=20resolve-before-you-ask=20?= =?UTF-8?q?=E2=80=94=20wire=20runtime=20lookups=20instead=20of=20asking=20?= =?UTF-8?q?(#5012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agents/workflow_builder/builder_prompt.rs | 31 +++++++++ .../flows/agents/workflow_builder/prompt.md | 64 ++++++++++++++++--- 2 files changed, 86 insertions(+), 9 deletions(-) diff --git a/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs b/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs index b6a2d0f3b2..6a744fda9f 100644 --- a/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs +++ b/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs @@ -608,6 +608,37 @@ mod tests { } } + /// Before asking the user for a missing value, the builder must exhaust + /// self-resolution — recall, connections, tool catalog, and (for + /// runtime-only facts like the user's own platform handle) wiring a + /// lookup node — and only ask for genuine preferences, not resolvable + /// facts. This also guards that the existing "zero questions is still + /// the happy path" balance line survives: the rule must not turn into + /// "ask about everything". + #[test] + fn standing_prompt_teaches_resolution_first_self_resolution() { + const STANDING_PROMPT: &str = include_str!("prompt.md"); + + for rule in [ + "asking is the last resort", + "Wire a runtime lookup", + "resolvable facts", + "genuine preferences", + "get authenticated user", + "what you already tried", + "zero questions is still the happy path", + ] { + assert!( + STANDING_PROMPT.contains(rule), + "standing prompt must teach the resolution-first rule `{rule}` — \ + before asking for any missing value, the builder must exhaust \ + self-resolution (recall, connections, tool catalog, runtime \ + lookup) and only ask for genuine preferences, while the \ + zero-questions happy path still holds" + ); + } + } + #[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 ff8a6fc30a..68350a80e3 100644 --- a/src/openhuman/flows/agents/workflow_builder/prompt.md +++ b/src/openhuman/flows/agents/workflow_builder/prompt.md @@ -626,6 +626,29 @@ Every message you send is the **finished reply**, not a thinking scratchpad. ### The ask-vs-just-build rule +**Resolution-first: asking is the last resort.** Before asking for ANY +missing value, exhaust self-resolution in order: + +1. **Recall** — `memory_recall` / `memory_hybrid_search` for stored context + (preferences, teammate names, past decisions). +2. **Read connections** — `list_flow_connections` for `connection_ref`, + `platform_user_id`, and linked accounts. +3. **Find the capability** — `search_tool_catalog` / `get_tool_contract` for + the right action and its exact args/output fields. +4. **Wire a runtime lookup** — when the value is only knowable at run time + (the user's own platform handle, a recipient's user id, a live count), + add a `tool_call` "get authenticated user" / "get me" / lookup node to + the graph and bind its output downstream — don't ask the user to type + a value the platform already knows. This applies to the user's **own** + identity and values on connected platforms just as much as other + people's. + +**Distinguish resolvable facts from genuine preferences.** A user's own +Twitter handle is a resolvable fact (wire a lookup node). "Which of your +3 Slack channels should I post to?" is a genuine preference (ask). Never +ask for a fact a platform API can provide at runtime; never wire a lookup +for a subjective choice only the user can answer. + Once `get_tool_contract` hands you a node's `required_args`, sort each one into exactly one bucket before you write the node: @@ -688,6 +711,24 @@ into exactly one bucket before you write the node: open-conversation `tool_call` first, only if that toolkit's contract requires one) → `tool_call` `dm_alan` (the toolkit's send action, recipient arg bound to `=nodes.find_alan.item.json.data.`). + - **"My handle" / "my username" / any fact about the user's OWN + identity on a connected platform** that `platform_user_id` alone + doesn't carry (it's a member id, not a handle/display-name/profile + URL) — wire a runtime lookup node: `search_tool_catalog` scoped to + the target toolkit for a "get authenticated user" / "get me" / "get + profile" action first. **Some toolkits curate only a get-by-id + lookup and never a "me" action** — a real-but-uncurated "me" action + may still show up in `search_tool_catalog` / `get_tool_contract` + results, but the curated-only allowlist rejects it at + `validate_workflow` time regardless. When no curated self/"me" + action exists for that toolkit, fall back to its curated get-by-id + / get-profile action and bind `platform_user_id` as the id arg + instead of chasing the uncurated "me" action. Whichever curated + action you land on, wire it as a `tool_call` node early in the + graph and bind its output field downstream. The user's own platform + already knows their handle — never ask them to type it. Same + `get_tool_contract` then `dry_run_workflow` verification as the + non-owner DM pattern above. - Exactly one connected account for the toolkit the step needs → that account (`list_flow_connections` / `composio_list_connections` tell you this; don't ask "which Gmail?" when there's only one). @@ -696,17 +737,22 @@ into exactly one bucket before you write the node: Fill these in yourself, then **name the choice in your final summary** (below) so the user can correct it in one message if you guessed wrong. 3. **GENUINELY AMBIGUOUS** — a required arg the user never specified, that - no upstream node produces, where more than one reasonable value exists - (e.g. "post to Slack" with several channels connected and no hint which). - **Ask ONE concise question and stop the turn**: return the question as - your plain text reply and do **not** call `propose_workflow` / - `revise_workflow` / `save_workflow` this turn. Wait for the user's answer - on the next turn before building further. + you cannot recall, read from a connection, or wire as a runtime lookup — + **and** where more than one reasonable value exists (a genuine + preference, not a resolvable fact) (e.g. "post to Slack" with several + channels connected and no hint which). + **Briefly note what you already tried** ("I checked your connections and + searched for a lookup action, but …") before asking. **Ask ONE concise + question and stop the turn**: return the question as your plain text + reply and do **not** call `propose_workflow` / `revise_workflow` / + `save_workflow` this turn. Wait for the user's answer on the next turn + before building further. Ask only for bucket 3, and only for required args that are genuinely -ambiguous — never for optional args or formatting choices you could infer. -Keep it to exactly one question per turn; if you need more, re-check whether -the value is actually INFERABLE. +ambiguous — never for optional args, formatting choices, or resolvable +facts you could wire as a runtime lookup. Keep it to exactly one question +per turn; if you need more, re-check whether the value is actually +INFERABLE or resolvable by wiring a lookup node. ### The verify loop — don't stop at "it compiles" From 1868c18abfb4b80e2463eac616bd7c72ee3558f0 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:16:44 +0530 Subject: [PATCH 68/86] fix(flows): make Composio tier gating effect-aware (reads no longer prompt) (#5013) --- src/openhuman/tinyflows/caps.rs | 232 ++++++++++++++++++++++++++++++-- 1 file changed, 222 insertions(+), 10 deletions(-) diff --git a/src/openhuman/tinyflows/caps.rs b/src/openhuman/tinyflows/caps.rs index 2d38d66bd5..a18deb1e76 100644 --- a/src/openhuman/tinyflows/caps.rs +++ b/src/openhuman/tinyflows/caps.rs @@ -1527,6 +1527,38 @@ async fn connected_toolkit_slugs(config: &Config) -> Option> { ) } +/// Effect-aware classification of a Composio `tool_call` slug into the +/// [`CommandClass`] the autonomy-tier gate ([`enforce_node_tier_gate`]) +/// evaluates it under. +/// +/// Reuses [`curated_scope_for`](crate::openhuman::memory_sync::composio::providers::curated_scope_for), +/// the same catalog walk `composio::ops`'s `gated_tools` hints use — a +/// registered native provider's `curated_tools()` first, then the static +/// `catalog_for_toolkit` fallback. **Fail-safe by construction:** only a +/// slug that resolves to a curated entry with `ToolScope::Read` maps to +/// `CommandClass::Read` (the one class every tier `Allow`s outright, so a +/// read never parks as a pending approval). Every other outcome — a +/// curated `Write`/`Admin` entry, a toolkit with no catalog entry for this +/// slug, a toolkit with no catalog at all, or an unparseable/empty slug — +/// maps to `CommandClass::Network`, the same class `http_request` uses +/// (prompts under Supervised/Full, blocks under ReadOnly). +/// +/// Deliberately does **not** fall back to +/// [`classify_unknown`](crate::openhuman::memory_sync::composio::providers::classify_unknown) +/// for uncurated slugs: that heuristic is tuned for the *curation* +/// allowlist (`flow_tool_allowed`'s Path B — "is this slug even visible to +/// the agent"), not for deciding whether a real side-effecting call skips +/// a human approval prompt. A "SEARCH"/"GET"-shaped uncurated slug must +/// still prompt until OpenHuman has actually hand-curated it as `Read`. +async fn classify_composio_action_for_tier(slug: &str) -> CommandClass { + use crate::openhuman::memory_sync::composio::providers::{curated_scope_for, ToolScope}; + + match curated_scope_for(slug) { + Some(ToolScope::Read) => CommandClass::Read, + Some(ToolScope::Write) | Some(ToolScope::Admin) | None => CommandClass::Network, + } +} + /// Deny-by-default curation gate for a flow `tool_call` slug (see /// [`flow_tool_allowed`] for the decision matrix). Fetches the user's live /// connected-toolkit set only when the slug's toolkit has no static catalog. @@ -2597,18 +2629,25 @@ impl ToolInvoker for OpenHumanTools { }); } - // Autonomy-tier gate (Phase 2): a Composio action reaches a - // third-party API over the network, so it is Network-class — same - // class as `http_request`. A read-only run `Block`s here and never - // reaches curation, the preflight, or the approval gate; - // Supervised/Full fall through to `gate_call_for_tier` below. Runs - // BEFORE the curation check (unlike the pre-fix behavior) so a - // read-only tier can never even probe which slugs are curated. - let tier_decision = - enforce_node_tier_gate(&self.security, CommandClass::Network, "tool_call")?; + // Autonomy-tier gate (Phase 2, made effect-aware): the node's + // [`CommandClass`] is derived from the action's curated + // [`ToolScope`](crate::openhuman::memory_sync::composio::providers::ToolScope) + // via [`classify_composio_action_for_tier`] — a curated Read action + // (e.g. `TWITTER_RECENT_SEARCH`) is `CommandClass::Read`, which every + // tier `Allow`s; a curated Write/Admin action, or anything not + // curated, is `CommandClass::Network` (same class `http_request` + // uses — fail-safe: only a curated Read skips the prompt). A + // read-only run `Block`s on a non-Read class here and never reaches + // curation, the preflight, or the approval gate; Supervised/Full + // fall through to `gate_call_for_tier` below. Runs BEFORE the + // curation check (unlike the pre-fix behavior) so a read-only tier + // can never even probe which slugs are curated. + let composio_class = classify_composio_action_for_tier(slug).await; + let tier_decision = enforce_node_tier_gate(&self.security, composio_class, "tool_call")?; tracing::debug!( target: "flows", %slug, + ?composio_class, ?tier_decision, tier = ?self.security.autonomy, "[flows] tool_call: composio node tier gate evaluated" @@ -2643,9 +2682,26 @@ impl ToolInvoker for OpenHumanTools { // `require_approval` toggle (Codex P1), same as the `http_request` and // `code` node paths. Deny short-circuits before any composio call; // Allow records an audit id to close out after the call resolves. + // + // Effect-aware short-circuit: when the tier decision is already + // `Allow` (a curated Read action — the only `CommandClass` this + // classifier maps to `Read`), skip `gate_call_for_tier`/ + // `intercept_audited` entirely. This matters + // because `flows/ops.rs`'s Rule 2 force-sets `require_approval: true` + // on any saved flow that contains a `tool_call` node, regardless of + // which actions it calls — without this short-circuit, that forced + // `Workflow { require_approval: true }` origin would still route a + // curated-Read `Allow` decision through `intercept_audited`'s normal + // parking flow, re-introducing the "reads wait for approval" bug via + // a different path than the one this fix closes at the tier gate. + // Refining Rule 2 itself to be scope-aware is a deferred follow-up. let summary = crate::openhuman::approval::summarize_action(slug, &args); let redacted = crate::openhuman::approval::redact_args(&args); - let (outcome, audit_id) = gate_call_for_tier(tier_decision, slug, &summary, redacted).await; + let (outcome, audit_id) = if tier_decision == GateDecision::Allow { + (crate::openhuman::approval::GateOutcome::Allow, None) + } else { + gate_call_for_tier(tier_decision, slug, &summary, redacted).await + }; if let crate::openhuman::approval::GateOutcome::Deny { reason } = outcome { tracing::warn!( target: "flows", @@ -4216,6 +4272,162 @@ mod tests { } } + // ── Effect-aware Composio tier gating (fixes reads parking as pending + // approvals): the tier gate must classify a Composio action by its + // curated [`ToolScope`], not blanket-treat every action as `Network`. + // Only a curated `Read` entry skips the prompt; curated `Write`/`Admin`, + // an uncurated toolkit, or an unparseable slug all still classify as + // `Network` (fail-safe — same class `http_request` uses). + + /// A genuinely curated read (`TWITTER_RECENT_SEARCH`) must resolve to + /// `CommandClass::Read`, which `ReadOnly`'s gate matrix allows — closing + /// the bug where every Composio action (reads included) hard-blocked + /// under a read-only tier. + #[tokio::test] + async fn composio_read_action_allowed_under_readonly_tier() { + use crate::openhuman::security::AutonomyLevel; + + let class = classify_composio_action_for_tier("TWITTER_RECENT_SEARCH").await; + assert_eq!(class, CommandClass::Read); + assert_eq!( + enforce_node_tier_gate(&policy(AutonomyLevel::ReadOnly), class, "tool_call") + .expect("a curated Read action must not be blocked under ReadOnly"), + GateDecision::Allow + ); + + // End-to-end: the adapter itself must not refuse before dispatch — + // it may still fail downstream (no Composio session configured in + // this test), but never with the policy-blocked marker. + let tools = OpenHumanTools { + config: Arc::new(Config::default()), + security: Arc::new(policy(AutonomyLevel::ReadOnly)), + }; + let err = tools + .invoke("TWITTER_RECENT_SEARCH", json!({}), None) + .await + .expect_err("no live Composio session is configured in this test"); + if let EngineError::Capability(msg) = err { + assert!( + !msg.contains(POLICY_BLOCKED_MARKER), + "a curated read must never be refused by the autonomy-tier gate, got: {msg}" + ); + } else { + panic!("expected EngineError::Capability"); + } + } + + /// A curated read under Supervised classifies as `CommandClass::Read`, + /// which the gate matrix always `Allow`s — so it can never trigger the + /// Supervised `Prompt` round-trip (the actual pending-approval bug: a + /// blanket `Network` classification prompted for every Composio call, + /// reads included). + #[tokio::test] + async fn composio_read_action_does_not_prompt_under_supervised_tier() { + use crate::openhuman::security::AutonomyLevel; + + let class = classify_composio_action_for_tier("TWITTER_RECENT_SEARCH").await; + assert_eq!(class, CommandClass::Read); + assert_eq!( + enforce_node_tier_gate(&policy(AutonomyLevel::Supervised), class, "tool_call") + .expect("a curated Read action must not be blocked under Supervised"), + GateDecision::Allow, + "a curated read must resolve to Allow, never Prompt, under Supervised" + ); + + let tools = OpenHumanTools { + config: Arc::new(Config::default()), + security: Arc::new(policy(AutonomyLevel::Supervised)), + }; + let err = tools + .invoke("TWITTER_RECENT_SEARCH", json!({}), None) + .await + .expect_err("no live Composio session is configured in this test"); + if let EngineError::Capability(msg) = err { + assert!( + !msg.contains(POLICY_BLOCKED_MARKER), + "a curated read must pass the tier gate under Supervised, got: {msg}" + ); + } else { + panic!("expected EngineError::Capability"); + } + } + + /// Guard: a curated *write* action must still resolve to a + /// `Network`-class decision that `Prompt`s under Supervised — the + /// effect-aware classification must never widen who skips approval + /// beyond curated reads. + #[tokio::test] + async fn composio_write_action_still_prompts_under_supervised_tier() { + use crate::openhuman::security::AutonomyLevel; + + for slug in ["TWITTER_CREATION_OF_A_POST", "GMAIL_SEND_EMAIL"] { + let class = classify_composio_action_for_tier(slug).await; + assert_eq!( + class, + CommandClass::Network, + "slug {slug} must classify as Network" + ); + assert_eq!( + enforce_node_tier_gate(&policy(AutonomyLevel::Supervised), class, "tool_call") + .expect( + "a Network-class action is not blocked (only prompted) under Supervised" + ), + GateDecision::Prompt, + "slug {slug} must still require a Supervised-tier approval prompt" + ); + } + } + + /// Guard: an uncurated / unrecognized slug must fail safe to + /// `Network` (never `Read`) so it still prompts under Supervised and + /// blocks under ReadOnly — an agent can't dodge approval just by + /// calling a toolkit action OpenHuman hasn't curated yet. + #[tokio::test] + async fn composio_unknown_slug_prompts_under_supervised_tier() { + use crate::openhuman::security::AutonomyLevel; + + let class = classify_composio_action_for_tier("UNKNOWN_SERVICE_DO_THING").await; + assert_eq!(class, CommandClass::Network); + assert_eq!( + enforce_node_tier_gate(&policy(AutonomyLevel::Supervised), class, "tool_call") + .expect("Network-class is prompted, not blocked, under Supervised"), + GateDecision::Prompt + ); + assert!( + enforce_node_tier_gate(&policy(AutonomyLevel::ReadOnly), class, "tool_call").is_err() + ); + } + + /// Unit coverage of the classifier itself, independent of the gate: a + /// curated Read entry classifies as `Read`; curated Write/Admin entries, + /// an uncurated toolkit, and an unparseable/empty slug all classify as + /// `Network` (fail-safe default — never silently widen to Read). + #[tokio::test] + async fn classify_composio_action_for_tier_matches_curated_scope_fail_safe() { + assert_eq!( + classify_composio_action_for_tier("TWITTER_RECENT_SEARCH").await, + CommandClass::Read + ); + assert_eq!( + classify_composio_action_for_tier("TWITTER_CREATION_OF_A_POST").await, + CommandClass::Network + ); + assert_eq!( + classify_composio_action_for_tier("TWITTER_POST_DELETE_BY_POST_ID").await, + CommandClass::Network + ); + // Uncurated toolkit (no catalog at all for "unknown"). + assert_eq!( + classify_composio_action_for_tier("UNKNOWN_SERVICE_DO_THING").await, + CommandClass::Network + ); + // Unparseable / empty slug. + assert_eq!( + classify_composio_action_for_tier("").await, + CommandClass::Network + ); + } + // ── Codex P1: Prompt-tier decisions must escalate past a workflow's own // require_approval=false default, never silently auto-allow ──────────── From 5c2af38cf40f6d3b2f4b6f5c32dc9021428634d0 Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Sat, 18 Jul 2026 09:11:48 +0530 Subject: [PATCH 69/86] fix(tinyplace): live-update open DM thread via inbox stream (#4988) --- .../pages/MessagingSection.test.tsx | 196 +++++++++++++++++- app/src/agentworld/pages/MessagingSection.tsx | 98 ++++++++- 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 + 16 files changed, 302 insertions(+), 6 deletions(-) diff --git a/app/src/agentworld/pages/MessagingSection.test.tsx b/app/src/agentworld/pages/MessagingSection.test.tsx index cc0de32992..3fc68acb1c 100644 --- a/app/src/agentworld/pages/MessagingSection.test.tsx +++ b/app/src/agentworld/pages/MessagingSection.test.tsx @@ -4,7 +4,7 @@ * * We mock the apiClient and fetchWalletStatus so no actual RPC calls are made. */ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, test, vi } from 'vitest'; @@ -1119,6 +1119,200 @@ describe('inbox stream lifecycle', () => { await screen.findByText(/Your inbox is empty/i); expect(screen.queryByTestId('inbox-live-indicator')).not.toBeInTheDocument(); }); + + test('keeps rendering when the inbox stream fails to start (non-fatal)', async () => { + vi.mocked(apiClient.streams.start).mockRejectedValueOnce(new Error('stream boom')); + render(); + await userEvent.click(screen.getByRole('tab', { name: 'Inbox' })); + // The fetch path is independent of the stream, so the panel still settles. + await screen.findByText(/Your inbox is empty/i); + expect(apiClient.streams.start).toHaveBeenCalledWith('inbox'); + }); + + test('swallows a stream stop failure on unmount', async () => { + vi.mocked(apiClient.streams.stop).mockRejectedValue(new Error('stop boom')); + const { unmount } = render(); + await userEvent.click(screen.getByRole('tab', { name: 'Inbox' })); + await screen.findByText(/Your inbox is empty/i); + await waitFor(() => expect(apiClient.streams.start).toHaveBeenCalledWith('inbox')); + unmount(); + await waitFor(() => expect(apiClient.streams.stop).toHaveBeenCalledWith('inbox')); + }); + + test('stops a stream that resolves after the panel already unmounted', async () => { + let resolveStart!: (v: { streamId: string }) => void; + vi.mocked(apiClient.streams.start).mockReturnValueOnce( + new Promise<{ streamId: string }>(res => { + resolveStart = res; + }) + ); + const { unmount } = render(); + await userEvent.click(screen.getByRole('tab', { name: 'Inbox' })); + await screen.findByText(/Your inbox is empty/i); + await waitFor(() => expect(apiClient.streams.start).toHaveBeenCalledWith('inbox')); + + // Unmount before `start` resolves: cleanup runs with streamRef still null. + unmount(); + // The late resolution must stop the orphaned stream, not leak it. + resolveStart({ streamId: 'late-inbox' }); + await waitFor(() => expect(apiClient.streams.stop).toHaveBeenCalledWith('late-inbox')); + }); +}); + +// ── Open DM real-time stream (#4928) ──────────────────────────────────────────── + +describe('open DM real-time stream', () => { + beforeEach(() => { + vi.mocked(apiClient.messages.list).mockResolvedValue({ messages: [] }); + vi.mocked(apiClient.directory.resolve).mockResolvedValue({ + identity: { cryptoId: 'resolved-crypto-id' }, + }); + vi.mocked(apiClient.streams.start).mockResolvedValue({ streamId: 'inbox' }); + // Default hook mock: idle (no live push yet). + mockUseTinyplaceStream.mockReturnValue({ + messages: [], + status: 'idle', + clearMessages: vi.fn(), + }); + }); + + async function openDm(user: ReturnType) { + await user.type(screen.getByPlaceholderText(/Recipient @handle/), 'peerLive'); + await user.click(screen.getByRole('button', { name: 'Open DM' })); + // Empty thread settled → the DM view is mounted and the mount fetch is done. + await screen.findByTestId('dm-empty-state'); + } + + test('starts the inbox stream while a DM thread is open', async () => { + const user = userEvent.setup(); + render(); + await openDm(user); + // Before the fix nothing in the DM-only surface subscribed to a stream. + expect(apiClient.streams.start).toHaveBeenCalledWith('inbox'); + }); + + test('refetches the open thread when an inbox stream event arrives (no manual refresh)', async () => { + const user = userEvent.setup(); + const { rerender } = render(); + await openDm(user); + + const callsBefore = vi.mocked(apiClient.messages.list).mock.calls.length; + + // A peer message lands on the relay inbox stream. Keep `status` unchanged + // (idle) so this asserts the *event* drives the refetch — not a + // connection-status transition to 'connected' (which would let the test + // pass even if the impl only refetched on status change). + mockUseTinyplaceStream.mockReturnValue({ + messages: [{ stream_id: 'inbox', kind: 'message', message: {} }], + status: 'idle', + clearMessages: vi.fn(), + }); + rerender(); + + // The open thread re-fetches on its own. Fails before the fix (the DM view + // only fetched on mount + after send), passes after. + await waitFor(() => { + expect(vi.mocked(apiClient.messages.list).mock.calls.length).toBeGreaterThan(callsBefore); + }); + }); + + test('shows the Live indicator once the DM stream is connected', async () => { + mockUseTinyplaceStream.mockReturnValue({ + messages: [], + status: 'connected', + clearMessages: vi.fn(), + }); + const user = userEvent.setup(); + render(); + await openDm(user); + expect(await screen.findByTestId('dm-live-indicator')).toBeInTheDocument(); + }); + + test('stops the inbox stream it started when the DM thread is closed', async () => { + const user = userEvent.setup(); + render(); + await openDm(user); + // The thread subscribed to a stream… + await waitFor(() => expect(apiClient.streams.start).toHaveBeenCalledWith('inbox')); + + // …and closing it (Back) must tear that subscription down with the + // started stream id, not leak it. + await user.click(screen.getByRole('button', { name: 'Back' })); + await waitFor(() => expect(apiClient.streams.stop).toHaveBeenCalledWith('inbox')); + }); + + test('stops the inbox stream on unmount', async () => { + const user = userEvent.setup(); + const { unmount } = render(); + await openDm(user); + await waitFor(() => expect(apiClient.streams.start).toHaveBeenCalledWith('inbox')); + + unmount(); + await waitFor(() => expect(apiClient.streams.stop).toHaveBeenCalledWith('inbox')); + }); + + test('stops the old stream and starts a fresh one when switching peers', async () => { + const user = userEvent.setup(); + render(); + await openDm(user); + await waitFor(() => expect(apiClient.streams.start).toHaveBeenCalledWith('inbox')); + const startsForFirstPeer = vi.mocked(apiClient.streams.start).mock.calls.length; + + // Leave this peer: the old stream must be stopped… + await user.click(screen.getByRole('button', { name: 'Back' })); + await waitFor(() => expect(apiClient.streams.stop).toHaveBeenCalledWith('inbox')); + + // …and opening a different peer must open a NEW subscription rather than + // reuse the stale one. + const recipient = screen.getByPlaceholderText(/Recipient @handle/); + await user.clear(recipient); + await user.type(recipient, 'peerOther'); + await user.click(screen.getByRole('button', { name: 'Open DM' })); + await screen.findByTestId('dm-empty-state'); + await waitFor(() => + expect(vi.mocked(apiClient.streams.start).mock.calls.length).toBeGreaterThan( + startsForFirstPeer + ) + ); + }); + + test('keeps the thread usable when the stream fails to start (non-fatal)', async () => { + vi.mocked(apiClient.streams.start).mockRejectedValueOnce(new Error('stream boom')); + const user = userEvent.setup(); + render(); + // openDm settles on the mount fetch, which is independent of the stream. + await openDm(user); + expect(apiClient.streams.start).toHaveBeenCalledWith('inbox'); + }); + + test('swallows a stream stop failure when the thread is closed', async () => { + vi.mocked(apiClient.streams.stop).mockRejectedValue(new Error('stop boom')); + const user = userEvent.setup(); + render(); + await openDm(user); + await waitFor(() => expect(apiClient.streams.start).toHaveBeenCalledWith('inbox')); + await user.click(screen.getByRole('button', { name: 'Back' })); + await waitFor(() => expect(apiClient.streams.stop).toHaveBeenCalledWith('inbox')); + }); + + test('stops a stream that resolves after the thread already closed', async () => { + let resolveStart!: (v: { streamId: string }) => void; + vi.mocked(apiClient.streams.start).mockReturnValueOnce( + new Promise<{ streamId: string }>(res => { + resolveStart = res; + }) + ); + const user = userEvent.setup(); + render(); + await openDm(user); + await waitFor(() => expect(apiClient.streams.start).toHaveBeenCalledWith('inbox')); + + // Close the thread before `start` resolves: cleanup runs with streamRef null. + await user.click(screen.getByRole('button', { name: 'Back' })); + // The late resolution stops the orphaned stream rather than leaking it. + resolveStart({ streamId: 'late-dm' }); + await waitFor(() => expect(apiClient.streams.stop).toHaveBeenCalledWith('late-dm')); + }); }); // ── SignalKeyStatusCard ─────────────────────────────────────────────────────── diff --git a/app/src/agentworld/pages/MessagingSection.tsx b/app/src/agentworld/pages/MessagingSection.tsx index e96f1dc87d..47cb8dfe82 100644 --- a/app/src/agentworld/pages/MessagingSection.tsx +++ b/app/src/agentworld/pages/MessagingSection.tsx @@ -878,13 +878,32 @@ function InboxPanel() { void apiClient.streams .start('inbox') .then(res => { - if (!cancelled) streamRef.current = res.streamId; + // Same resolved-after-cancel guard as the DM thread stream: if the + // panel unmounted before `start` resolved, stop the stream we just + // opened instead of leaking it. + if (cancelled) { + void apiClient.streams.stop(res.streamId).catch(err => { + log( + '[messaging] inbox stream stop-after-cancel failed (%s): %s', + res.streamId, + String(err) + ); + }); + return; + } + streamRef.current = res.streamId; }) - .catch(() => {}); + .catch(err => { + log('[messaging] inbox stream start failed: %s', String(err)); + }); return () => { cancelled = true; if (streamRef.current !== null) { - void apiClient.streams.stop(streamRef.current).catch(() => {}); + const stopId = streamRef.current; + void apiClient.streams.stop(stopId).catch(err => { + log('[messaging] inbox stream stop failed (%s): %s', stopId, String(err)); + }); + streamRef.current = null; } }; }, []); @@ -1067,6 +1086,14 @@ function useDirectMessages(peerId: string, errorMessages: DmErrorMessages) { const [errorKind, setErrorKind] = useState(null); const [sending, setSending] = useState(false); + // Real-time updates for the open thread. Incoming DMs land in the relay + // inbox, so the `inbox` stream fires on every new message (the same signal + // InboxPanel uses to live-update unread counts). We mirror that here: start + // the stream while the thread is open and re-fetch when an event arrives, so + // a peer's message shows up without a manual refresh (see #4928). + const streamRef = useRef(null); + const { messages: streamMessages, status: streamStatus } = useTinyplaceStream('inbox'); + const refresh = useCallback(async () => { setLoading(true); setError(null); @@ -1143,7 +1170,60 @@ function useDirectMessages(peerId: string, errorMessages: DmErrorMessages) { void refresh(); }, [refresh]); - return { messages, loading, error, errorKind, sending, send, refresh }; + // Start the inbox stream while a DM thread is open; stop it on unmount / + // peer change. Failures are non-fatal — the thread still works via the + // mount fetch + post-send refresh, just without live push. + useEffect(() => { + let cancelled = false; + void apiClient.streams + .start('inbox') + .then(res => { + // If the effect was already torn down (peer change / unmount) before + // `start` resolved, our cleanup ran with `streamRef.current` still + // null and never stopped this stream. Stop it now so a rapid peer + // switch can't orphan a live backend subscription. + if (cancelled) { + void apiClient.streams.stop(res.streamId).catch(err => { + log( + '[messaging] dm inbox stream stop-after-cancel failed (%s): %s', + res.streamId, + String(err) + ); + }); + return; + } + streamRef.current = res.streamId; + log('[messaging] dm inbox stream started: %s', res.streamId); + }) + .catch(err => { + log('[messaging] dm inbox stream start failed: %s', String(err)); + }); + return () => { + cancelled = true; + if (streamRef.current !== null) { + const stopId = streamRef.current; + void apiClient.streams.stop(stopId).catch(err => { + log('[messaging] dm inbox stream stop failed (%s): %s', stopId, String(err)); + }); + streamRef.current = null; + } + }; + }, [peerId]); + + // Re-fetch the open thread whenever a new inbox stream event arrives. The + // event isn't peer-scoped, so refresh() re-filters by peerId — an event for + // another peer simply yields no new rows for this thread (harmless). + useEffect(() => { + if (streamMessages.length > 0) { + log('[messaging] dm inbox stream event -> refreshing open thread'); + void refresh(); + } + // Mirror InboxPanel: fire on new stream messages only, not on refresh + // identity changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [streamMessages.length]); + + return { messages, loading, error, errorKind, sending, send, refresh, streamStatus }; } function ActiveDmView({ @@ -1174,7 +1254,7 @@ function ActiveDmView({ }), [t] ); - const { messages, loading, error, errorKind, sending, send } = useDirectMessages( + const { messages, loading, error, errorKind, sending, send, streamStatus } = useDirectMessages( peerId, errorMessages ); @@ -1216,6 +1296,14 @@ function ActiveDmView({ Back {peerId} + {streamStatus === 'connected' ? ( + + + {t('agentworld.messaging.live', 'Live')} + + ) : null} Date: Sat, 18 Jul 2026 09:12:07 +0530 Subject: [PATCH 70/86] fix(tinyplace): open the profile when a Directory entry is clicked (Closes #4927) (#4990) --- .../components/AgentProfileModal.tsx | 146 ++++++++++++++++ .../pages/DirectorySection.test.tsx | 159 +++++++++++++----- app/src/agentworld/pages/DirectorySection.tsx | 94 ++++------- app/src/agentworld/pages/directoryHelpers.ts | 56 ++++++ app/src/lib/i18n/ar.ts | 5 + app/src/lib/i18n/bn.ts | 5 + app/src/lib/i18n/de.ts | 5 + app/src/lib/i18n/en.ts | 5 + app/src/lib/i18n/es.ts | 5 + app/src/lib/i18n/fr.ts | 5 + app/src/lib/i18n/hi.ts | 5 + app/src/lib/i18n/id.ts | 5 + app/src/lib/i18n/it.ts | 5 + app/src/lib/i18n/ko.ts | 5 + app/src/lib/i18n/pl.ts | 5 + app/src/lib/i18n/pt.ts | 5 + app/src/lib/i18n/ru.ts | 5 + app/src/lib/i18n/zh-CN.ts | 5 + 18 files changed, 425 insertions(+), 100 deletions(-) create mode 100644 app/src/agentworld/components/AgentProfileModal.tsx create mode 100644 app/src/agentworld/pages/directoryHelpers.ts diff --git a/app/src/agentworld/components/AgentProfileModal.tsx b/app/src/agentworld/components/AgentProfileModal.tsx new file mode 100644 index 0000000000..00b66dbf8e --- /dev/null +++ b/app/src/agentworld/components/AgentProfileModal.tsx @@ -0,0 +1,146 @@ +/** + * AgentProfileModal — read view of a Tiny Place directory entry's profile. + * + * Opened from `DirectorySection` when a directory card is clicked (previously a + * dead path that only toggled a highlight — GH-4927). Seeds instantly from the + * `AgentCard` already in hand, then enriches with the full public profile via + * `apiClient.graphql.user(agentId)` (`tinyplace_graphql_user`). If that lookup + * fails or 402s, the modal degrades gracefully to the card data with a soft + * notice rather than blanking. + */ +import debugFactory from 'debug'; +import { useEffect, useState } from 'react'; + +import { ModalShell } from '../../components/ui/ModalShell'; +import { + type AgentCard, + type GqlProfile, + PaymentRequiredError, +} from '../../lib/agentworld/invokeApiClient'; +import { useT } from '../../lib/i18n/I18nContext'; +import { apiClient } from '../AgentWorldShell'; +import { getHandle, getInitials, getSkills } from '../pages/directoryHelpers'; + +const debug = debugFactory('agentworld:directory'); + +/** Enrichment fetch state for the full GraphQL profile. */ +type ProfileFetch = + | { status: 'loading' } + | { status: 'ok'; profile: GqlProfile } + | { status: 'unavailable' }; + +/** Format an ISO timestamp as a short human date; fall back to the raw value. */ +function formatJoined(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + // Runtime locale (not hard-coded en-US) so the date localizes with the app. + return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); +} + +export interface AgentProfileModalProps { + agent: AgentCard; + onClose: () => void; +} + +export default function AgentProfileModal({ agent, onClose }: AgentProfileModalProps) { + const { t } = useT(); + const [fetchState, setFetchState] = useState({ status: 'loading' }); + + useEffect(() => { + let cancelled = false; + debug('[tinyplace][ui] AgentProfileModal: fetching full profile for a directory entry'); + + void apiClient.graphql + .user(agent.agentId) + .then(profile => { + if (cancelled) return; + if (profile) { + debug('[tinyplace][ui] AgentProfileModal: full profile loaded'); + setFetchState({ status: 'ok', profile }); + } else { + debug('[tinyplace][ui] AgentProfileModal: no profile record, using card data'); + setFetchState({ status: 'unavailable' }); + } + }) + .catch((err: unknown) => { + if (cancelled) return; + if (err instanceof PaymentRequiredError) { + debug('[tinyplace][ui] AgentProfileModal: 402 payment_required, using card data'); + } else { + debug('[tinyplace][ui] AgentProfileModal: profile fetch failed: %s', String(err)); + } + setFetchState({ status: 'unavailable' }); + }); + + return () => { + cancelled = true; + }; + }, [agent.agentId]); + + const profile = fetchState.status === 'ok' ? fetchState.profile : null; + const handle = getHandle(agent); + const initials = getInitials(agent); + const actorType = profile?.actorType?.trim() || undefined; + const bio = profile?.bio?.trim() || (agent.description ?? '').trim(); + const tags = profile?.tags && profile.tags.length > 0 ? profile.tags : getSkills(agent); + const verified = profile?.verified === true; + const joined = profile?.createdAt ? formatJoined(profile.createdAt) : null; + + return ( + {initials}}> +
+ {/* Meta row: verified badge + joined date. */} + {(verified || joined) && ( +
+ {verified && ( + + {t('agentWorld.directory.profile.verified')} + + )} + {joined && ( + + {t('agentWorld.directory.profile.joined')} {joined} + + )} +
+ )} + + {/* Bio (or an empty-state placeholder). */} +

+ {bio || t('agentWorld.directory.profile.noBio')} +

+ + {/* Skills / tags. */} + {tags.length > 0 && ( +
+

+ {t('agentWorld.directory.profile.skills')} +

+
+ {tags.map(tag => ( + + {tag} + + ))} +
+
+ )} + + {/* Soft notice when the richer profile couldn't be loaded. */} + {fetchState.status === 'unavailable' && ( +

+ {t('agentWorld.directory.profile.loadError')} +

+ )} +
+ + ); +} diff --git a/app/src/agentworld/pages/DirectorySection.test.tsx b/app/src/agentworld/pages/DirectorySection.test.tsx index 0c588afee4..b042cf8c20 100644 --- a/app/src/agentworld/pages/DirectorySection.test.tsx +++ b/app/src/agentworld/pages/DirectorySection.test.tsx @@ -4,9 +4,9 @@ * The page loads the agent directory via `apiClient.graphql.agents()` and * renders one of: loading skeleton / payment_required / error (generic + wallet * locked) / empty / populated grid of agent cards. Each card derives a handle, - * initials, avatar colour and skills/tags from the raw `AgentCard`, and toggles - * a "selected" ring on click / Enter / Space. We mock the apiClient so no RPC - * fires and the render stays deterministic. + * initials, avatar colour and skills/tags from the raw `AgentCard`, and opens + * that agent's profile modal on click / Enter / Space (GH-4927). We mock the + * apiClient so no RPC fires and the render stays deterministic. */ import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -21,7 +21,7 @@ import DirectorySection from './DirectorySection'; vi.mock('../AgentWorldShell', () => ({ apiClient: { - graphql: { agents: vi.fn() }, + graphql: { agents: vi.fn(), user: vi.fn() }, follows: { stats: vi.fn(), followers: vi.fn(), @@ -35,6 +35,7 @@ vi.mock('../AgentWorldShell', () => ({ vi.mock('../../services/walletApi', () => ({ fetchWalletStatus: vi.fn() })); const listAgents = vi.mocked(apiClient.graphql.agents); +const getUser = vi.mocked(apiClient.graphql.user); const walletStatus = vi.mocked(fetchWalletStatus); const followFollow = vi.mocked(apiClient.follows.follow); const followUnfollow = vi.mocked(apiClient.follows.unfollow); @@ -50,6 +51,8 @@ beforeEach(() => { followerCount: 0, followingCount: 0, }); + // Default: no richer GraphQL profile — the modal degrades to the card data. + getUser.mockResolvedValue(null); }); // ── Loading state ────────────────────────────────────────────────────────────── @@ -222,8 +225,14 @@ describe('populated directory grid', () => { // ── Interactions ──────────────────────────────────────────────────────────────── -describe('card selection', () => { - test('toggles the selected ring on click', async () => { +describe('card opens the profile (GH-4927)', () => { + /** Grab the outer card div (role=button, tagName DIV — Follow is a {t( diff --git a/app/src/components/settings/panels/__tests__/AIPanel.test.tsx b/app/src/components/settings/panels/__tests__/AIPanel.test.tsx index 2d319f2cb2..2daa48468d 100644 --- a/app/src/components/settings/panels/__tests__/AIPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/AIPanel.test.tsx @@ -893,6 +893,26 @@ describe('AIPanel', () => { expect(screen.queryByRole('switch', { name: /Disconnect OpenAI/i })).not.toBeInTheDocument(); }); + // Regression for #4852: the Codex auth button had a hardcoded Korean fallback + // (`Codex 인증`) because the `settings.ai.codexAuthButton` key was missing from + // every locale, so the English UI rendered Korean text. Assert the English + // label renders and no Korean survives. + it('renders the Codex auth button with the active-locale (English) label', async () => { + vi.mocked(loadAISettings).mockResolvedValue({ ...baseSettings, cloudProviders: [] }); + + renderWithProviders(); + + await waitFor(() => + expect(screen.getByRole('switch', { name: /Connect OpenAI/i })).toBeInTheDocument() + ); + + const codexButton = screen.getByRole('button', { name: /Connect Codex/i }); + expect(codexButton).toBeInTheDocument(); + // The Korean fallback must be gone from the English onboarding screen. + expect(screen.queryByText(/인증/)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Codex 인증/i })).not.toBeInTheDocument(); + }); + it('connects OpenAI through Codex CLI auth without storing an API key', async () => { vi.mocked(loadAISettings).mockResolvedValue({ ...baseSettings, cloudProviders: [] }); @@ -902,7 +922,7 @@ describe('AIPanel', () => { expect(screen.getByRole('switch', { name: /Connect OpenAI/i })).toBeInTheDocument() ); - fireEvent.click(screen.getByRole('button', { name: /Codex 인증/i })); + fireEvent.click(screen.getByRole('button', { name: /Connect Codex/i })); await waitFor(() => expect(vi.mocked(importOpenAiCodexCliAuth)).toHaveBeenCalledTimes(1)); expect(vi.mocked(startOpenAiCodexOAuth)).not.toHaveBeenCalled(); @@ -947,7 +967,7 @@ describe('AIPanel', () => { expect(screen.getByRole('switch', { name: /Connect OpenAI/i })).toBeInTheDocument() ); - fireEvent.click(screen.getByRole('button', { name: /Codex 인증/i })); + fireEvent.click(screen.getByRole('button', { name: /Connect Codex/i })); await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent(expectedMessage)); expect(vi.mocked(setCloudProviderKey)).not.toHaveBeenCalled(); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 0a3dd433c0..57832ea70e 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -4523,6 +4523,8 @@ const messages: TranslationMap = { 'settings.ai.openRouterOauthDescription': 'وقع مع OpenRouter وإستيراد مفتاح ×xxxxx متحكم به مستخدماً', 'settings.ai.connecting': 'جارٍ الاتصال...', + 'settings.ai.codexAuthButton': 'ربط Codex', + 'settings.ai.codexAuthHelper': 'يستخدم تسجيل دخول Codex CLI الحالي من ~/.codex/auth.json.', 'settings.ai.backgroundLoops': 'حلقات الخلفية', 'settings.ai.backgroundLoopsDesc': 'شاهدْ ما يَعْملُ بدون رسالةِ دردشةِ، يَتوقّفُ عملَ نبضات القلب، ويَتفحصُ مؤخراً دفترِ دفاترِ الإئتمانِ.', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 3138bdb6cb..491867a9c8 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -4638,6 +4638,8 @@ const messages: TranslationMap = { 'settings.ai.openRouterOauthDescription': 'xqxqxx সহযোগে সাইন করুন এবং xqxyqx ব্যবহার করে একটি ব্যবহারকারী xxqx কী ইম্পোর্ট করুন', 'settings.ai.connecting': 'সংযোগ করা হচ্ছে...', + 'settings.ai.codexAuthButton': 'Codex সংযুক্ত করুন', + 'settings.ai.codexAuthHelper': '~/.codex/auth.json থেকে বিদ্যমান Codex CLI লগইন ব্যবহার করে।', 'settings.ai.backgroundLoops': 'ব্যাকগ্রাউন্ড লুপস', 'settings.ai.backgroundLoopsDesc': 'লক্ষ্য করুন যে, কোন আড্ডা ছাড়াই কি করা হয়, হার্টবিটের কাজ বিরতি দিন এবং সম্প্রতি ক্রেডিট কার্ড পরীক্ষা করুন ।', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 26e34222d8..dee022958d 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -4769,6 +4769,9 @@ const messages: TranslationMap = { 'settings.ai.openRouterOauthDescription': 'Melden Sie sich mit OpenRouter an und importieren Sie einen benutzergesteuerten API-Schlüssel mit PKCE.', 'settings.ai.connecting': 'Verbindung wird hergestellt...', + 'settings.ai.codexAuthButton': 'Codex verbinden', + 'settings.ai.codexAuthHelper': + 'Verwendet die vorhandene Codex-CLI-Anmeldung aus ~/.codex/auth.json.', 'settings.ai.backgroundLoops': 'Hintergrundschleifen', 'settings.ai.backgroundLoopsDesc': 'Sehen Sie, was ohne Chat-Nachricht läuft, pausieren Sie die Herzschlagarbeit und überprüfen Sie die letzten Kreditbuchzeilen.', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 70e22d5260..7540db7f1b 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -5344,6 +5344,8 @@ const en: TranslationMap = { 'settings.ai.openRouterOauthDescription': 'Sign in with OpenRouter and import a user-controlled API key using PKCE.', 'settings.ai.connecting': 'Connecting...', + 'settings.ai.codexAuthButton': 'Connect Codex', + 'settings.ai.codexAuthHelper': 'Uses the existing Codex CLI login from ~/.codex/auth.json.', 'settings.ai.backgroundLoops': 'Background loops', 'settings.ai.backgroundLoopsDesc': 'See what runs without a chat message, pause heartbeat work, and inspect recent credit ledger rows.', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 0c2cd3455e..aa8eaef771 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -4717,6 +4717,9 @@ const messages: TranslationMap = { 'settings.ai.openRouterOauthDescription': 'Inicia sesión con OpenRouter e importa una clave API controlada por el usuario usando PKCE.', 'settings.ai.connecting': 'Conectando...', + 'settings.ai.codexAuthButton': 'Conectar Codex', + 'settings.ai.codexAuthHelper': + 'Usa el inicio de sesión existente de Codex CLI desde ~/.codex/auth.json.', 'settings.ai.backgroundLoops': 'Bucles de fondo', 'settings.ai.backgroundLoopsDesc': 'Vea qué se ejecuta sin un mensaje de chat, pause el trabajo de latido y examine las filas recientes del libro mayor de créditos.', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 18ca04b8db..14d7f4a900 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -4746,6 +4746,9 @@ const messages: TranslationMap = { 'settings.ai.openRouterOauthDescription': "Connectez-vous avec OpenRouter et importez une clé API contrôlée par l'utilisateur en utilisant PKCE.", 'settings.ai.connecting': 'Connexion...', + 'settings.ai.codexAuthButton': 'Connecter Codex', + 'settings.ai.codexAuthHelper': + 'Utilise la connexion Codex CLI existante depuis ~/.codex/auth.json.', 'settings.ai.backgroundLoops': 'Boucles de fond', 'settings.ai.backgroundLoopsDesc': "Voyez ce qui s'exécute sans message de discussion, interrompez le travail du battement de cœur et inspectez les lignes récentes du grand livre de crédit.", diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 330e0c9bfc..7fb5a9dd68 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -4637,6 +4637,8 @@ const messages: TranslationMap = { 'settings.ai.openRouterOauthDescription': 'OpenRouter के साथ साइन इन करें और PKCE का उपयोग करके उपयोगकर्ता नियंत्रित API कुंजी आयात करें।', 'settings.ai.connecting': 'कनेक्ट हो रहा है...', + 'settings.ai.codexAuthButton': 'Codex कनेक्ट करें', + 'settings.ai.codexAuthHelper': '~/.codex/auth.json से मौजूदा Codex CLI लॉगिन का उपयोग करता है।', 'settings.ai.backgroundLoops': 'पृष्ठभूमि लूप', 'settings.ai.backgroundLoopsDesc': 'क्या एक चैट संदेश के बिना चलाता है, दिल की धड़कन काम को रोकें, और हाल ही में क्रेडिट लेजर पंक्तियों का निरीक्षण करें।', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index d5b348d7e4..126d0e8c01 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -4653,6 +4653,8 @@ const messages: TranslationMap = { 'settings.ai.openRouterOauthDescription': 'Masuk dengan kunci OpenRouter dan impor sebuah user yang dikendalikan API menggunakan PKCE.', 'settings.ai.connecting': 'Menghubungkan...', + 'settings.ai.codexAuthButton': 'Hubungkan Codex', + 'settings.ai.codexAuthHelper': 'Menggunakan login Codex CLI yang ada dari ~/.codex/auth.json.', 'settings.ai.backgroundLoops': 'Perulangan latar belakang', 'settings.ai.backgroundLoopsDesc': 'Lihat apa yang berjalan tanpa pesan obrolan, jeda kerja detak jantung, dan memeriksa buku kas kredit baru-baru ini.', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 29f28efa2b..676751c944 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -4711,6 +4711,8 @@ const messages: TranslationMap = { 'settings.ai.openRouterOauthDescription': "Accedi con OpenRouter e importa una chiave API controllata dall'utente usando PKCE.", 'settings.ai.connecting': 'Connessione in corso...', + 'settings.ai.codexAuthButton': 'Connetti Codex', + 'settings.ai.codexAuthHelper': 'Usa il login Codex CLI esistente da ~/.codex/auth.json.', 'settings.ai.backgroundLoops': 'Loop in background', 'settings.ai.backgroundLoopsDesc': 'Vedi cosa funziona senza un messaggio di chat, metti in pausa il lavoro del battito cardiaco e ispeziona le righe recenti del libro mastro dei crediti.', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 2d255d1c7c..503625b04e 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -4585,6 +4585,8 @@ const messages: TranslationMap = { 'settings.ai.openRouterOauthDescription': 'OpenRouter로 로그인하고 PKCE를 사용해 사용자가 제어하는 API 키를 가져옵니다.', 'settings.ai.connecting': '연결 중...', + 'settings.ai.codexAuthButton': 'Codex 연결', + 'settings.ai.codexAuthHelper': '~/.codex/auth.json의 기존 Codex CLI 로그인을 사용합니다.', 'settings.ai.backgroundLoops': '백그라운드 루프', 'settings.ai.backgroundLoopsDesc': '채팅 메시지 없이 실행되는 항목을 확인하고, 하트비트 작업을 일시 중지하며, 최근 크레딧 원장 행을 검사합니다.', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 025c3fe992..c89ad8d050 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -4707,6 +4707,8 @@ const messages: TranslationMap = { 'settings.ai.openRouterOauthDescription': 'Zaloguj się przez OpenRouter i zaimportuj kontrolowany przez użytkownika klucz API z użyciem PKCE.', 'settings.ai.connecting': 'Łączenie...', + 'settings.ai.codexAuthButton': 'Połącz Codex', + 'settings.ai.codexAuthHelper': 'Używa istniejącego logowania Codex CLI z ~/.codex/auth.json.', 'settings.ai.backgroundLoops': 'Pętle w tle', 'settings.ai.backgroundLoopsDesc': 'Zobacz, co działa bez wiadomości na czacie, wstrzymaj pracę heartbeat i sprawdź ostatnie wiersze księgi kredytów.', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 84bc12ba9c..845114238b 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -4704,6 +4704,8 @@ const messages: TranslationMap = { 'settings.ai.openRouterOauthDescription': 'Faça login com OpenRouter e importe uma chave API controlada pelo usuário usando PKCE.', 'settings.ai.connecting': 'Conectando...', + 'settings.ai.codexAuthButton': 'Conectar Codex', + 'settings.ai.codexAuthHelper': 'Usa o login existente da CLI do Codex de ~/.codex/auth.json.', 'settings.ai.backgroundLoops': 'Loops de segundo plano.', 'settings.ai.backgroundLoopsDesc': 'Veja o que funciona sem uma mensagem de chat, pause o trabalho do pulso e inspecione as linhas recentes do livro razão de créditos.', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 667528bde8..2e82561026 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -4681,6 +4681,8 @@ const messages: TranslationMap = { 'settings.ai.openRouterOauthDescription': 'Войдите в систему с помощью OpenRouter и импортируйте управляемый пользователем ключ API с помощью PKCE.', 'settings.ai.connecting': 'Подключение...', + 'settings.ai.codexAuthButton': 'Подключить Codex', + 'settings.ai.codexAuthHelper': 'Использует существующий вход Codex CLI из ~/.codex/auth.json.', 'settings.ai.backgroundLoops': 'Фоновые циклы', 'settings.ai.backgroundLoopsDesc': 'Посмотрите, что выполняется без сообщения чата, приостановите работу Heartbeat и проверьте последние строки кредитной книги.', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 99df09b5d3..678738cee6 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -4387,6 +4387,8 @@ const messages: TranslationMap = { 'settings.ai.openRouterOauthDescription': '使用 OpenRouter 登录,并通过 PKCE 导入由用户控制的 API 密钥。', 'settings.ai.connecting': '正在连接...', + 'settings.ai.codexAuthButton': '连接 Codex', + 'settings.ai.codexAuthHelper': '使用来自 ~/.codex/auth.json 的现有 Codex CLI 登录。', 'settings.ai.backgroundLoops': '背景循环', 'settings.ai.backgroundLoopsDesc': '查看没有聊天消息时运行的内容,暂停心跳任务,并检查最近的额度账本行。', From 7790c5d500a70d480832197527dc5cf640cd0816 Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Sat, 18 Jul 2026 09:12:32 +0530 Subject: [PATCH 72/86] fix(composio): stop auto-registering unsyncable toolkits as memory sources (Closes #4957) (#4993) --- src/openhuman/memory_sync/composio/bus.rs | 63 ++++--- .../memory_sync/composio/bus_tests.rs | 31 +++- src/openhuman/tinycortex/sync.rs | 155 ++++++++++++++++++ 3 files changed, 227 insertions(+), 22 deletions(-) diff --git a/src/openhuman/memory_sync/composio/bus.rs b/src/openhuman/memory_sync/composio/bus.rs index bd63db8bc2..3d1187a387 100644 --- a/src/openhuman/memory_sync/composio/bus.rs +++ b/src/openhuman/memory_sync/composio/bus.rs @@ -62,6 +62,20 @@ use crate::openhuman::composio::client::ComposioClient; use crate::openhuman::composio::ops; use crate::openhuman::composio::FetchConnectedIntegrationsStatus; +/// Whether a Composio `toolkit` may be auto-registered as a memory source. +/// +/// A toolkit is registrable iff a native memory-sync provider exists for it in +/// the registry (the single source of truth shared with the +/// `memory_sources.supported_toolkits` RPC). A toolkit with no provider has no +/// `build_pipeline` arm, so registering it would report ACTIVE and then fail +/// every sync with "tinycortex sync does not support toolkit" — the silent lie +/// of #4957. Both auto-register sites in the connection-created handler skip +/// non-registrable toolkits. Extracted as a pure predicate so the skip decision +/// is unit-testable without driving the async event handler. +fn toolkit_is_memory_source_registrable(toolkit: &str) -> bool { + get_provider(toolkit).is_some() +} + /// Env var that **disables** the triage pipeline. The pipeline is /// enabled by default; set to `1`/`true`/`yes` to opt out (e.g. for /// debugging or in environments where LLM calls on every Composio @@ -637,26 +651,19 @@ impl EventHandler for ComposioConnectionCreatedSubscriber { ); } else { let Some(provider) = get_provider(&toolkit) else { - tracing::debug!( + // No native memory-sync provider → this toolkit cannot ingest + // into memory. Do NOT auto-register it as a memory source: a + // source that reports ACTIVE and then fails every sync with + // "tinycortex sync does not support toolkit" is a silent lie + // to the user (#4957). The connection stays a valid agent-tool + // integration; it simply never becomes a memory source until a + // pipeline lands (tracked per-toolkit in #4958+). The cache + // refresh above already ran for every toolkit. + tracing::info!( toolkit = %toolkit, - "[composio:bus] no provider registered for toolkit; cache refreshed, no provider hook to dispatch" + connection_id = %connection_id, + "[composio:bus] no memory-sync provider for toolkit; skipping memory_sources auto-register (not syncable, #4957)" ); - // Still fall through to auto-register below. - let label = format!("{toolkit} connection"); - if let Err(e) = crate::openhuman::memory_sources::upsert_composio_source( - &toolkit, - &connection_id, - &label, - ) - .await - { - tracing::warn!( - toolkit = %toolkit, - connection_id = %connection_id, - error = %e, - "[composio:bus] memory_sources auto-register failed (non-fatal)" - ); - } return; }; @@ -698,9 +705,23 @@ impl EventHandler for ComposioConnectionCreatedSubscriber { } } - // Auto-register this connection in the memory_sources registry so - // it appears in the unified sources list regardless of whether the - // initial sync ran. + // Auto-register this connection in the memory_sources registry so it + // appears in the unified sources list regardless of whether the + // initial sync ran — but ONLY for toolkits that can actually sync. + // The provider registry is the single source of truth shared with the + // `memory_sources.supported_toolkits` RPC; gating here (the same check + // used above) means a toolkit with no pipeline never surfaces as a + // memory source that would silently fail every sync (#4957). This also + // guards the onboarding-incomplete path, which reaches here without + // evaluating the provider branch above. + if !toolkit_is_memory_source_registrable(&toolkit) { + tracing::info!( + toolkit = %toolkit, + connection_id = %connection_id, + "[composio:bus] no memory-sync provider for toolkit; skipping memory_sources auto-register (not syncable, #4957)" + ); + return; + } let label = format!("{toolkit} connection"); if let Err(e) = crate::openhuman::memory_sources::upsert_composio_source( &toolkit, diff --git a/src/openhuman/memory_sync/composio/bus_tests.rs b/src/openhuman/memory_sync/composio/bus_tests.rs index 5aaa5a0ea3..1024bed339 100644 --- a/src/openhuman/memory_sync/composio/bus_tests.rs +++ b/src/openhuman/memory_sync/composio/bus_tests.rs @@ -1 +1,30 @@ -// Placeholder — tests live in bus.rs inline or will be added here. +//! Unit tests for the composio connection-created event handler's gating. + +use super::toolkit_is_memory_source_registrable; +use crate::openhuman::memory_sync::composio::init_default_composio_sync_providers; + +/// #4957 regression: the connection-created handler must only auto-register a +/// toolkit as a memory source when a native memory-sync provider exists for it. +/// This locks the skip decision (`toolkit_is_memory_source_registrable`) that +/// both auto-register sites in `handle` consult — a toolkit with no provider +/// (the prod offenders `googlecalendar` / `googlesheets`) has no +/// `build_pipeline` arm and must be skipped, never becoming a memory source +/// that reports ACTIVE and then silently fails every sync. +#[test] +fn only_provider_backed_toolkits_are_memory_source_registrable() { + init_default_composio_sync_providers(); + + // Built-in providers exist → registrable. + assert!(toolkit_is_memory_source_registrable("gmail")); + assert!(toolkit_is_memory_source_registrable("slack")); + assert!(toolkit_is_memory_source_registrable("github")); + + // No provider → skipped (the exact #4957 failures the human hit in prod). + assert!(!toolkit_is_memory_source_registrable("googlecalendar")); + assert!(!toolkit_is_memory_source_registrable("googlesheets")); + // Unknown / empty slugs are likewise not registrable. + assert!(!toolkit_is_memory_source_registrable( + "definitely-not-a-toolkit" + )); + assert!(!toolkit_is_memory_source_registrable("")); +} diff --git a/src/openhuman/tinycortex/sync.rs b/src/openhuman/tinycortex/sync.rs index 482bc63922..797346e058 100644 --- a/src/openhuman/tinycortex/sync.rs +++ b/src/openhuman/tinycortex/sync.rs @@ -305,6 +305,30 @@ pub async fn run_gmail_backfill( .map_err(|error| SourcePipelineFailure::without_usage(error.to_string())) } +/// Composio toolkit slugs that have a native memory-sync pipeline in +/// [`build_pipeline`] — the authoritative "can actually ingest into memory" set. +/// +/// This MUST stay in lockstep with the registered memory-sync providers +/// (`memory_sync::composio::all_composio_sync_providers`): the +/// `memory_sources.supported_toolkits` RPC advertises the provider registry, and +/// the `connection_created` auto-register gates on it, so any divergence +/// reintroduces the "connection reports ACTIVE but silently never ingests" +/// failure this guards against (#4957). The registry↔pipeline equality is pinned +/// by `composio_syncable_set_matches_provider_registry` in the tests below, and +/// the arms of the `match` in [`build_pipeline`] map 1:1 to these slugs. +pub fn syncable_composio_toolkits() -> &'static [&'static str] { + &["clickup", "github", "gmail", "linear", "notion", "slack"] +} + +/// Whether `toolkit` has a native memory-sync pipeline (case-insensitive). +/// Callers deciding *whether to offer/register* a Composio source should prefer +/// the provider registry (`get_composio_sync_provider`) so there is a single +/// advertised source of truth; this mirror exists for the sync layer itself. +pub fn is_composio_toolkit_syncable(toolkit: &str) -> bool { + let slug = toolkit.trim().to_ascii_lowercase(); + syncable_composio_toolkits().contains(&slug.as_str()) +} + fn build_pipeline( source: &MemorySourceEntry, config: &Config, @@ -338,6 +362,15 @@ fn build_pipeline( .map(str::trim) .filter(|connection_id| !connection_id.is_empty()) .ok_or_else(|| "composio source missing connection_id".to_string())?; + // Fail closed *before* resolving credentials/client for any toolkit without a + // native pipeline. This keeps the unsupported-toolkit error identical to the + // match's fallback while making the syncable set a single, testable gate that + // stays pinned to the provider registry (#4957). + if !is_composio_toolkit_syncable(&toolkit) { + return Err(format!( + "tinycortex sync does not support toolkit '{toolkit}'" + )); + } let composio = composio_config(config)?; memory_config.sync.composio = Some(composio.clone()); let client = ComposioClient::new(composio); @@ -522,3 +555,125 @@ fn stage_name(stage: SyncStage) -> &'static str { SyncStage::Failed => "failed", } } + +#[cfg(test)] +mod tests { + use super::{build_pipeline, is_composio_toolkit_syncable, syncable_composio_toolkits}; + use crate::openhuman::config::Config; + use crate::openhuman::memory_sources::MemorySourceEntry; + use crate::openhuman::memory_sync::composio::{ + get_composio_sync_provider, init_default_composio_sync_providers, + }; + + /// The advertised set (`memory_sources.supported_toolkits`, sourced from the + /// provider registry) and the syncable set (`build_pipeline`) must not + /// diverge: a toolkit that is advertised but has no pipeline reports ACTIVE + /// and then silently never ingests — the exact defect of #4957. + /// + /// Both directions are asserted against an explicit built-in slug set. The + /// provider registry is process-global and sibling tests register throwaway + /// providers into it without unregistering, so walking it directly would be + /// order-flaky; pinning the built-in set keeps this deterministic. + #[test] + fn advertised_and_syncable_toolkit_sets_cannot_diverge() { + init_default_composio_sync_providers(); + + // Every syncable toolkit must have a registered provider — otherwise it + // could never be advertised or auto-registered in the first place. + for &slug in syncable_composio_toolkits() { + assert!( + get_composio_sync_provider(slug).is_some(), + "syncable toolkit `{slug}` has no registered memory-sync provider" + ); + } + + // Every built-in provider shipped by `init_default_composio_sync_providers` + // must be syncable. This is the #4957 direction: advertising a provider + // that `build_pipeline` rejects is the silent failure we guard against. + // + // We pin the built-in slug set explicitly rather than walking + // `all_composio_sync_providers()`: that registry is process-global and + // sibling tests register throwaway providers into it that they never + // unregister (e.g. `provideronly` in composio/tools_tests.rs, `stub-no-active` + // in composio/identity.rs), so a raw registry walk fails nondeterministically + // depending on test execution order. A new built-in toolkit must be added to + // this list, to `syncable_composio_toolkits`, and to `build_pipeline` together + // — the assert_eq below fails loudly if the first two ever drift apart. + const BUILTIN_SYNC_PROVIDERS: &[&str] = + &["clickup", "github", "gmail", "linear", "notion", "slack"]; + + let mut builtin = BUILTIN_SYNC_PROVIDERS.to_vec(); + builtin.sort_unstable(); + let mut syncable = syncable_composio_toolkits().to_vec(); + syncable.sort_unstable(); + assert_eq!( + builtin, syncable, + "the built-in provider set and syncable set diverged — a provider is \ + advertised without a matching `build_pipeline` arm, or vice versa (#4957)" + ); + + for &slug in BUILTIN_SYNC_PROVIDERS { + assert!( + get_composio_sync_provider(slug).is_some(), + "built-in provider `{slug}` is not registered by \ + init_default_composio_sync_providers" + ); + assert!( + is_composio_toolkit_syncable(slug), + "built-in provider `{slug}` is advertised but has no build_pipeline arm — \ + it would report ACTIVE and silently fail to sync (#4957)" + ); + } + } + + /// Behavioural regression for #4957: an unsupported Composio toolkit is + /// rejected by `build_pipeline` *before* any credential/client resolution. + /// + /// We hand it a default `Config` (no Composio auth configured). If the gate + /// ran AFTER config resolution we would get a config error ("backend bearer + /// token is not configured" / "direct API key is not configured"); instead + /// we must get the unsupported-toolkit error, proving the fail-closed + /// ordering that stops an unsyncable toolkit from ever reaching a pipeline. + #[test] + fn build_pipeline_rejects_unsupported_toolkit_before_resolving_config() { + // `googlecalendar` is a real Composio toolkit with no native pipeline — + // exactly the prod case from #4957. + let source: MemorySourceEntry = serde_json::from_value(serde_json::json!({ + "id": "composio:googlecalendar:conn-1", + "kind": "composio", + "label": "googlecalendar connection", + "toolkit": "googlecalendar", + "connection_id": "conn-1", + })) + .expect("construct composio source"); + + let config = Config::default(); + let mut memory_config = + tinycortex::memory::config::MemoryConfig::new("/tmp/openhuman-test-ws"); + + // `build_pipeline` returns `Result, String>`; the + // Ok arm is not `Debug`, so match rather than `expect_err`. + let err = match build_pipeline(&source, &config, &mut memory_config) { + Ok(_) => panic!("unsupported toolkit must be rejected before config resolution"), + Err(e) => e, + }; + assert!( + err.contains("does not support toolkit 'googlecalendar'"), + "expected the unsupported-toolkit error (proving rejection precedes \ + config resolution), got: {err}" + ); + } + + /// Locks the reported prod failures (googlecalendar / googlesheets) as + /// non-syncable, and pins case-insensitive/trimming behaviour. + #[test] + fn is_composio_toolkit_syncable_classifies_known_slugs() { + assert!(!is_composio_toolkit_syncable("googlecalendar")); + assert!(!is_composio_toolkit_syncable("googlesheets")); + assert!(!is_composio_toolkit_syncable("discord")); + assert!(!is_composio_toolkit_syncable("")); + assert!(is_composio_toolkit_syncable("gmail")); + assert!(is_composio_toolkit_syncable("Gmail")); + assert!(is_composio_toolkit_syncable(" slack ")); + } +} From 929f56d7f65cdb967db58d59501c9de0c6c33910 Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Sat, 18 Jul 2026 09:12:48 +0530 Subject: [PATCH 73/86] fix(tinyplace): real-time feed updates via feeds stream (#4926) (#4994) --- app/src/agentworld/pages/FeedSection.test.tsx | 175 ++++++++++++++++++ app/src/agentworld/pages/FeedSection.tsx | 111 ++++++++++- 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/tinyplace/manifest.rs | 20 +- src/openhuman/tinyplace/streams.rs | 11 ++ 18 files changed, 327 insertions(+), 4 deletions(-) diff --git a/app/src/agentworld/pages/FeedSection.test.tsx b/app/src/agentworld/pages/FeedSection.test.tsx index 9e8d93a529..2a36d45a63 100644 --- a/app/src/agentworld/pages/FeedSection.test.tsx +++ b/app/src/agentworld/pages/FeedSection.test.tsx @@ -41,10 +41,26 @@ vi.mock('../AgentWorldShell', () => ({ likePost: vi.fn(), unlikePost: vi.fn(), }, + streams: { start: vi.fn(), stop: vi.fn(), list: vi.fn().mockResolvedValue({ streams: [] }) }, directory: { reverse: vi.fn() }, }, })); +// ── Mock useTinyplaceStream hook ────────────────────────────────────────────── +// vi.hoisted so the mock is available inside the vi.mock factory (which is +// itself hoisted above the imports). Default: idle (no live push yet). +const { mockUseTinyplaceStream } = vi.hoisted(() => ({ + mockUseTinyplaceStream: vi.fn((_streamId?: string) => ({ + messages: [] as unknown[], + status: 'idle', + clearMessages: vi.fn(), + })), +})); + +vi.mock('../hooks/useTinyplaceStream', () => ({ + useTinyplaceStream: (streamId?: string) => mockUseTinyplaceStream(streamId), +})); + vi.mock('../../services/walletApi', () => ({ fetchWalletStatus: vi.fn() })); // ── Sample data (generic placeholders) ─────────────────────────────────────── @@ -102,6 +118,11 @@ const samplePostDetail = { beforeEach(() => { vi.clearAllMocks(); + // Feed real-time stream defaults (#4926): start resolves the canonical + // feed: id; the hook is idle until a test overrides it. + vi.mocked(apiClient.streams.start).mockResolvedValue({ streamId: `feed:${MY_AGENT_ID}` }); + vi.mocked(apiClient.streams.stop).mockResolvedValue(undefined); + mockUseTinyplaceStream.mockReturnValue({ messages: [], status: 'idle', clearMessages: vi.fn() }); vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [], count: 0 }); vi.mocked(apiClient.graphql.post).mockResolvedValue(samplePostDetail); vi.mocked(apiClient.graphql.user).mockResolvedValue({ @@ -740,6 +761,160 @@ describe('delete actions', () => { }); }); +// ── Feed real-time stream (#4926) ──────────────────────────────────────────── + +describe('feed real-time stream', () => { + test("starts the viewer's own feed stream on mount", async () => { + render(); + // Wallet resolves to MY_AGENT_ID, so the panel subscribes to feed:. + // Before the fix nothing in FeedSection subscribed to any stream. + await waitFor(() => { + expect(vi.mocked(apiClient.streams.start)).toHaveBeenCalledWith('feed', MY_AGENT_ID); + }); + }); + + test('does not start a feed stream when no wallet is configured', async () => { + // No solana account → agentId is null → nothing to subscribe to. + vi.mocked(fetchWalletStatus).mockResolvedValue({ accounts: [] } as any); + render(); + await waitFor(() => { + expect(screen.getByText(/set up your wallet/i)).toBeInTheDocument(); + }); + expect(vi.mocked(apiClient.streams.start)).not.toHaveBeenCalled(); + }); + + test('refetches the home feed when a feed stream event arrives (no manual refresh)', async () => { + const { rerender } = render(); + // Wait for the initial wallet-gated fetch to land. + await waitFor(() => { + expect(vi.mocked(apiClient.graphql.homeFeed)).toHaveBeenCalled(); + }); + const callsBefore = vi.mocked(apiClient.graphql.homeFeed).mock.calls.length; + + // A new event lands on the viewer's feed stream. Keep `status` idle so this + // asserts the *event* drives the refetch — not a status transition. + mockUseTinyplaceStream.mockReturnValue({ + messages: [{ stream_id: `feed:${MY_AGENT_ID}`, kind: 'feed', message: {} }], + status: 'idle', + clearMessages: vi.fn(), + }); + rerender(); + + // The feed re-fetches on its own. Fails before the fix (feed only fetched on + // mount + explicit refetch), passes after. + await waitFor(() => { + expect(vi.mocked(apiClient.graphql.homeFeed).mock.calls.length).toBeGreaterThan(callsBefore); + }); + }); + + test('shows the Live indicator once the feed stream is connected', async () => { + mockUseTinyplaceStream.mockReturnValue({ + messages: [], + status: 'connected', + clearMessages: vi.fn(), + }); + render(); + expect(await screen.findByTestId('feed-live-indicator')).toBeInTheDocument(); + }); + + test('stops the feed stream it started when the panel unmounts', async () => { + const { unmount } = render(); + await waitFor(() => { + expect(vi.mocked(apiClient.streams.start)).toHaveBeenCalledWith('feed', MY_AGENT_ID); + }); + + // Teardown must stop the started stream with its resolved id, not leak it + // (the gap CodeRabbit flagged on the sibling DM PR #4988). + unmount(); + await waitFor(() => { + expect(vi.mocked(apiClient.streams.stop)).toHaveBeenCalledWith(`feed:${MY_AGENT_ID}`); + }); + }); + + test('a live event merges new posts without collapsing expanded "Load more" pages', async () => { + const user = userEvent.setup(); + const liveItem = { + ...sampleFeedItem, + post: { + ...samplePost, + postId: 'post-live', + body: 'LIVENEWPOST', + createdAt: new Date(Date.UTC(2026, 1, 1)).toISOString(), + }, + }; + vi.mocked(apiClient.graphql.homeFeed) + .mockResolvedValueOnce(buildFeedPage(FEED_PAGE_SIZE, 0)) // mount: page one + .mockResolvedValueOnce(buildFeedPage(FEED_PAGE_SIZE, FEED_PAGE_SIZE)) // Load more: page two + // The live-event merge refetches page one; it now carries a brand-new post. + .mockResolvedValue({ + items: [liveItem, ...buildFeedPage(FEED_PAGE_SIZE - 1, 0).items], + count: 1000, + }); + + const { rerender } = render(); + await waitFor(() => expect(screen.getAllByText('PAGEDPOST')).toHaveLength(FEED_PAGE_SIZE)); + + // Expand to two pages (100 items). + await user.click(screen.getByRole('button', { name: /load more/i })); + await waitFor(() => expect(screen.getAllByText('PAGEDPOST')).toHaveLength(FEED_PAGE_SIZE * 2)); + + // A live event arrives on the viewer's feed stream. + mockUseTinyplaceStream.mockReturnValue({ + messages: [{ stream_id: `feed:${MY_AGENT_ID}`, kind: 'feed', message: {} }], + status: 'idle', + clearMessages: vi.fn(), + }); + rerender(); + + // The new post surfaces at the top… + expect(await screen.findByText('LIVENEWPOST')).toBeInTheDocument(); + // …and the two expanded pages are NOT collapsed back to page one — the bug + // oxoxDev flagged: resetting to firstPageFeedState would drop these to 49. + expect(screen.getAllByText('PAGEDPOST')).toHaveLength(FEED_PAGE_SIZE * 2); + }); + + test('keeps refreshing after the stream buffer caps at 100 events', async () => { + vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue(buildFeedPage(3, 0)); + const { rerender } = render(); + await waitFor(() => expect(vi.mocked(apiClient.graphql.homeFeed)).toHaveBeenCalled()); + + // `useTinyplaceStream` caps `messages` at 100. Simulate a full buffer whose + // newest element (index 99) has a distinct identity per event. + const full = (tag: string) => + Array.from({ length: 100 }, (_, i) => ({ + stream_id: `feed:${MY_AGENT_ID}`, + kind: 'feed', + message: { seq: i === 99 ? tag : String(i) }, + })); + + mockUseTinyplaceStream.mockReturnValue({ + messages: full('a'), + status: 'idle', + clearMessages: vi.fn(), + }); + rerender(); + await waitFor(() => + expect(vi.mocked(apiClient.graphql.homeFeed).mock.calls.length).toBeGreaterThanOrEqual(2) + ); + const callsAfterFirst = vi.mocked(apiClient.graphql.homeFeed).mock.calls.length; + + // A further event shifts the buffer (drop oldest, append new): length STAYS + // 100 but the newest element is a fresh object. A length-keyed effect would + // never fire again here; keying on last-message identity still does. + mockUseTinyplaceStream.mockReturnValue({ + messages: full('b'), + status: 'idle', + clearMessages: vi.fn(), + }); + rerender(); + await waitFor(() => + expect(vi.mocked(apiClient.graphql.homeFeed).mock.calls.length).toBeGreaterThan( + callsAfterFirst + ) + ); + }); +}); + // ── Pagination (offset-based "Load more", #4923) ───────────────────────────── /** Build a home-feed page of `n` items with sequential ids from `start`. */ diff --git a/app/src/agentworld/pages/FeedSection.tsx b/app/src/agentworld/pages/FeedSection.tsx index 8afa9ac52a..b0a2ffc229 100644 --- a/app/src/agentworld/pages/FeedSection.tsx +++ b/app/src/agentworld/pages/FeedSection.tsx @@ -34,6 +34,7 @@ import { useT } from '../../lib/i18n/I18nContext'; import { fetchWalletStatus } from '../../services/walletApi'; import { apiClient } from '../AgentWorldShell'; import ConfirmDialog from '../components/ConfirmDialog'; +import { useTinyplaceStream } from '../hooks/useTinyplaceStream'; import { relativeTime } from './relativeTime'; const log = debug('agentworld:feed'); @@ -645,6 +646,18 @@ export default function FeedSection() { const { agentId: myAgentId, configured: walletConfigured } = useWalletResolution(); + // ── Real-time feed updates (#4926) ───────────────────────────────────────── + // The SDK exposes a per-feed WebSocket stream (`feeds::stream`); core wires it + // as the `feed` StreamKind. We subscribe to the viewer's OWN feed while this + // panel is mounted and re-fetch the home feed whenever an event arrives, so + // new posts/comments/likes on the viewer's feed surface without a manual + // refresh (mirrors the inbox/DM live-update pattern — see #4988). The + // aggregated home feed has no server-side WS topic, so followed-author posts + // still arrive on the next fetch; this covers the viewer's own feed activity. + const feedStreamId = myAgentId ? `feed:${myAgentId}` : undefined; + const { messages: streamMessages, status: streamStatus } = useTinyplaceStream(feedStreamId); + const feedStreamRef = useRef(null); + // Guards async setState after unmount. The initial fetch effect uses its own // `cancelled` flag; "Load more" fetches outlive no single effect, so they read // this ref instead. @@ -869,14 +882,96 @@ export default function FeedSection() { // ── Refetch feed ─────────────────────────────────────────────────────────── - // A fresh compose invalidates offsets, so reset pagination to the first page. - const refetchFeed = () => { + // A fresh compose/delete invalidates offsets, so reset pagination to the first + // page. `useCallback` keeps a stable identity for the callers below. + const refetchFeed = useCallback(() => { void apiClient.graphql .homeFeed({ limit: FEED_PAGE_SIZE, offset: 0, includeSelf: true }) .then(result => { setFeedState(firstPageFeedState(result)); }); - }; + }, []); + + // Reconcile a live feed event WITHOUT collapsing pagination. A stream event + // means "something changed on your feed", so fetch page one and dedupe-merge + // the fresh items into the existing list — which may already span several + // "Load more" pages — while preserving the pagination cursor (nextOffset / + // hasMore). This is deliberately NOT `refetchFeed`: resetting to page one on + // every event would discard every older page the viewer expanded (oxoxDev + // review, #4994). New items sort to the top; already-loaded pages stay put. + const mergeLiveFeedUpdate = useCallback(() => { + void apiClient.graphql + .homeFeed({ limit: FEED_PAGE_SIZE, offset: 0, includeSelf: true }) + .then(result => { + if (!mountedRef.current) return; + const page = Array.isArray(result?.items) ? result.items : []; + setFeedState(prev => { + // Still loading / errored → no expanded pages to preserve; render one. + if (prev.status !== 'ok') return firstPageFeedState(result); + const seen = new Set(prev.items.map(item => item.post.postId)); + const fresh = page.filter(item => !seen.has(item.post.postId)); + if (fresh.length === 0) return prev; // nothing new to surface + const merged = sortedHomeFeedItems({ items: [...prev.items, ...fresh] }); + log('live feed event merged', { fresh: fresh.length, total: merged.length }); + return { ...prev, items: merged }; + }); + }) + .catch((err: unknown) => { + if (!mountedRef.current) return; + log('live feed merge failed: %s', String(err)); + }); + }, []); + + // ── Start / stop the viewer's own feed stream ────────────────────────────── + // Open the stream while a resolved wallet is present; stop it on unmount / + // identity change. Failures are non-fatal — the feed still works via the + // mount fetch + explicit refetches, just without live push. Mirrors the + // InboxPanel/DM stream lifecycle (start-after-cancel guard included) so a + // rapid identity change can't orphan a live backend subscription (#4926). + useEffect(() => { + if (!myAgentId) return; + let cancelled = false; + void apiClient.streams + .start('feed', myAgentId) + .then(res => { + if (cancelled) { + void apiClient.streams.stop(res.streamId).catch(err => { + log('feed stream stop-after-cancel failed (%s): %s', res.streamId, String(err)); + }); + return; + } + feedStreamRef.current = res.streamId; + log('feed stream started: %s', res.streamId); + }) + .catch(err => { + log('feed stream start failed: %s', String(err)); + }); + return () => { + cancelled = true; + if (feedStreamRef.current !== null) { + const stopId = feedStreamRef.current; + void apiClient.streams.stop(stopId).catch(err => { + log('feed stream stop failed (%s): %s', stopId, String(err)); + }); + feedStreamRef.current = null; + } + }; + }, [myAgentId]); + + // Reconcile the open feed whenever a new stream event arrives. Key the effect + // on the NEWEST message's identity, not `streamMessages.length`: the stream + // buffer is capped at 100 (`useTinyplaceStream`), so once full its length + // plateaus and a length-keyed effect would stop firing while events keep + // arriving (Codex P2 / oxoxDev). The buffer appends a fresh object per event + // (`[...prev.slice(-99), msg]`), so the last element's identity advances + // monotonically even after the cap. Merge (not reset) so expanded pages stay. + const lastStreamMessage = + streamMessages.length > 0 ? streamMessages[streamMessages.length - 1] : null; + useEffect(() => { + if (!myAgentId || !lastStreamMessage) return; + log('feed stream event -> merging live feed update'); + mergeLiveFeedUpdate(); + }, [lastStreamMessage, myAgentId, mergeLiveFeedUpdate]); // ── Render ───────────────────────────────────────────────────────────────── @@ -971,6 +1066,16 @@ export default function FeedSection() { return ( + {streamStatus === 'connected' && ( +
+ + + {t('agentworld.feed.live', 'Live')} + +
+ )} {myAgentId && feedState.status === 'ok' && ( )} diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 57832ea70e..049de7b6d3 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -7046,6 +7046,7 @@ const messages: TranslationMap = { 'agentworld.jobs.applyModal.bidAmountPlaceholder': 'مثال: 450 USDC', 'agentworld.jobs.applyModal.deliveryLabel': 'وقت التسليم المتوقع', 'agentworld.jobs.applyModal.deliveryPlaceholder': 'مثال: أسبوعان', + 'agentworld.feed.live': 'مباشر', 'agentworld.jobs.applyModal.cancel': 'إلغاء', 'agentworld.jobs.applyModal.submit': 'إرسال الطلب', 'agentworld.jobs.applyModal.submitting': 'جارٍ التقديم…', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 491867a9c8..f977b5e9a2 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -7212,6 +7212,7 @@ const messages: TranslationMap = { 'agentworld.jobs.applyModal.bidAmountPlaceholder': 'যেমন: 450 USDC', 'agentworld.jobs.applyModal.deliveryLabel': 'আনুমানিক ডেলিভারি', 'agentworld.jobs.applyModal.deliveryPlaceholder': 'যেমন: 2 সপ্তাহ', + 'agentworld.feed.live': 'লাইভ', 'agentworld.jobs.applyModal.cancel': 'বাতিল', 'agentworld.jobs.applyModal.submit': 'আবেদন জমা দিন', 'agentworld.jobs.applyModal.submitting': 'আবেদন করা হচ্ছে…', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index dee022958d..5f8be7d87a 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -7426,6 +7426,7 @@ const messages: TranslationMap = { 'agentworld.jobs.applyModal.bidAmountPlaceholder': 'z. B. 450 USDC', 'agentworld.jobs.applyModal.deliveryLabel': 'Voraussichtliche Lieferzeit', 'agentworld.jobs.applyModal.deliveryPlaceholder': 'z. B. 2 Wochen', + 'agentworld.feed.live': 'Live', 'agentworld.jobs.applyModal.cancel': 'Abbrechen', 'agentworld.jobs.applyModal.submit': 'Bewerbung einreichen', 'agentworld.jobs.applyModal.submitting': 'Wird eingereicht…', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 7540db7f1b..440e560973 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -7585,6 +7585,7 @@ const en: TranslationMap = { 'agentworld.jobs.applyModal.bidAmountPlaceholder': 'e.g. 450 USDC', 'agentworld.jobs.applyModal.deliveryLabel': 'Estimated Delivery', 'agentworld.jobs.applyModal.deliveryPlaceholder': 'e.g. 2 weeks', + 'agentworld.feed.live': 'Live', 'agentworld.jobs.applyModal.cancel': 'Cancel', 'agentworld.jobs.applyModal.submit': 'Submit Application', 'agentworld.jobs.applyModal.submitting': 'Applying…', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index aa8eaef771..89c9450053 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -7363,6 +7363,7 @@ const messages: TranslationMap = { 'agentworld.jobs.applyModal.bidAmountPlaceholder': 'ej. 450 USDC', 'agentworld.jobs.applyModal.deliveryLabel': 'Entrega estimada', 'agentworld.jobs.applyModal.deliveryPlaceholder': 'ej. 2 semanas', + 'agentworld.feed.live': 'En vivo', 'agentworld.jobs.applyModal.cancel': 'Cancelar', 'agentworld.jobs.applyModal.submit': 'Enviar solicitud', 'agentworld.jobs.applyModal.submitting': 'Enviando…', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 14d7f4a900..410b71face 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -7397,6 +7397,7 @@ const messages: TranslationMap = { 'agentworld.jobs.applyModal.bidAmountPlaceholder': 'ex. 450 USDC', 'agentworld.jobs.applyModal.deliveryLabel': 'Délai estimé', 'agentworld.jobs.applyModal.deliveryPlaceholder': 'ex. 2 semaines', + 'agentworld.feed.live': 'En direct', 'agentworld.jobs.applyModal.cancel': 'Annuler', 'agentworld.jobs.applyModal.submit': 'Soumettre la candidature', 'agentworld.jobs.applyModal.submitting': 'Envoi en cours…', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 7fb5a9dd68..240f26d857 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -7207,6 +7207,7 @@ const messages: TranslationMap = { 'agentworld.jobs.applyModal.bidAmountPlaceholder': 'उदा. 450 USDC', 'agentworld.jobs.applyModal.deliveryLabel': 'अनुमानित डिलीवरी', 'agentworld.jobs.applyModal.deliveryPlaceholder': 'उदा. 2 सप्ताह', + 'agentworld.feed.live': 'लाइव', 'agentworld.jobs.applyModal.cancel': 'रद्द करें', 'agentworld.jobs.applyModal.submit': 'आवेदन सबमिट करें', 'agentworld.jobs.applyModal.submitting': 'आवेदन हो रहा है…', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 126d0e8c01..37a0fbec30 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -7244,6 +7244,7 @@ const messages: TranslationMap = { 'agentworld.jobs.applyModal.bidAmountPlaceholder': 'mis. 450 USDC', 'agentworld.jobs.applyModal.deliveryLabel': 'Estimasi Pengiriman', 'agentworld.jobs.applyModal.deliveryPlaceholder': 'mis. 2 minggu', + 'agentworld.feed.live': 'Langsung', 'agentworld.jobs.applyModal.cancel': 'Batal', 'agentworld.jobs.applyModal.submit': 'Kirim Lamaran', 'agentworld.jobs.applyModal.submitting': 'Melamar…', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 676751c944..612161102b 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -7349,6 +7349,7 @@ const messages: TranslationMap = { 'agentworld.jobs.applyModal.bidAmountPlaceholder': 'es. 450 USDC', 'agentworld.jobs.applyModal.deliveryLabel': 'Consegna stimata', 'agentworld.jobs.applyModal.deliveryPlaceholder': 'es. 2 settimane', + 'agentworld.feed.live': 'In diretta', 'agentworld.jobs.applyModal.cancel': 'Annulla', 'agentworld.jobs.applyModal.submit': 'Invia candidatura', 'agentworld.jobs.applyModal.submitting': 'Candidatura in corso…', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 503625b04e..610c15c023 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -7124,6 +7124,7 @@ const messages: TranslationMap = { 'agentworld.jobs.applyModal.bidAmountPlaceholder': '예: 450 USDC', 'agentworld.jobs.applyModal.deliveryLabel': '예상 납기', 'agentworld.jobs.applyModal.deliveryPlaceholder': '예: 2주', + 'agentworld.feed.live': '실시간', 'agentworld.jobs.applyModal.cancel': '취소', 'agentworld.jobs.applyModal.submit': '지원서 제출', 'agentworld.jobs.applyModal.submitting': '지원 중…', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index c89ad8d050..2f2aa0e550 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -7321,6 +7321,7 @@ const messages: TranslationMap = { 'agentworld.jobs.applyModal.bidAmountPlaceholder': 'np. 450 USDC', 'agentworld.jobs.applyModal.deliveryLabel': 'Szacowany czas realizacji', 'agentworld.jobs.applyModal.deliveryPlaceholder': 'np. 2 tygodnie', + 'agentworld.feed.live': 'Na żywo', 'agentworld.jobs.applyModal.cancel': 'Anuluj', 'agentworld.jobs.applyModal.submit': 'Wyślij aplikację', 'agentworld.jobs.applyModal.submitting': 'Wysyłanie…', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 845114238b..35fc908aa6 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -7330,6 +7330,7 @@ const messages: TranslationMap = { 'agentworld.jobs.applyModal.bidAmountPlaceholder': 'ex. 450 USDC', 'agentworld.jobs.applyModal.deliveryLabel': 'Prazo estimado de entrega', 'agentworld.jobs.applyModal.deliveryPlaceholder': 'ex. 2 semanas', + 'agentworld.feed.live': 'Ao vivo', 'agentworld.jobs.applyModal.cancel': 'Cancelar', 'agentworld.jobs.applyModal.submit': 'Enviar candidatura', 'agentworld.jobs.applyModal.submitting': 'A enviar…', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 2e82561026..ed2a22f2cd 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -7294,6 +7294,7 @@ const messages: TranslationMap = { 'agentworld.jobs.applyModal.bidAmountPlaceholder': 'напр. 450 USDC', 'agentworld.jobs.applyModal.deliveryLabel': 'Ориентировочный срок выполнения', 'agentworld.jobs.applyModal.deliveryPlaceholder': 'напр. 2 недели', + 'agentworld.feed.live': 'В эфире', 'agentworld.jobs.applyModal.cancel': 'Отмена', 'agentworld.jobs.applyModal.submit': 'Отправить заявку', 'agentworld.jobs.applyModal.submitting': 'Отправка…', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 678738cee6..bee42f2677 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -6817,6 +6817,7 @@ const messages: TranslationMap = { 'agentworld.jobs.applyModal.bidAmountPlaceholder': '例:450 USDC', 'agentworld.jobs.applyModal.deliveryLabel': '预计交付时间', 'agentworld.jobs.applyModal.deliveryPlaceholder': '例:2周', + 'agentworld.feed.live': '实时', 'agentworld.jobs.applyModal.cancel': '取消', 'agentworld.jobs.applyModal.submit': '提交申请', 'agentworld.jobs.applyModal.submitting': '申请中…', diff --git a/src/openhuman/tinyplace/manifest.rs b/src/openhuman/tinyplace/manifest.rs index 5a6a488d2b..fabc43a6bc 100644 --- a/src/openhuman/tinyplace/manifest.rs +++ b/src/openhuman/tinyplace/manifest.rs @@ -2604,10 +2604,12 @@ pub(crate) fn handle_tinyplace_streams_start(params: Map) -> Cont let kind = match kind_str_trimmed { "inbox" => super::streams::StreamKind::Inbox, "conversation" => super::streams::StreamKind::Conversation, + "feed" => super::streams::StreamKind::Feed, _ => return Err(format!("unsupported streamType: {kind_str_trimmed}")), }; - // conversation streams require a target id. + // conversation and feed streams require a target id (conversation_id / + // feed handle respectively); inbox is a singleton with no target. let target_id = params .get("streamId") .and_then(|v| v.as_str()) @@ -2617,6 +2619,9 @@ pub(crate) fn handle_tinyplace_streams_start(params: Map) -> Cont if kind == super::streams::StreamKind::Conversation && target_id.is_none() { return Err("streamId is required for conversation streams".to_string()); } + if kind == super::streams::StreamKind::Feed && target_id.is_none() { + return Err("streamId is required for feed streams".to_string()); + } log::debug!( "{LOG_PREFIX} streams_start kind={kind_str_trimmed} target_id={:?}", @@ -5817,6 +5822,19 @@ mod tests { assert!(err.contains("streamId"), "got: {err}"); } + /// streams_start rejects a feed stream without a streamId (the feed handle). + /// Regression for #4926: "feed" is a valid streamType but, like + /// "conversation", it is target-scoped and must carry a streamId. + #[test] + fn streams_start_feed_requires_stream_id() { + let mut params = Map::new(); + params.insert("streamType".to_string(), Value::String("feed".to_string())); + let err = block_on(handle_tinyplace_streams_start(params)).unwrap_err(); + assert!(err.contains("streamId"), "got: {err}"); + // And it must NOT be rejected as an unsupported streamType. + assert!(!err.contains("unsupported"), "got: {err}"); + } + /// streams_stop rejects a missing/blank streamId. #[test] fn streams_stop_requires_stream_id() { diff --git a/src/openhuman/tinyplace/streams.rs b/src/openhuman/tinyplace/streams.rs index 9fc7a2ae9f..0a218dea08 100644 --- a/src/openhuman/tinyplace/streams.rs +++ b/src/openhuman/tinyplace/streams.rs @@ -36,6 +36,8 @@ pub(crate) const MAX_CONCURRENT_STREAMS: usize = 5; pub(crate) enum StreamKind { Inbox, Conversation, + /// A single feed's live post/comment/like stream, keyed by feed handle. + Feed, } /// Metadata for an active stream (returned to the renderer). @@ -84,6 +86,7 @@ pub(crate) fn stream_id_for(kind: StreamKind, target_id: Option<&str>) -> String match kind { StreamKind::Inbox => "inbox".to_string(), StreamKind::Conversation => format!("conversation:{}", target_id.unwrap_or("")), + StreamKind::Feed => format!("feed:{}", target_id.unwrap_or("")), } } @@ -138,6 +141,10 @@ impl StreamManager { let conv_id = target_id.as_deref().unwrap_or(""); client.conversations.stream(conv_id, None, None) } + StreamKind::Feed => { + let handle = target_id.as_deref().unwrap_or(""); + client.feeds.stream(handle, None) + } }; // Publish connecting status. @@ -238,6 +245,7 @@ fn kind_to_str(kind: &StreamKind) -> &'static str { match kind { StreamKind::Inbox => "inbox", StreamKind::Conversation => "conversation", + StreamKind::Feed => "feed", } } @@ -324,6 +332,9 @@ mod tests { stream_id_for(StreamKind::Conversation, Some("abc123")), "conversation:abc123" ); + // Feed streams are keyed by feed handle (#4926). + assert_eq!(stream_id_for(StreamKind::Feed, Some("alice")), "feed:alice"); + assert_eq!(stream_id_for(StreamKind::Feed, None), "feed:"); } #[tokio::test] From 24864aa8f53b89cdf33903090067db45e183f9c2 Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Sat, 18 Jul 2026 09:13:28 +0530 Subject: [PATCH 74/86] refactor(core): relocate the web chat conduit out of channels/ (#5002) (#5004) --- app/src-tauri/src/claude_code.rs | 2 +- app/src-tauri/src/imessage_scanner/mod.rs | 2 +- app/src-tauri/src/lib.rs | 8 +++--- app/src-tauri/src/mascot_native_window.rs | 5 +++- app/src-tauri/src/native_notifications/mod.rs | 4 +-- app/src-tauri/src/notch_window.rs | 6 +--- app/src/services/chatService.ts | 2 +- docs/TEST-COVERAGE-MATRIX.md | 2 +- src/core/all.rs | 2 +- src/core/event_bus/events.rs | 4 +-- src/core/jsonrpc.rs | 12 ++++---- src/core/observability.rs | 10 +++---- src/core/socketio.rs | 8 +++--- src/main.rs | 2 +- src/openhuman/agent/README.md | 2 +- .../progress_tracing/journal_projection.rs | 2 +- .../agent/task_dispatcher/executor.rs | 8 +++--- .../agent/task_dispatcher/registry.rs | 20 ++++++------- src/openhuman/agent/triage/evaluator.rs | 2 +- src/openhuman/agent/turn_origin.rs | 2 +- .../agent_orchestration/command_center/mod.rs | 2 +- .../run_ledger_finalize.rs | 2 +- src/openhuman/agentbox/invoker.rs | 4 +-- src/openhuman/approval/README.md | 4 +-- src/openhuman/approval/gate.rs | 6 ++-- src/openhuman/artifacts/store.rs | 2 +- src/openhuman/channels/bus.rs | 7 ++--- src/openhuman/channels/host/adapters.rs | 2 +- src/openhuman/channels/mod.rs | 1 - src/openhuman/channels/proactive.rs | 2 +- src/openhuman/channels/providers/mod.rs | 1 - .../providers/telegram/remote_control.rs | 2 +- .../channels/runtime/dispatch/processor.rs | 4 +-- src/openhuman/channels/runtime/startup.rs | 8 +++--- src/openhuman/cron/scheduler.rs | 18 ++++++------ src/openhuman/cron/scheduler_tests.rs | 2 +- src/openhuman/flows/ops.rs | 8 +++--- .../inference/provider/error_code.rs | 2 +- src/openhuman/meet_agent/brain/llm.rs | 2 +- src/openhuman/mod.rs | 1 + src/openhuman/prompt_injection/README.md | 2 +- src/openhuman/security/egress/mod.rs | 2 +- src/openhuman/test_support/README.md | 2 +- src/openhuman/test_support/introspect.rs | 2 +- src/openhuman/threads/README.md | 4 +-- src/openhuman/threads/ops.rs | 2 +- src/openhuman/wallet/README.md | 2 +- src/openhuman/wallet/execution.rs | 2 +- .../providers/web => web_chat}/event_bus.rs | 8 +++--- .../providers/web => web_chat}/mod.rs | 3 +- .../providers/web => web_chat}/ops.rs | 6 ++-- .../web => web_chat}/presentation.rs | 0 .../web => web_chat}/presentation_tests.rs | 0 .../web => web_chat}/progress_bridge.rs | 0 .../providers/web => web_chat}/run_task.rs | 0 .../providers/web => web_chat}/schemas.rs | 0 .../providers/web => web_chat}/session.rs | 0 .../providers/web => web_chat}/types.rs | 0 .../providers => web_chat}/web_errors.rs | 0 .../providers => web_chat}/web_tests.rs | 0 tests/agent_harness_e2e.rs | 4 +-- tests/agentbox_e2e.rs | 2 +- ...io_list_tools_stack_overflow_regression.rs | 4 +-- ...nnels_bus_presentation_raw_coverage_e2e.rs | 4 +-- ...channels_large_round25_raw_coverage_e2e.rs | 2 +- ...channels_provider_deep_raw_coverage_e2e.rs | 16 +++++------ ...els_provider_leftovers_raw_coverage_e2e.rs | 2 +- .../channels_runtime_raw_coverage_e2e.rs | 2 +- .../channels_web_startup_raw_coverage_e2e.rs | 2 +- .../channels_web_telegram_raw_coverage_e2e.rs | 2 +- ...ls_web_yuanbao_round22_raw_coverage_e2e.rs | 2 +- ...ools_approval_channels_raw_coverage_e2e.rs | 28 +++++++++---------- ...tools_network_channels_raw_coverage_e2e.rs | 2 +- tests/web_cancel_request_scoping.rs | 2 +- 74 files changed, 142 insertions(+), 152 deletions(-) rename src/openhuman/{channels/providers/web => web_chat}/event_bus.rs (99%) rename src/openhuman/{channels/providers/web => web_chat}/mod.rs (98%) rename src/openhuman/{channels/providers/web => web_chat}/ops.rs (99%) rename src/openhuman/{channels/providers/web => web_chat}/presentation.rs (100%) rename src/openhuman/{channels/providers/web => web_chat}/presentation_tests.rs (100%) rename src/openhuman/{channels/providers/web => web_chat}/progress_bridge.rs (100%) rename src/openhuman/{channels/providers/web => web_chat}/run_task.rs (100%) rename src/openhuman/{channels/providers/web => web_chat}/schemas.rs (100%) rename src/openhuman/{channels/providers/web => web_chat}/session.rs (100%) rename src/openhuman/{channels/providers/web => web_chat}/types.rs (100%) rename src/openhuman/{channels/providers => web_chat}/web_errors.rs (100%) rename src/openhuman/{channels/providers => web_chat}/web_tests.rs (100%) diff --git a/app/src-tauri/src/claude_code.rs b/app/src-tauri/src/claude_code.rs index c70f960cc0..7ff7a98ed3 100644 --- a/app/src-tauri/src/claude_code.rs +++ b/app/src-tauri/src/claude_code.rs @@ -43,7 +43,7 @@ end tell"#; .args(["-e", script]) .spawn() .map_err(|e| format!("failed to open Terminal.app: {e}"))?; - return Ok("Terminal.app".into()); + Ok("Terminal.app".into()) } #[cfg(target_os = "linux")] diff --git a/app/src-tauri/src/imessage_scanner/mod.rs b/app/src-tauri/src/imessage_scanner/mod.rs index 289fd7cac0..e8cefd9929 100644 --- a/app/src-tauri/src/imessage_scanner/mod.rs +++ b/app/src-tauri/src/imessage_scanner/mod.rs @@ -372,7 +372,7 @@ fn extract_text_from_attributed_body(blob: &[u8]) -> Option { .filter_map(|r| String::from_utf8(r).ok()) .filter(|s| { let trimmed = s.trim(); - trimmed.len() >= 2 && !ignored_markers.iter().any(|m| trimmed == *m) + trimmed.len() >= 2 && !ignored_markers.contains(&trimmed) }) .max_by_key(|s| s.len()) .map(|s| s.trim().to_string()) diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index ee15b7897e..b597458325 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -1195,7 +1195,7 @@ fn mascot_window_show(app: AppHandle) -> Result<(), String> { log::info!("[mascot-window] show requested"); #[cfg(target_os = "macos")] { - return mascot_native_window::show(&app); + mascot_native_window::show(&app) } #[cfg(not(target_os = "macos"))] { @@ -1258,11 +1258,11 @@ fn notch_window_show(app: AppHandle) -> Result<(), String> { log::info!("[notch-window] show requested"); #[cfg(target_os = "macos")] { - return dispatch_notch_on_main(app, |app| { + dispatch_notch_on_main(app, |app| { if let Err(e) = notch_window::show(app) { log::warn!("[notch-window] show failed: {e}"); } - }); + }) } #[cfg(not(target_os = "macos"))] { @@ -1277,7 +1277,7 @@ fn notch_window_hide(app: AppHandle) -> Result<(), String> { log::info!("[notch-window] hide requested"); #[cfg(target_os = "macos")] { - return dispatch_notch_on_main(app, |_app| notch_window::hide()); + dispatch_notch_on_main(app, |_app| notch_window::hide()) } #[cfg(not(target_os = "macos"))] { diff --git a/app/src-tauri/src/mascot_native_window.rs b/app/src-tauri/src/mascot_native_window.rs index 063361a356..5c27a36ea2 100644 --- a/app/src-tauri/src/mascot_native_window.rs +++ b/app/src-tauri/src/mascot_native_window.rs @@ -52,6 +52,9 @@ const DRAG_POLL_SECONDS: f64 = 0.016; /// dropped webview. struct MascotPanel { panel: Retained, + // RAII keep-alive: never read, but dropping it deallocates the WKWebView and + // blanks the panel. Must outlive the show/hide cycle — do not remove. + #[allow(dead_code)] webview: Retained, drag_timer: Retained, } @@ -389,7 +392,7 @@ unsafe fn build_webview( let _: () = msg_send![&*webview, setAutoresizingMask: 18u64]; // width|height // Make the webview the panel's content view so it fills the frame. - let webview_ref: &objc2::runtime::AnyObject = &*webview; + let webview_ref: &objc2::runtime::AnyObject = &webview; let webview_view: *mut objc2::runtime::AnyObject = webview_ref as *const _ as *mut objc2::runtime::AnyObject; let _: () = msg_send![panel, setContentView: webview_view]; diff --git a/app/src-tauri/src/native_notifications/mod.rs b/app/src-tauri/src/native_notifications/mod.rs index 7fb5281e7d..18e23669a7 100644 --- a/app/src-tauri/src/native_notifications/mod.rs +++ b/app/src-tauri/src/native_notifications/mod.rs @@ -37,7 +37,7 @@ use crate::AppRuntime; pub fn notification_permission_state() -> Result { #[cfg(target_os = "macos")] { - return macos::permission_state(); + macos::permission_state() } #[cfg(not(target_os = "macos"))] { @@ -51,7 +51,7 @@ pub fn notification_permission_state() -> Result { pub fn notification_permission_request() -> Result { #[cfg(target_os = "macos")] { - return macos::request_permission(); + macos::request_permission() } #[cfg(not(target_os = "macos"))] { diff --git a/app/src-tauri/src/notch_window.rs b/app/src-tauri/src/notch_window.rs index 78653688cb..4fc3f7202a 100644 --- a/app/src-tauri/src/notch_window.rs +++ b/app/src-tauri/src/notch_window.rs @@ -61,10 +61,6 @@ thread_local! { static NOTCH: RefCell> = const { RefCell::new(None) }; } -pub(crate) fn is_open() -> bool { - NOTCH.with(|cell| cell.borrow().is_some()) -} - pub(crate) fn hide() { NOTCH.with(|cell| { if let Some(existing) = cell.borrow_mut().take() { @@ -262,7 +258,7 @@ unsafe fn build_webview( // Auto-resize to fill the panel content view. let _: () = msg_send![&*webview, setAutoresizingMask: 18u64]; // width|height - let webview_ref: &objc2::runtime::AnyObject = &*webview; + let webview_ref: &objc2::runtime::AnyObject = &webview; let webview_view = webview_ref as *const _ as *mut objc2::runtime::AnyObject; let _: () = msg_send![panel, setContentView: webview_view]; diff --git a/app/src/services/chatService.ts b/app/src/services/chatService.ts index 6fb128bb5c..f6b25e34d6 100644 --- a/app/src/services/chatService.ts +++ b/app/src/services/chatService.ts @@ -966,7 +966,7 @@ export function subscribeChatEvents(listeners: ChatEventListeners): () => void { } // Artifact lifecycle events (#2779). The Rust subscriber in - // `channels/providers/web::ArtifactSurfaceSubscriber` packs the + // `web_chat::ArtifactSurfaceSubscriber` packs the // artifact payload into the generic `args` field of the wire // envelope (kept the WebChannelEvent struct shape stable to avoid // touching ~10 existing call sites with `..Default::default()`). diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index 171ad792a9..51b9a18b1a 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -182,7 +182,7 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil | 4.2.1 | User Message Handling | WD+RI | `conversations-web-channel-flow.spec.ts`, `tests/json_rpc_e2e.rs` | ✅ | | | 4.2.2 | AI Response Generation | WD | `agent-review.spec.ts` | ✅ | Mock LLM | | 4.2.3 | Streaming Responses | RI | `tests/json_rpc_e2e.rs`, `tests/agent_harness_e2e.rs` | ✅ | `tests/agent_harness_e2e.rs` adds provider-level SSE tool-arg accumulation (chunked args reassembled + parsed) and engine-level delta forwarding (#3471) | -| 4.2.4 | Parallel inference (cross-thread + within-thread forked turns) | RU+VU | `src/openhuman/channels/providers/web_tests.rs`, `app/src/store/__tests__/chatRuntimeSlice.test.ts`, `app/src/providers/__tests__/ChatRuntimeProvider.test.tsx` | 🟡 | Concurrent same-/cross-thread dispatch, cooperative `CancellationToken` teardown, and parallel-lane stream routing covered; dedicated WD E2E is a follow-up | +| 4.2.4 | Parallel inference (cross-thread + within-thread forked turns) | RU+VU | `src/openhuman/web_chat/web_tests.rs`, `app/src/store/__tests__/chatRuntimeSlice.test.ts`, `app/src/providers/__tests__/ChatRuntimeProvider.test.tsx` | 🟡 | Concurrent same-/cross-thread dispatch, cooperative `CancellationToken` teardown, and parallel-lane stream routing covered; dedicated WD E2E is a follow-up | | 4.2.5 | Per-thread todo list (plan strip above composer) | RU+VU+WD | `src/openhuman/agent/tools/todo.rs`, `app/src/pages/conversations/components/ThreadTodoStrip.test.tsx`, `app/test/e2e/specs/chat-thread-todo-strip.spec.ts` | ✅ | Read-only thread-scoped todo strip fed by `task_board_updated`; agent `todo` tool guidance + thread binding; E2E drives a `todo` tool call and asserts the card renders | | 4.2.6 | Background-activity panel (chat-header Background tasks button) | VU+WD | `app/src/pages/conversations/hooks/useBackgroundActivity.test.ts`, `app/src/pages/conversations/components/__tests__/BackgroundActivityRows.test.tsx`, `app/test/e2e/specs/chat-background-activity-panel.spec.ts` | ✅ | View-only panel surfacing this chat's async sub-agents + global cron jobs, subconscious/heartbeat status, and memory syncing; freshness-only "Syncing now" labeling; E2E opens the panel and asserts its sections render and close | | 4.2.7 | Plan-mode review (Approve / Reject / Send-feedback before execute) | RU+RI+VU | `src/openhuman/plan_review/gate.rs`, `src/openhuman/plan_review/tool.rs`, `src/openhuman/plan_review/schemas.rs`, `tests/json_rpc_e2e.rs`, `app/src/pages/conversations/components/PlanReviewCard.test.tsx`, `app/src/pages/__tests__/Conversations.render.test.tsx` | ✅ | Interactive turns call `request_plan_review`, which parks the LIVE turn on the in-memory `PlanReviewGate` (oneshot) until the user decides; `plan_review_request` socket event drives `PlanReviewCard`, which resolves via `openhuman.plan_review_decide` (approve resumes-and-executes / reject resumes-and-stops / revise resumes-with-feedback). RU covers gate park/resolve/timeout + tool auto-approve + parking; RI covers the decide RPC; VU covers the card + provider wiring. WD E2E (agent-driven park flow) tracked as follow-up | diff --git a/src/core/all.rs b/src/core/all.rs index d686517cca..8ead22c1df 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -388,7 +388,7 @@ fn build_registered_controllers() -> Vec { push( &mut controllers, DomainGroup::Channels, - crate::openhuman::channels::providers::web::all_web_channel_registered_controllers(), + crate::openhuman::web_chat::all_web_channel_registered_controllers(), ); push( &mut controllers, diff --git a/src/core/event_bus/events.rs b/src/core/event_bus/events.rs index 2d2ee80fe7..bc9a7af811 100644 --- a/src/core/event_bus/events.rs +++ b/src/core/event_bus/events.rs @@ -579,7 +579,7 @@ pub enum DomainEvent { /// flow-approval-surface, PR2/PR3). Unlike `ApprovalRequested`, this /// event carries no `thread_id`/`client_id` — a flow run has neither, so /// the generic chat-routed socket bridge - /// (`channels::providers::web::event_bus::ApprovalSurfaceSubscriber`) + /// (`web_chat::event_bus::ApprovalSurfaceSubscriber`) /// silently drops it (that gap was the original silent-deadlock bug). /// Published by `ApprovalGate::intercept_audited` alongside the existing /// `ApprovalRequested`, bridged by `core::socketio` directly to a @@ -611,7 +611,7 @@ pub enum DomainEvent { /// /// Bridged to the `external_transfer_pending` web-channel socket event by /// `EgressSurfaceSubscriber` (defined in - /// `src/openhuman/channels/providers/web/event_bus.rs`) when the emitting + /// `src/openhuman/web_chat/event_bus.rs`) when the emitting /// turn carries chat routing. `thread_id` / `client_id` come from the /// ambient `APPROVAL_CHAT_CONTEXT` and are `None` for CLI / cron / /// background transfers (no chat surface to route to). diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index 8bcabec2f3..fca8e65268 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -1479,7 +1479,7 @@ async fn events_handler( } let client_id = query.client_id; - let rx = crate::openhuman::channels::providers::web::subscribe_web_channel_events(); + let rx = crate::openhuman::web_chat::subscribe_web_channel_events(); let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map( move |item| -> Option> { let event = match item { @@ -2350,18 +2350,18 @@ pub async fn bootstrap_core_runtime( // `start_channels` is skipped for web-chat-only cores. Without this an // 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::channels::providers::web::register_approval_surface_subscriber(); + 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::channels::providers::web::register_automation_halt_subscriber(); + 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 // with the approval gate disabled. Idempotent (OnceLock-guarded). - crate::openhuman::channels::providers::web::register_egress_surface_subscriber(); + crate::openhuman::web_chat::register_egress_surface_subscriber(); if decision.install_gate { // Per-launch correlation token for the approval gate. This is @@ -2382,7 +2382,7 @@ pub async fn bootstrap_core_runtime( ); // (The approval/plan-review surface bridge is registered unconditionally // above — it must run even when this gate-install branch is skipped.) - crate::openhuman::channels::providers::web::register_artifact_surface_subscriber(); + crate::openhuman::web_chat::register_artifact_surface_subscriber(); } else { log::error!( "[runtime] approval gate DISABLED (OPENHUMAN_APPROVAL_GATE=0 honored on host={}) — \ @@ -2402,7 +2402,7 @@ pub async fn bootstrap_core_runtime( // `if approval_gate` block so artifact events still publish when the user // sets OPENHUMAN_APPROVAL_GATE=0 (CR #3328947323 on PR #3026). Idempotent // (OnceLock-guarded inside register_artifact_surface_subscriber). - crate::openhuman::channels::providers::web::register_artifact_surface_subscriber(); + crate::openhuman::web_chat::register_artifact_surface_subscriber(); // --- Workspace migrations -------------------------------------------- crate::openhuman::startup::run_workspace_migrations(&workspace_dir); diff --git a/src/core/observability.rs b/src/core/observability.rs index d81d5b529e..1ae514c17d 100644 --- a/src/core/observability.rs +++ b/src/core/observability.rs @@ -211,7 +211,7 @@ pub enum ExpectedErrorKind { /// `agent::run_single` already suppresses the **agent-layer** Sentry /// event for this condition via the typed /// `AgentError::EmptyProviderResponse` + `AgentError::skips_sentry()` - /// (PR #2790, TAURI-RUST-4JX). But `channels::providers::web:: + /// (PR #2790, TAURI-RUST-4JX). But `web_chat:: /// run_chat_task` **re-reports** the same failure under /// `domain=web_channel operation=run_chat_task` after the typed error /// has been flattened to a `String` at the native-bus boundary — so the @@ -302,7 +302,7 @@ pub enum ExpectedErrorKind { /// (`api_error`) already demotes its own per-attempt event; this catches /// the **re-report** when the same flattened error is raised again under /// `domain=web_channel` / `agent` (the path - /// `channels::providers::web::run_chat_task` → + /// `web_chat::run_chat_task` → /// `report_error_or_expected`). The FE surfaces actionable copy via /// `classify_inference_error`; Sentry must not double-report (F2/F4). /// @@ -975,7 +975,7 @@ fn is_memory_store_breaker_open(lower: &str) -> bool { /// - `"OpenHuman API error (401 Unauthorized): {…\"Session expired. Please /// log in again.\"…}"` — emitted by `providers::ops::api_error` from the /// OpenHuman backend and re-raised through `agent::run_single` / -/// `channels::providers::web::run_chat_task` (OPENHUMAN-TAURI-26). The +/// `web_chat::run_chat_task` (OPENHUMAN-TAURI-26). The /// `"session expired"` substring anchors the match to the OpenHuman /// backend's session-renewal body, not the bare numeric status. /// - `"OpenHuman API error (401 Unauthorized): {…\"error\":\"Invalid token\"…}"` @@ -1870,7 +1870,7 @@ fn is_filesystem_user_path_invalid_message(lower: &str) -> bool { /// `text_chars=0 thinking_chars=0 tool_calls=0`. /// /// This catches the **web-channel re-report** (Sentry TAURI-RUST-4Z1): -/// `channels::providers::web::run_chat_task` wraps the failure as +/// `web_chat::run_chat_task` wraps the failure as /// `"run_chat_task failed client_id=… error=The model returned an empty /// response. Please try again."` and routes it through /// `report_error_or_expected` after the typed @@ -2598,7 +2598,7 @@ fn all_provider_attempts_are_transient(message: &str) -> bool { /// Defense-in-depth filter for the Sentry `before_send` hook: the primary /// suppression lives at the call sites in `agent::harness::session:: /// runtime::run_single`, `channels::runtime::dispatch`, and -/// `channels::providers::web::run_chat_task`, all of which now skip +/// `web_chat::run_chat_task`, all of which now skip /// `report_error` when this variant is detected. This filter catches any /// future call site that re-emits the message without going through those /// funnels — e.g. a new wrapper that calls `tracing::error!` directly with diff --git a/src/core/socketio.rs b/src/core/socketio.rs index 84afc6fc84..9459c21ddf 100644 --- a/src/core/socketio.rs +++ b/src/core/socketio.rs @@ -505,7 +505,7 @@ pub fn attach_socketio() -> (socketioxide::layer::SocketIoLayer, SocketIo) { ); // Trigger the web channel's chat logic. - match crate::openhuman::channels::providers::web::start_chat( + match crate::openhuman::web_chat::start_chat( &client_id, &payload.thread_id, &payload.message, @@ -514,7 +514,7 @@ pub fn attach_socketio() -> (socketioxide::layer::SocketIoLayer, SocketIo) { payload.profile_id, payload.locale, payload.queue_mode, - crate::openhuman::channels::providers::web::ChatRequestMetadata::default(), + crate::openhuman::web_chat::ChatRequestMetadata::default(), ) .await { @@ -556,7 +556,7 @@ pub fn attach_socketio() -> (socketioxide::layer::SocketIoLayer, SocketIo) { client_id, payload.thread_id ); - let _ = crate::openhuman::channels::providers::web::cancel_chat_scoped( + let _ = crate::openhuman::web_chat::cancel_chat_scoped( &client_id, &payload.thread_id, payload.request_id.as_deref(), @@ -607,7 +607,7 @@ pub fn spawn_web_channel_bridge(io: SocketIo) { // 1. Web channel events → per-client rooms. let io_web = io.clone(); tokio::spawn(async move { - let mut rx = crate::openhuman::channels::providers::web::subscribe_web_channel_events(); + let mut rx = crate::openhuman::web_chat::subscribe_web_channel_events(); loop { let event = match rx.recv().await { Ok(event) => event, diff --git a/src/main.rs b/src/main.rs index 45324defe9..fbd3b41672 100644 --- a/src/main.rs +++ b/src/main.rs @@ -119,7 +119,7 @@ fn main() { // slipped past the call-site filters in // `agent::harness::session::runtime::run_single`, // `channels::runtime::dispatch`, and - // `channels::providers::web::run_chat_task`. The cap is a + // `web_chat::run_chat_task`. The cap is a // deterministic agent-state outcome surfaced to the user via // the chat-rendered "Error: …" message — Sentry is the wrong // surface for it (OPENHUMAN-TAURI-99 / -98). diff --git a/src/openhuman/agent/README.md b/src/openhuman/agent/README.md index eb36cba9ad..38bd21ae24 100644 --- a/src/openhuman/agent/README.md +++ b/src/openhuman/agent/README.md @@ -27,7 +27,7 @@ Multi-agent orchestration domain. Owns the LLM tool-calling loop, sub-agent disp ## Called by -- `src/openhuman/channels/runtime/dispatch.rs` and `channels/providers/web.rs` — drive chat turns from inbound channel messages. +- `src/openhuman/channels/runtime/dispatch.rs` and `web_chat/` — drive chat turns from inbound channel messages. - `src/openhuman/cron/scheduler.rs` — fire scheduled triggers through `triage::run_triage` + `apply_decision`. - `src/openhuman/webhooks/ops.rs` — webhook ingestion routes through triage. - `src/openhuman/composio/bus.rs` — Composio trigger envelopes go through `agent::triage`. diff --git a/src/openhuman/agent/progress_tracing/journal_projection.rs b/src/openhuman/agent/progress_tracing/journal_projection.rs index f15acb8812..5665df51c5 100644 --- a/src/openhuman/agent/progress_tracing/journal_projection.rs +++ b/src/openhuman/agent/progress_tracing/journal_projection.rs @@ -4,7 +4,7 @@ //! [`AgentObservation`] journal (the crate `AgentEvent` record) and folds it //! through the existing [`SpanCollector`], so trace spans no longer require the //! *live* in-run `AgentProgress` side-observer -//! (`channels/providers/web/progress_bridge.rs`). A UI/supervisor can attach +//! (`web_chat/progress_bridge.rs`). A UI/supervisor can attach //! after a run, read the journal, and rebuild identical spans. //! //! This is deliberately built on `SpanCollector` (not a re-derivation) so span diff --git a/src/openhuman/agent/task_dispatcher/executor.rs b/src/openhuman/agent/task_dispatcher/executor.rs index d54571bfca..466f5ab99d 100644 --- a/src/openhuman/agent/task_dispatcher/executor.rs +++ b/src/openhuman/agent/task_dispatcher/executor.rs @@ -195,13 +195,13 @@ pub(super) async fn run_autonomous( if let Some(thread_id) = session_thread_id.as_deref() { let (progress_tx, progress_rx) = tokio::sync::mpsc::channel(64); agent.set_on_progress(Some(progress_tx)); - crate::openhuman::channels::providers::web::spawn_progress_bridge( + crate::openhuman::web_chat::spawn_progress_bridge( progress_rx, "system".to_string(), thread_id.to_string(), run_id.to_string(), crate::openhuman::threads::turn_state::TurnStateStore::new(workspace_dir.clone()), - crate::openhuman::channels::providers::web::ChatRequestMetadata { + crate::openhuman::web_chat::ChatRequestMetadata { // Trace attribution: mark the run autonomous and carry the // resolved executor agent so Langfuse traces read // `agent.turn:` with channel.source=autonomous. @@ -251,7 +251,7 @@ pub(super) async fn run_autonomous( if let Some(thread_id) = session_thread_id.as_deref() { match &result { Ok(response) => { - crate::openhuman::channels::providers::web::presentation::deliver_response( + crate::openhuman::web_chat::presentation::deliver_response( "system", thread_id, run_id, @@ -265,7 +265,7 @@ pub(super) async fn run_autonomous( .await; } Err(err) => { - crate::openhuman::channels::providers::web::publish_web_channel_event( + crate::openhuman::web_chat::publish_web_channel_event( crate::core::socketio::WebChannelEvent { event: "chat_error".to_string(), client_id: "system".to_string(), diff --git a/src/openhuman/agent/task_dispatcher/registry.rs b/src/openhuman/agent/task_dispatcher/registry.rs index 375f369397..2d88c34fc6 100644 --- a/src/openhuman/agent/task_dispatcher/registry.rs +++ b/src/openhuman/agent/task_dispatcher/registry.rs @@ -106,17 +106,15 @@ fn cancel_taken_run(thread_id: &str, run: ActiveRun) { &run.run_id, Err("Cancelled by user".to_string()), ); - crate::openhuman::channels::providers::web::publish_web_channel_event( - crate::core::socketio::WebChannelEvent { - event: "chat_error".to_string(), - client_id: "system".to_string(), - thread_id: thread_id.to_string(), - request_id: run.run_id.clone(), - message: Some("Cancelled".to_string()), - error_type: Some("cancelled".to_string()), - ..Default::default() - }, - ); + crate::openhuman::web_chat::publish_web_channel_event(crate::core::socketio::WebChannelEvent { + event: "chat_error".to_string(), + client_id: "system".to_string(), + thread_id: thread_id.to_string(), + request_id: run.run_id.clone(), + message: Some("Cancelled".to_string()), + error_type: Some("cancelled".to_string()), + ..Default::default() + }); tracing::info!( thread_id = %thread_id, card_id = %run.card_id, diff --git a/src/openhuman/agent/triage/evaluator.rs b/src/openhuman/agent/triage/evaluator.rs index 351e95e89e..e7a6fbcba2 100644 --- a/src/openhuman/agent/triage/evaluator.rs +++ b/src/openhuman/agent/triage/evaluator.rs @@ -647,7 +647,7 @@ fn is_prompt_guard_rejection(message: &str) -> bool { /// /// The vocabulary matches the OpenHuman backend's error copy and common /// third-party provider phrasing. It does **not** mirror the -/// *semantics* of `channels/providers/web.rs` (a different code path); +/// *semantics* of `web_chat/` (a different code path); /// it is an independent, conservative allowlist evaluated inline so the /// triage evaluator carries no cross-domain import. /// diff --git a/src/openhuman/agent/turn_origin.rs b/src/openhuman/agent/turn_origin.rs index 49955a63ed..ef712b5837 100644 --- a/src/openhuman/agent/turn_origin.rs +++ b/src/openhuman/agent/turn_origin.rs @@ -4,7 +4,7 @@ //! consistent decisions across web, channel, subconscious, and cron entry //! points without relying on the *absence* of other task-locals as a signal. //! -//! Every entry point that drives the agent loop ([`crate::openhuman::channels::providers::web`], +//! Every entry point that drives the agent loop ([`crate::openhuman::web_chat`], //! [`crate::openhuman::channels::runtime::dispatch`], [`crate::openhuman::subconscious`], //! [`crate::openhuman::cron`], CLI) MUST scope a real [`AgentTurnOrigin`] //! around its `run_turn` invocation. Any path that fails to do so is treated diff --git a/src/openhuman/agent_orchestration/command_center/mod.rs b/src/openhuman/agent_orchestration/command_center/mod.rs index 13b937da23..d1e6aa0447 100644 --- a/src/openhuman/agent_orchestration/command_center/mod.rs +++ b/src/openhuman/agent_orchestration/command_center/mod.rs @@ -5,7 +5,7 @@ //! a normalized status model (needs-input / working / completed / failed / //! stopped) so users can see what is in flight, what is blocked on them, and //! what finished. Live run state already persists to the ledger via the spawn -//! tools + `channels::providers::web::progress_bridge`; this module only +//! tools + `web_chat::progress_bridge`; this module only //! projects and groups it for display. //! //! Control verbs (stop / retry / continue / follow-up) live in [`control`]: diff --git a/src/openhuman/agent_orchestration/run_ledger_finalize.rs b/src/openhuman/agent_orchestration/run_ledger_finalize.rs index be4a7cf7ae..4133a42114 100644 --- a/src/openhuman/agent_orchestration/run_ledger_finalize.rs +++ b/src/openhuman/agent_orchestration/run_ledger_finalize.rs @@ -12,7 +12,7 @@ //! — the *spawning turn's* progress channel. //! //! The run-ledger's terminal transition used to live **only** in the per-turn -//! [`progress_bridge`](crate::openhuman::channels::providers::web::progress_bridge), +//! [`progress_bridge`](crate::openhuman::web_chat::progress_bridge), //! which consumes that progress channel. That works for synchronous subagents //! (they finish inside the turn, sink still alive) but **leaks** for detached //! `spawn_async_subagent` runs: they outlive the parent turn, so when they diff --git a/src/openhuman/agentbox/invoker.rs b/src/openhuman/agentbox/invoker.rs index fca699ad2c..8c6983d001 100644 --- a/src/openhuman/agentbox/invoker.rs +++ b/src/openhuman/agentbox/invoker.rs @@ -10,11 +10,9 @@ use std::sync::Arc; use tokio::sync::broadcast::error::RecvError; use crate::core::socketio::WebChannelEvent; -use crate::openhuman::channels::providers::web::{ - start_chat, subscribe_web_channel_events, ChatRequestMetadata, -}; use crate::openhuman::memory::rpc_models::CreateConversationThreadRequest; use crate::openhuman::threads::ops::thread_create_new; +use crate::openhuman::web_chat::{start_chat, subscribe_web_channel_events, ChatRequestMetadata}; /// Outcome of inspecting one broadcast event against the request we're /// awaiting. Extracted as a pure function so the request-id filtering and diff --git a/src/openhuman/approval/README.md b/src/openhuman/approval/README.md index 18c0b48d73..416227fc2a 100644 --- a/src/openhuman/approval/README.md +++ b/src/openhuman/approval/README.md @@ -56,7 +56,7 @@ None. This module gates other domains' tools; it owns no tools of its own (no `t Published via `publish_global` (domain `approval`, defined in `src/core/event_bus/events.rs`): -- `DomainEvent::ApprovalRequested { request_id, tool_name, action_summary, args_redacted, session_id, thread_id, client_id }` — emitted when a call is parked. Bridged to the `approval_request` web-channel socket event by `ApprovalSurfaceSubscriber` (defined in `src/openhuman/channels/providers/web.rs`). +- `DomainEvent::ApprovalRequested { request_id, tool_name, action_summary, args_redacted, session_id, thread_id, client_id }` — emitted when a call is parked. Bridged to the `approval_request` web-channel socket event by `ApprovalSurfaceSubscriber` (defined in `src/openhuman/web_chat/`). - `DomainEvent::ApprovalDecided { request_id, tool_name, decision }` — emitted when a decision is applied. No `bus.rs` in this module — it only publishes; the subscriber lives in the `channels` web provider. @@ -80,7 +80,7 @@ SQLite DB at `{workspace_dir}/approval/approval.db`, table `pending_approvals` ( - `src/core/jsonrpc.rs` — installs the global gate (`ApprovalGate::init_global`) at startup; wires the approval RPCs. - `src/core/all.rs` — registers the controller schemas. - `src/openhuman/tinyagents/middleware.rs` (`ApprovalSecurityMiddleware`, a `wrap_tool` middleware on every turn path) — routes external-effect tool calls through the gate before `execute()` and records the terminal audit row. -- `src/openhuman/channels/providers/web.rs` — sets `APPROVAL_CHAT_CONTEXT`, hosts `ApprovalSurfaceSubscriber`, and routes typed yes/no replies to `approval_decide`. +- `src/openhuman/web_chat/` — sets `APPROVAL_CHAT_CONTEXT`, hosts `ApprovalSurfaceSubscriber`, and routes typed yes/no replies to `approval_decide`. - `src/openhuman/channels/proactive.rs`, `src/openhuman/agent/triage/escalation.rs`, `src/openhuman/tools/impl/system/install_tool.rs`, `src/openhuman/wallet/execution.rs` — interact with the gate / approval types. ## Notes / gotchas diff --git a/src/openhuman/approval/gate.rs b/src/openhuman/approval/gate.rs index 3b11a213b0..afde88c84b 100644 --- a/src/openhuman/approval/gate.rs +++ b/src/openhuman/approval/gate.rs @@ -71,7 +71,7 @@ const IN_CALL_APPROVAL_TTL: Duration = Duration::from_secs(120); /// 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 (`channels::providers::web`); because the `run_turn` handler, the +/// agent run (`web_chat`); because the `run_turn` handler, the /// tool loop, and `intercept` all run inline (`.await`) within that spawned /// task, it propagates down to `intercept` with no signature plumbing. Absent /// for non-chat callers (CLI, sub-agents) — their approvals are simply not @@ -815,7 +815,7 @@ impl ApprovalGate { // Flow-origin surface bridge (flow-approval-surface, PR3): a flow run // has no chat thread/client to route the generic `ApprovalRequested` // through (both are `None` above, so the web-channel bridge silently - // drops it — see `channels::providers::web::event_bus`'s + // drops it — see `web_chat::event_bus`'s // `ApprovalSurfaceSubscriber`), which is exactly the silent-deadlock // bug this correlation fixes. Broadcast a dedicated // `flow_approval_request` socket event (no thread/client required, @@ -1340,7 +1340,7 @@ mod tests { /// A matching web-chat origin for the chat context fixture. Tests /// exercising the parking flow scope BOTH task-locals — production - /// callers in `channels/providers/web` do the same. + /// callers in `web_chat` do the same. fn web_origin() -> AgentTurnOrigin { AgentTurnOrigin::WebChat { thread_id: "t-test".into(), diff --git a/src/openhuman/artifacts/store.rs b/src/openhuman/artifacts/store.rs index 9d21de44b1..9255a871a9 100644 --- a/src/openhuman/artifacts/store.rs +++ b/src/openhuman/artifacts/store.rs @@ -616,7 +616,7 @@ pub async fn fail_artifact( } /// Read the active [`ApprovalChatContext`] task-local (set by -/// `channels::providers::web` around each chat turn) and return its +/// `web_chat` around each chat turn) and return its /// thread + client ids. Returns `(None, None)` for non-chat callers /// (CLI, cron, sub-agent runners) so artifact emit hooks degrade /// gracefully — the event is still published but the web subscriber diff --git a/src/openhuman/channels/bus.rs b/src/openhuman/channels/bus.rs index e82267d400..6ee6d355ba 100644 --- a/src/openhuman/channels/bus.rs +++ b/src/openhuman/channels/bus.rs @@ -87,10 +87,9 @@ impl EventHandler for ChannelInboundSubscriber { // here so the surface stays segregated end-to-end. let client_id = derive_inbound_client_id(channel, sender.as_deref()); - let mut event_rx = - crate::openhuman::channels::providers::web::subscribe_web_channel_events(); + let mut event_rx = crate::openhuman::web_chat::subscribe_web_channel_events(); - let request_id = match crate::openhuman::channels::providers::web::start_chat( + let request_id = match crate::openhuman::web_chat::start_chat( &client_id, &thread_id, message, @@ -99,7 +98,7 @@ impl EventHandler for ChannelInboundSubscriber { None, None, None, - crate::openhuman::channels::providers::web::ChatRequestMetadata { + crate::openhuman::web_chat::ChatRequestMetadata { // Tag inbound provider messages so traces classify as // run:channel_inbound instead of interactive chat. source: Some("channel_inbound".to_string()), diff --git a/src/openhuman/channels/host/adapters.rs b/src/openhuman/channels/host/adapters.rs index 8a017aad1c..8b0367ddc4 100644 --- a/src/openhuman/channels/host/adapters.rs +++ b/src/openhuman/channels/host/adapters.rs @@ -352,7 +352,7 @@ impl EventSink for OpenHumanEventSink { "{LOG_PREFIX} web event payload not a WebChannelEvent ({kind}): {e}" ) })?; - crate::openhuman::channels::providers::web::publish_web_channel_event(event); + crate::openhuman::web_chat::publish_web_channel_event(event); } "channel" => { use crate::core::event_bus::{publish_global, DomainEvent}; diff --git a/src/openhuman/channels/mod.rs b/src/openhuman/channels/mod.rs index e869a3876a..f28dc7d379 100644 --- a/src/openhuman/channels/mod.rs +++ b/src/openhuman/channels/mod.rs @@ -30,7 +30,6 @@ pub use providers::qq; pub use providers::signal; pub use providers::slack; pub use providers::telegram; -pub use providers::web; pub use providers::whatsapp; #[cfg(feature = "whatsapp-web")] pub use providers::whatsapp_web; diff --git a/src/openhuman/channels/proactive.rs b/src/openhuman/channels/proactive.rs index 9298fb9dea..fe9f762d42 100644 --- a/src/openhuman/channels/proactive.rs +++ b/src/openhuman/channels/proactive.rs @@ -21,8 +21,8 @@ use crate::core::event_bus::{DomainEvent, EventHandler}; use crate::core::socketio::WebChannelEvent; -use crate::openhuman::channels::providers::web::publish_web_channel_event; use crate::openhuman::channels::{Channel, ChannelSendExt, SendMessage}; +use crate::openhuman::web_chat::publish_web_channel_event; use async_trait::async_trait; use std::collections::HashMap; use std::sync::{Arc, RwLock}; diff --git a/src/openhuman/channels/providers/mod.rs b/src/openhuman/channels/providers/mod.rs index 37534b47b4..37f9bac96a 100644 --- a/src/openhuman/channels/providers/mod.rs +++ b/src/openhuman/channels/providers/mod.rs @@ -12,7 +12,6 @@ pub mod qq; pub mod signal; pub mod slack; pub mod telegram; -pub mod web; pub mod whatsapp; #[cfg(feature = "whatsapp-web")] pub mod whatsapp_web; diff --git a/src/openhuman/channels/providers/telegram/remote_control.rs b/src/openhuman/channels/providers/telegram/remote_control.rs index 93c35c232f..fe2d81c7bf 100644 --- a/src/openhuman/channels/providers/telegram/remote_control.rs +++ b/src/openhuman/channels/providers/telegram/remote_control.rs @@ -270,7 +270,7 @@ async fn build_new_session_response(ctx: &ChannelRuntimeContext, msg: &ChannelMe ); } - crate::openhuman::channels::providers::web::invalidate_thread_sessions(&thread_id).await; + crate::openhuman::web_chat::invalidate_thread_sessions(&thread_id).await; tracing::info!( "{LOG_PREFIX} new session thread_id={thread_id} reply_target={} sender_key={sender_key}", diff --git a/src/openhuman/channels/runtime/dispatch/processor.rs b/src/openhuman/channels/runtime/dispatch/processor.rs index d393109edb..973bf5d37b 100644 --- a/src/openhuman/channels/runtime/dispatch/processor.rs +++ b/src/openhuman/channels/runtime/dispatch/processor.rs @@ -89,7 +89,7 @@ pub(crate) fn channel_has_approval_surface(channel: &str) -> bool { /// Otherwise return `false` so the caller can dispatch the message as a /// fresh turn (which intentionally cancels any parked approval — the /// user is redirecting). Mirrors the web channel intercept at -/// `channels/providers/web.rs:493-525`. +/// `web_chat/`. /// /// [`ApprovalGate::decide`]: crate::openhuman::approval::ApprovalGate::decide async fn try_route_approval_reply(msg: &traits::ChannelMessage) -> bool { @@ -185,7 +185,7 @@ pub(crate) async fn process_channel_runtime_message( // history key, route it to `ApprovalGate::decide` and return — running // a fresh agent turn would cancel the parked tool call. Any other text // falls through to the normal dispatch (the user is redirecting). Mirrors - // the same intercept in `channels/providers/web.rs:493-525`. + // the same intercept in `web_chat/`. if channel_has_approval_surface(&msg.channel) && try_route_approval_reply(&msg).await { return; } diff --git a/src/openhuman/channels/runtime/startup.rs b/src/openhuman/channels/runtime/startup.rs index b3a13b5a11..59716ef3cf 100644 --- a/src/openhuman/channels/runtime/startup.rs +++ b/src/openhuman/channels/runtime/startup.rs @@ -166,20 +166,20 @@ pub async fn start_channels(mut config: Config) -> Result<()> { crate::openhuman::agent_meetings::bus::register_meeting_event_subscriber(); // Surface parked ApprovalGate requests as chat messages so the user can // answer yes/no in the thread (chat-native approval, issue #1339). - crate::openhuman::channels::providers::web::register_approval_surface_subscriber(); + crate::openhuman::web_chat::register_approval_surface_subscriber(); // Surface generated-artifact lifecycle events (ArtifactReady / // ArtifactFailed) as `artifact_ready` / `artifact_failed` web-channel // events so the frontend ArtifactCard can render in chat (#2779). - crate::openhuman::channels::providers::web::register_artifact_surface_subscriber(); + 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::channels::providers::web::register_automation_halt_subscriber(); + 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). - crate::openhuman::channels::providers::web::register_egress_surface_subscriber(); + crate::openhuman::web_chat::register_egress_surface_subscriber(); // Spawn the per-toolkit provider periodic sync scheduler. This is // a thin tokio task that ticks every minute and dispatches into // any provider whose `sync_interval_secs` has elapsed for an diff --git a/src/openhuman/cron/scheduler.rs b/src/openhuman/cron/scheduler.rs index 1c869beb21..115e28fdc5 100644 --- a/src/openhuman/cron/scheduler.rs +++ b/src/openhuman/cron/scheduler.rs @@ -76,7 +76,7 @@ fn agent_error_to_user_message(err: &AgentError) -> &'static str { // contract, for clean notification-drawer rendering) names // the two highest-signal remedies — credits and model // configuration. The richer three-remedy copy lives on the - // chat-surface side (`channels/providers/web_errors.rs`'s + // chat-surface side (`web_chat/web_errors.rs`'s // empty_response arm) where there's no drawer-width limit. "Empty model response. Out of credits (Settings \u{2192} Billing) or try another model in Connections \u{2192} API keys \u{2192} LLM." } @@ -764,15 +764,13 @@ fn permanent_halt_message(credits_exhausted: bool, budget_exhausted: bool) -> &' /// deep-link action even though no chat thread is active. fn publish_cron_user_error(kind: &str) { log::debug!("[cron] action=surface_user_error kind={kind}"); - crate::openhuman::channels::providers::web::publish_web_channel_event( - crate::core::socketio::WebChannelEvent { - event: "user_error".to_string(), - client_id: "system".to_string(), - error_type: Some(kind.to_string()), - error_source: Some("cron".to_string()), - ..Default::default() - }, - ); + crate::openhuman::web_chat::publish_web_channel_event(crate::core::socketio::WebChannelEvent { + event: "user_error".to_string(), + client_id: "system".to_string(), + error_type: Some(kind.to_string()), + error_source: Some("cron".to_string()), + ..Default::default() + }); } async fn process_due_jobs(config: &Config, security: &Arc, jobs: Vec) { diff --git a/src/openhuman/cron/scheduler_tests.rs b/src/openhuman/cron/scheduler_tests.rs index fc5b31df3b..f14212282f 100644 --- a/src/openhuman/cron/scheduler_tests.rs +++ b/src/openhuman/cron/scheduler_tests.rs @@ -1866,7 +1866,7 @@ fn next_user_error( #[test] fn publish_cron_user_error_broadcasts_metadata_only_for_each_kind() { - use crate::openhuman::channels::providers::web::subscribe_web_channel_events; + use crate::openhuman::web_chat::subscribe_web_channel_events; // Folded from two tests that both published `api_key_missing` to the // process-global bus and could false-pass off each other's broadcast under diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index 1ee18d0563..62a2968054 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -4018,13 +4018,13 @@ fn attach_flow_progress_bridge( source = %source, "[flows] progress bridge: attaching (streaming copilot/scout turn)" ); - crate::openhuman::channels::providers::web::spawn_progress_bridge( + crate::openhuman::web_chat::spawn_progress_bridge( progress_rx, "system".to_string(), target.thread_id.clone(), target.request_id.clone(), crate::openhuman::threads::turn_state::TurnStateStore::new(config.workspace_dir.clone()), - crate::openhuman::channels::providers::web::ChatRequestMetadata { + crate::openhuman::web_chat::ChatRequestMetadata { source: Some(source.to_string()), ..Default::default() }, @@ -4046,7 +4046,7 @@ async fn finalize_flow_stream( ) { match result { Ok(text) => { - crate::openhuman::channels::providers::web::presentation::deliver_response( + crate::openhuman::web_chat::presentation::deliver_response( "system", &target.thread_id, &target.request_id, @@ -4060,7 +4060,7 @@ async fn finalize_flow_stream( .await; } Err(err) => { - crate::openhuman::channels::providers::web::publish_web_channel_event( + crate::openhuman::web_chat::publish_web_channel_event( crate::core::socketio::WebChannelEvent { event: "chat_error".to_string(), client_id: "system".to_string(), diff --git a/src/openhuman/inference/provider/error_code.rs b/src/openhuman/inference/provider/error_code.rs index d2825ac37f..a69e838d52 100644 --- a/src/openhuman/inference/provider/error_code.rs +++ b/src/openhuman/inference/provider/error_code.rs @@ -13,7 +13,7 @@ //! runs their own provider key, no `errorCode` is present). The presence of an //! `errorCode` is therefore the single load-bearing signal for two decisions: //! -//! 1. **Classification** ([`super::super::super::channels::providers::web_errors::classify_inference_error`]): +//! 1. **Classification** ([`super::super::super::web_chat::web_errors::classify_inference_error`]): //! when an `errorCode` is present, branch on it FIRST and ignore the //! substring heuristics; when it is absent, fall back to the substring //! ladder (the BYO / direct-provider path, whose "check your API key" / diff --git a/src/openhuman/meet_agent/brain/llm.rs b/src/openhuman/meet_agent/brain/llm.rs index 5d54872bf8..47124b7db2 100644 --- a/src/openhuman/meet_agent/brain/llm.rs +++ b/src/openhuman/meet_agent/brain/llm.rs @@ -146,7 +146,7 @@ async fn get_or_build_agent_for_meet(request_id: &str) -> Result &str { - "channels::web::egress_surface" + "web_chat::egress_surface" } fn domains(&self) -> Option<&[&str]> { @@ -178,7 +178,7 @@ struct ArtifactSurfaceSubscriber; #[async_trait] impl EventHandler for ArtifactSurfaceSubscriber { fn name(&self) -> &str { - "channels::web::artifact_surface" + "web_chat::artifact_surface" } fn domains(&self) -> Option<&[&str]> { @@ -326,7 +326,7 @@ struct ApprovalSurfaceSubscriber; #[async_trait] impl EventHandler for ApprovalSurfaceSubscriber { fn name(&self) -> &str { - "channels::web::approval_surface" + "web_chat::approval_surface" } fn domains(&self) -> Option<&[&str]> { @@ -411,7 +411,7 @@ struct AutomationHaltSubscriber; #[async_trait] impl EventHandler for AutomationHaltSubscriber { fn name(&self) -> &str { - "channels::web::automation_halt" + "web_chat::automation_halt" } fn domains(&self) -> Option<&[&str]> { diff --git a/src/openhuman/channels/providers/web/mod.rs b/src/openhuman/web_chat/mod.rs similarity index 98% rename from src/openhuman/channels/providers/web/mod.rs rename to src/openhuman/web_chat/mod.rs index 4c979e8a78..2e8527413d 100644 --- a/src/openhuman/channels/providers/web/mod.rs +++ b/src/openhuman/web_chat/mod.rs @@ -9,7 +9,6 @@ mod schemas; mod session; mod types; -#[path = "../web_errors.rs"] mod web_errors; pub(crate) use web_errors::classify_inference_error; #[cfg(any(test, debug_assertions))] @@ -140,5 +139,5 @@ pub mod test_support { pub(crate) use types::SessionCacheFingerprint; #[cfg(test)] -#[path = "../web_tests.rs"] +#[path = "web_tests.rs"] mod tests; diff --git a/src/openhuman/channels/providers/web/ops.rs b/src/openhuman/web_chat/ops.rs similarity index 99% rename from src/openhuman/channels/providers/web/ops.rs rename to src/openhuman/web_chat/ops.rs index bc7a2a44c3..5a46c33067 100644 --- a/src/openhuman/channels/providers/web/ops.rs +++ b/src/openhuman/web_chat/ops.rs @@ -474,7 +474,7 @@ pub async fn start_chat( let prompt_decision = enforce_prompt_input( &message, PromptEnforcementContext { - source: "channels.providers.web.start_chat", + source: "web_chat.start_chat", request_id: Some(&request_id), user_id: Some(&client_id), session_id: Some(&thread_id), @@ -728,7 +728,7 @@ pub async fn start_chat( match result { Ok(chat_result) => { - crate::openhuman::channels::providers::web::presentation::deliver_response( + crate::openhuman::web_chat::presentation::deliver_response( &client_id_task, &thread_id_task, &request_id_task, @@ -931,7 +931,7 @@ async fn spawn_parallel_turn( match result { Some(Ok(chat_result)) => { - crate::openhuman::channels::providers::web::presentation::deliver_response( + crate::openhuman::web_chat::presentation::deliver_response( &client_id_task, &thread_id_task, &request_id_task, diff --git a/src/openhuman/channels/providers/web/presentation.rs b/src/openhuman/web_chat/presentation.rs similarity index 100% rename from src/openhuman/channels/providers/web/presentation.rs rename to src/openhuman/web_chat/presentation.rs diff --git a/src/openhuman/channels/providers/web/presentation_tests.rs b/src/openhuman/web_chat/presentation_tests.rs similarity index 100% rename from src/openhuman/channels/providers/web/presentation_tests.rs rename to src/openhuman/web_chat/presentation_tests.rs diff --git a/src/openhuman/channels/providers/web/progress_bridge.rs b/src/openhuman/web_chat/progress_bridge.rs similarity index 100% rename from src/openhuman/channels/providers/web/progress_bridge.rs rename to src/openhuman/web_chat/progress_bridge.rs diff --git a/src/openhuman/channels/providers/web/run_task.rs b/src/openhuman/web_chat/run_task.rs similarity index 100% rename from src/openhuman/channels/providers/web/run_task.rs rename to src/openhuman/web_chat/run_task.rs diff --git a/src/openhuman/channels/providers/web/schemas.rs b/src/openhuman/web_chat/schemas.rs similarity index 100% rename from src/openhuman/channels/providers/web/schemas.rs rename to src/openhuman/web_chat/schemas.rs diff --git a/src/openhuman/channels/providers/web/session.rs b/src/openhuman/web_chat/session.rs similarity index 100% rename from src/openhuman/channels/providers/web/session.rs rename to src/openhuman/web_chat/session.rs diff --git a/src/openhuman/channels/providers/web/types.rs b/src/openhuman/web_chat/types.rs similarity index 100% rename from src/openhuman/channels/providers/web/types.rs rename to src/openhuman/web_chat/types.rs diff --git a/src/openhuman/channels/providers/web_errors.rs b/src/openhuman/web_chat/web_errors.rs similarity index 100% rename from src/openhuman/channels/providers/web_errors.rs rename to src/openhuman/web_chat/web_errors.rs diff --git a/src/openhuman/channels/providers/web_tests.rs b/src/openhuman/web_chat/web_tests.rs similarity index 100% rename from src/openhuman/channels/providers/web_tests.rs rename to src/openhuman/web_chat/web_tests.rs diff --git a/tests/agent_harness_e2e.rs b/tests/agent_harness_e2e.rs index 8bbd69760a..0b1b82b866 100644 --- a/tests/agent_harness_e2e.rs +++ b/tests/agent_harness_e2e.rs @@ -1361,7 +1361,7 @@ encrypt = false /// same binary lose the bridge silently. This per-test helper avoids the issue by /// registering a fresh subscription on each test's own runtime. fn register_approval_bridge() -> Option { - openhuman_core::openhuman::channels::providers::web::fresh_approval_surface_subscription() + openhuman_core::openhuman::web_chat::fresh_approval_surface_subscription() } /// Pre-create a file in the action_dir so file_write sees it as an existing @@ -1473,7 +1473,7 @@ async fn approval_gate_approve_flow_inner() { .await; // Wait for the approval_request SSE event. - // Actual shape (src/openhuman/channels/providers/web/event_bus.rs:195-224): + // Actual shape (src/openhuman/web_chat/event_bus.rs:195-224): // { "event": "approval_request", "data": { "request_id": "...", "tool_name": "...", // "action_summary": "...", "args_redacted": {...} }, ... } let approval = wait_for_event(&mut events, "approval_request", Duration::from_secs(60)).await; diff --git a/tests/agentbox_e2e.rs b/tests/agentbox_e2e.rs index a44ca3b973..5521499e6a 100644 --- a/tests/agentbox_e2e.rs +++ b/tests/agentbox_e2e.rs @@ -14,7 +14,7 @@ //! //! The AgentBox `/run` invoker (`agentbox::invoker::CoreAgentInvoker`) drives //! the **live agent runtime** through the web-channel pipeline -//! (`channels::providers::web::start_chat`). End-to-end completion against a +//! (`web_chat::start_chat`). End-to-end completion against a //! freshly-bootstrapped tempdir workspace requires: //! //! 1. A logged-in user session on disk — `start_chat` and several upstream diff --git a/tests/composio_list_tools_stack_overflow_regression.rs b/tests/composio_list_tools_stack_overflow_regression.rs index 85c21916ec..565b08f583 100644 --- a/tests/composio_list_tools_stack_overflow_regression.rs +++ b/tests/composio_list_tools_stack_overflow_regression.rs @@ -22,7 +22,7 @@ //! ← subagent_runner::run_inner_loop / run_typed_mode / run_subagent //! ← SkillDelegationTool::execute (delegate_to_integrations_agent) //! ← Agent::execute_tool_call / execute_tools / turn -//! ← channels::providers::web::run_chat_task +//! ← web_chat::run_chat_task //! ``` //! //! The crash trigger is `composio_list_tools` reloading the config from @@ -50,7 +50,7 @@ //! ## What this test does //! //! Faithful reproduction in cargo-test is awkward: we can't easily -//! rebuild the upper chat-channel layers (`channels::providers::web:: +//! rebuild the upper chat-channel layers (`web_chat:: //! run_chat_task → Agent::turn → execute_tools → SkillDelegationTool`) //! without standing up an HTTP + Socket.IO stack. We drive the production //! path from `run_subagent` downward — i.e. everything below diff --git a/tests/raw_coverage/channels_bus_presentation_raw_coverage_e2e.rs b/tests/raw_coverage/channels_bus_presentation_raw_coverage_e2e.rs index 3e5cdb944c..5d1263e0f5 100644 --- a/tests/raw_coverage/channels_bus_presentation_raw_coverage_e2e.rs +++ b/tests/raw_coverage/channels_bus_presentation_raw_coverage_e2e.rs @@ -8,8 +8,8 @@ use std::time::Duration; use openhuman_core::core::event_bus::{DomainEvent, EventHandler}; use openhuman_core::openhuman::agent_memory::memory_loader::MemoryCitation; use openhuman_core::openhuman::channels::bus::ChannelInboundSubscriber; -use openhuman_core::openhuman::channels::providers::web::presentation::test_support as presentation_test_support; -use openhuman_core::openhuman::channels::providers::web::{ +use openhuman_core::openhuman::web_chat::presentation::test_support as presentation_test_support; +use openhuman_core::openhuman::web_chat::{ subscribe_web_channel_events, test_support as web_test_support, }; use serde_json::json; diff --git a/tests/raw_coverage/channels_large_round25_raw_coverage_e2e.rs b/tests/raw_coverage/channels_large_round25_raw_coverage_e2e.rs index 11632f99bf..c0c11f97a0 100644 --- a/tests/raw_coverage/channels_large_round25_raw_coverage_e2e.rs +++ b/tests/raw_coverage/channels_large_round25_raw_coverage_e2e.rs @@ -17,7 +17,7 @@ use openhuman_core::openhuman::channels::providers::mattermost::{ test_support as mattermost_support, MattermostChannel, }; use openhuman_core::openhuman::channels::providers::telegram::test_support as telegram_support; -use openhuman_core::openhuman::channels::providers::web::{self, test_support as web_support}; +use openhuman_core::openhuman::web_chat::{self as web, test_support as web_support}; use openhuman_core::openhuman::channels::test_support::{ build_channel_context_block_for_test, run_dispatch_harness, select_acknowledgment_reaction_for_test, DispatchHarnessOptions, TestMemoryEntry, diff --git a/tests/raw_coverage/channels_provider_deep_raw_coverage_e2e.rs b/tests/raw_coverage/channels_provider_deep_raw_coverage_e2e.rs index fb03729d8b..a69d01a252 100644 --- a/tests/raw_coverage/channels_provider_deep_raw_coverage_e2e.rs +++ b/tests/raw_coverage/channels_provider_deep_raw_coverage_e2e.rs @@ -8,7 +8,7 @@ use axum::{ routing::post, Router, }; -use openhuman_core::openhuman::channels::providers::web::{ +use openhuman_core::openhuman::web_chat::{ cancel_chat, start_chat, subscribe_web_channel_events, ChatRequestMetadata, }; use openhuman_core::openhuman::channels::providers::yuanbao::{YuanbaoChannel, YuanbaoConfig}; @@ -310,7 +310,7 @@ async fn web_channel_validation_cancel_and_classifier_snapshots_are_publicly_exe assert!(blocked.is_err()); let rate_limited = - openhuman_core::openhuman::channels::web::test_support::classify_error_for_test( + openhuman_core::openhuman::web_chat::test_support::classify_error_for_test( r#"openrouter API error (429 Too Many Requests): {"error":{"message":"slow down","retry_after":1.2}}"#, ); assert_eq!(rate_limited.error_type, "rate_limited"); @@ -320,38 +320,38 @@ async fn web_channel_validation_cancel_and_classifier_snapshots_are_publicly_exe assert!(rate_limited.retryable); let business_429 = - openhuman_core::openhuman::channels::web::test_support::classify_error_for_test( + openhuman_core::openhuman::web_chat::test_support::classify_error_for_test( "zai API error (429): code 1311 no available package", ); assert_eq!(business_429.error_type, "rate_limited"); assert!(!business_429.retryable); let action_budget = - openhuman_core::openhuman::channels::web::test_support::classify_error_for_test( + openhuman_core::openhuman::web_chat::test_support::classify_error_for_test( "rate limit exceeded: action budget exhausted", ); assert_eq!(action_budget.error_type, "action_budget_exceeded"); assert_eq!(action_budget.source, "openhuman_budget"); assert_eq!(action_budget.provider, None); - let exhausted = openhuman_core::openhuman::channels::web::test_support::classify_error_for_test( + let exhausted = openhuman_core::openhuman::web_chat::test_support::classify_error_for_test( "All providers/models failed. Attempts: openai API error (503 Service Unavailable)", ); assert_eq!(exhausted.fallback_available, Some(false)); assert_eq!( - openhuman_core::openhuman::channels::web::test_support::retry_after_secs_for_test( + openhuman_core::openhuman::web_chat::test_support::retry_after_secs_for_test( r#"{"retry_after": 0.1}"# ), Some(1) ); - assert!(openhuman_core::openhuman::channels::web::test_support::extracted_provider_detail_for_test( + assert!(openhuman_core::openhuman::web_chat::test_support::extracted_provider_detail_for_test( r#"openai API error (404): {"error":{"message":"Project does not have access to model x"}}"# ) .expect("provider detail") .contains("model")); assert!( - openhuman_core::openhuman::channels::web::test_support::is_non_retryable_rate_limit_for_test( + openhuman_core::openhuman::web_chat::test_support::is_non_retryable_rate_limit_for_test( "plan does not include this model" ) ); diff --git a/tests/raw_coverage/channels_provider_leftovers_raw_coverage_e2e.rs b/tests/raw_coverage/channels_provider_leftovers_raw_coverage_e2e.rs index 89bebebda1..8dc6d1d95e 100644 --- a/tests/raw_coverage/channels_provider_leftovers_raw_coverage_e2e.rs +++ b/tests/raw_coverage/channels_provider_leftovers_raw_coverage_e2e.rs @@ -15,7 +15,7 @@ use axum::{ Router, }; use openhuman_core::openhuman::channels::providers::telegram::TelegramChannel; -use openhuman_core::openhuman::channels::providers::web::{ +use openhuman_core::openhuman::web_chat::{ cancel_chat, start_chat, subscribe_web_channel_events, test_support as web_test_support, ChatRequestMetadata, }; diff --git a/tests/raw_coverage/channels_runtime_raw_coverage_e2e.rs b/tests/raw_coverage/channels_runtime_raw_coverage_e2e.rs index 7f97cf0d22..882f37bea7 100644 --- a/tests/raw_coverage/channels_runtime_raw_coverage_e2e.rs +++ b/tests/raw_coverage/channels_runtime_raw_coverage_e2e.rs @@ -9,7 +9,7 @@ use axum::{ Router, }; use openhuman_core::core::event_bus::{DomainEvent, EventHandler}; -use openhuman_core::openhuman::channels::providers::web::{ +use openhuman_core::openhuman::web_chat::{ cancel_chat, start_chat, subscribe_web_channel_events, ChatRequestMetadata, }; use openhuman_core::openhuman::channels::providers::yuanbao::{YuanbaoChannel, YuanbaoConfig}; diff --git a/tests/raw_coverage/channels_web_startup_raw_coverage_e2e.rs b/tests/raw_coverage/channels_web_startup_raw_coverage_e2e.rs index a6d57ad4c9..2e0ff56143 100644 --- a/tests/raw_coverage/channels_web_startup_raw_coverage_e2e.rs +++ b/tests/raw_coverage/channels_web_startup_raw_coverage_e2e.rs @@ -10,7 +10,7 @@ use openhuman_core::openhuman::channels::start_channels; use openhuman_core::openhuman::channels::test_support::{ lock_agent_handler, run_dispatch_harness, DispatchHarnessOptions, TestMemoryEntry, }; -use openhuman_core::openhuman::channels::web::{ +use openhuman_core::openhuman::web_chat::{ all_web_channel_controller_schemas, all_web_channel_registered_controllers, channel_web_cancel, channel_web_chat, schemas, start_chat, subscribe_web_channel_events, test_support as web_test_support, ChatRequestMetadata, diff --git a/tests/raw_coverage/channels_web_telegram_raw_coverage_e2e.rs b/tests/raw_coverage/channels_web_telegram_raw_coverage_e2e.rs index 7784797cce..ef1675d42a 100644 --- a/tests/raw_coverage/channels_web_telegram_raw_coverage_e2e.rs +++ b/tests/raw_coverage/channels_web_telegram_raw_coverage_e2e.rs @@ -16,7 +16,7 @@ use axum::{ }; use openhuman_core::core::event_bus::{init_global, publish_global, DomainEvent}; use openhuman_core::openhuman::channels::providers::telegram::TelegramChannel; -use openhuman_core::openhuman::channels::providers::web::{ +use openhuman_core::openhuman::web_chat::{ cancel_chat, register_approval_surface_subscriber, start_chat, subscribe_web_channel_events, test_support as web_test_support, ChatRequestMetadata, }; diff --git a/tests/raw_coverage/channels_web_yuanbao_round22_raw_coverage_e2e.rs b/tests/raw_coverage/channels_web_yuanbao_round22_raw_coverage_e2e.rs index 687005c5d9..11872a3f0c 100644 --- a/tests/raw_coverage/channels_web_yuanbao_round22_raw_coverage_e2e.rs +++ b/tests/raw_coverage/channels_web_yuanbao_round22_raw_coverage_e2e.rs @@ -13,7 +13,7 @@ use axum::{ Router, }; use openhuman_core::openhuman::channels::providers::telegram::TelegramChannel; -use openhuman_core::openhuman::channels::providers::web::{ +use openhuman_core::openhuman::web_chat::{ cancel_chat, start_chat, subscribe_web_channel_events, test_support as web_test_support, ChatRequestMetadata, }; diff --git a/tests/raw_coverage/tools_approval_channels_raw_coverage_e2e.rs b/tests/raw_coverage/tools_approval_channels_raw_coverage_e2e.rs index f7ed574df5..7402dc39f1 100644 --- a/tests/raw_coverage/tools_approval_channels_raw_coverage_e2e.rs +++ b/tests/raw_coverage/tools_approval_channels_raw_coverage_e2e.rs @@ -2067,8 +2067,8 @@ async fn channel_provider_public_paths_cover_pre_network_errors_and_utilities() #[tokio::test] async fn web_channel_public_paths_cover_event_delivery_and_validation_errors() { - let mut rx = openhuman_core::openhuman::channels::web::subscribe_web_channel_events(); - openhuman_core::openhuman::channels::web::publish_web_channel_event(WebChannelEvent { + let mut rx = openhuman_core::openhuman::web_chat::subscribe_web_channel_events(); + openhuman_core::openhuman::web_chat::publish_web_channel_event(WebChannelEvent { event: "coverage_event".to_string(), client_id: "client-1".to_string(), thread_id: "thread-1".to_string(), @@ -2086,7 +2086,7 @@ async fn web_channel_public_paths_cover_event_delivery_and_validation_errors() { assert_eq!(event.message.as_deref(), Some("hello web channel")); assert_eq!( - openhuman_core::openhuman::channels::web::start_chat( + openhuman_core::openhuman::web_chat::start_chat( "", "thread-1", "hello", @@ -2095,14 +2095,14 @@ async fn web_channel_public_paths_cover_event_delivery_and_validation_errors() { None, None, None, - openhuman_core::openhuman::channels::web::ChatRequestMetadata::default(), + openhuman_core::openhuman::web_chat::ChatRequestMetadata::default(), ) .await .expect_err("blank client_id"), "client_id is required" ); assert_eq!( - openhuman_core::openhuman::channels::web::start_chat( + openhuman_core::openhuman::web_chat::start_chat( "client-1", "", "hello", @@ -2111,14 +2111,14 @@ async fn web_channel_public_paths_cover_event_delivery_and_validation_errors() { None, None, None, - openhuman_core::openhuman::channels::web::ChatRequestMetadata::default(), + openhuman_core::openhuman::web_chat::ChatRequestMetadata::default(), ) .await .expect_err("blank thread_id"), "thread_id is required" ); assert_eq!( - openhuman_core::openhuman::channels::web::start_chat( + openhuman_core::openhuman::web_chat::start_chat( "client-1", "thread-1", " ", @@ -2127,7 +2127,7 @@ async fn web_channel_public_paths_cover_event_delivery_and_validation_errors() { None, None, None, - openhuman_core::openhuman::channels::web::ChatRequestMetadata::default(), + openhuman_core::openhuman::web_chat::ChatRequestMetadata::default(), ) .await .expect_err("blank message"), @@ -2135,26 +2135,26 @@ async fn web_channel_public_paths_cover_event_delivery_and_validation_errors() { ); assert_eq!( - openhuman_core::openhuman::channels::web::cancel_chat("", "thread-1") + openhuman_core::openhuman::web_chat::cancel_chat("", "thread-1") .await .expect_err("blank cancel client_id"), "client_id is required" ); assert_eq!( - openhuman_core::openhuman::channels::web::cancel_chat("client-1", "") + openhuman_core::openhuman::web_chat::cancel_chat("client-1", "") .await .expect_err("blank cancel thread_id"), "thread_id is required" ); assert!( - openhuman_core::openhuman::channels::web::cancel_chat("client-1", "thread-1") + openhuman_core::openhuman::web_chat::cancel_chat("client-1", "thread-1") .await .expect("cancel with no in-flight request") .is_none() ); - openhuman_core::openhuman::channels::web::invalidate_thread_sessions("thread-1").await; + openhuman_core::openhuman::web_chat::invalidate_thread_sessions("thread-1").await; assert!( - openhuman_core::openhuman::channels::web::in_flight_entries_for_test() + openhuman_core::openhuman::web_chat::in_flight_entries_for_test() .await .is_empty() ); @@ -2181,7 +2181,7 @@ async fn proactive_subscriber_routes_web_and_active_external_channel_without_net } } - let mut rx = openhuman_core::openhuman::channels::web::subscribe_web_channel_events(); + let mut rx = openhuman_core::openhuman::web_chat::subscribe_web_channel_events(); let capture = Arc::new(CapturingChannel::default()); let mut channels: HashMap> = HashMap::new(); channels.insert("capture".into(), capture.clone()); diff --git a/tests/raw_coverage/tools_network_channels_raw_coverage_e2e.rs b/tests/raw_coverage/tools_network_channels_raw_coverage_e2e.rs index 97cb277dd2..5a9b254611 100644 --- a/tests/raw_coverage/tools_network_channels_raw_coverage_e2e.rs +++ b/tests/raw_coverage/tools_network_channels_raw_coverage_e2e.rs @@ -18,7 +18,7 @@ use tempfile::{tempdir, TempDir}; use tokio::time::timeout; use openhuman_core::core::socketio::WebChannelEvent; -use openhuman_core::openhuman::channels::providers::web::{ +use openhuman_core::openhuman::web_chat::{ all_web_channel_controller_schemas, all_web_channel_registered_controllers, cancel_chat, channel_web_cancel, publish_web_channel_event, schemas as web_channel_schema, start_chat, subscribe_web_channel_events, ChatRequestMetadata, diff --git a/tests/web_cancel_request_scoping.rs b/tests/web_cancel_request_scoping.rs index 4b79df0fab..c90f78c228 100644 --- a/tests/web_cancel_request_scoping.rs +++ b/tests/web_cancel_request_scoping.rs @@ -16,7 +16,7 @@ //! These assertions pin that decision. `cancel_should_target` is a pure //! predicate, so this is a fast, deterministic unit test with no runtime setup. -use openhuman_core::openhuman::channels::web::cancel_should_target; +use openhuman_core::openhuman::web_chat::cancel_should_target; #[test] fn unscoped_cancel_always_targets_the_in_flight_turn() { From 9117b08ad5e4c11b898969e04d7df67508b82099 Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Sat, 18 Jul 2026 09:13:38 +0530 Subject: [PATCH 75/86] fix(tauri): resolve macOS-gated clippy -D warnings blocking pre-push (#5018) (#5020) From 9975450cd957bb35c632b5f130451f81bb58fbc2 Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Sat, 18 Jul 2026 09:13:47 +0530 Subject: [PATCH 76/86] fix(composio): gate per-action tools on their full contract (#4853) (#4995) --- .../harness/subagent_runner/ops/provider.rs | 8 + src/openhuman/composio/action_tool.rs | 155 +++++++++++--- src/openhuman/composio/contract_gate.rs | 200 ++++++++++++++++++ src/openhuman/composio/contract_gate_tests.rs | 134 ++++++++++++ src/openhuman/composio/mod.rs | 1 + 5 files changed, 473 insertions(+), 25 deletions(-) create mode 100644 src/openhuman/composio/contract_gate.rs create mode 100644 src/openhuman/composio/contract_gate_tests.rs diff --git a/src/openhuman/agent/harness/subagent_runner/ops/provider.rs b/src/openhuman/agent/harness/subagent_runner/ops/provider.rs index acdf56f1e4..e8acc67533 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops/provider.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops/provider.rs @@ -247,6 +247,14 @@ pub(crate) struct LazyToolkitResolver { const TIER4_MIN_SLUG_LEN: usize = 8; impl LazyToolkitResolver { + /// NOTE (contract gate, #4853): this builds a *fresh* `ComposioActionTool` + /// — and therefore a fresh, empty `ContractGate` — on every call. The gate's + /// surface-once state lives in the tool instance, so if a future wiring + /// resolves a new tool per invocation the gate would surface the full + /// contract on every call and the retry would never see `first_time == false` + /// to proceed. When this path is wired to actually dispatch, cache the + /// resolved tool (and its gate) per turn so a given action can proceed after + /// its contract has been surfaced once. pub(super) fn resolve(&self, name: &str) -> Option> { let action = self.find_action(name)?; Some(Box::new( diff --git a/src/openhuman/composio/action_tool.rs b/src/openhuman/composio/action_tool.rs index 867a4ae8b7..bf2139eb1f 100644 --- a/src/openhuman/composio/action_tool.rs +++ b/src/openhuman/composio/action_tool.rs @@ -67,6 +67,13 @@ pub struct ComposioActionTool { /// Composio connection. Used when the sub-agent is spawned for a /// particular account (e.g. "send from my work Gmail"). connection_id: Option, + /// Per-turn contract gate (#4853). On the first call to this action the + /// gate surfaces the action's FULL live input schema/description so the + /// model composes well-formed arguments (e.g. correctly-quoted Gmail + /// queries) instead of guessing from the thin spawn-time schema; the retry + /// executes normally. Held per tool instance, which lives for one + /// `integrations_agent` spawn, so "seen" is scoped to that turn. + gate: super::contract_gate::ContractGate, } impl ComposioActionTool { @@ -93,6 +100,7 @@ impl ComposioActionTool { description, parameters, connection_id, + gate: super::contract_gate::ContractGate::new(), } } } @@ -184,6 +192,52 @@ impl Tool for ComposioActionTool { } } + // [#1710 Wave 4 / #4853] Reload the live config snapshot ONCE, up front, + // and use it for BOTH the contract-gate lookup and dispatch. A mid-session + // `composio.mode` / credential / workspace change must route the gate's + // live-catalog fetch and the actual execution through the SAME config; + // consulting the captured spawn-time `self.config` here would let the gate + // resolve (or skip) a contract against stale routing while dispatch used + // fresh routing. Anchored to this tool's original config path rather than + // re-resolving process-global `OPENHUMAN_WORKSPACE` (the tool is scoped to + // the user/workspace it was created for). + let live_config = + match config_rpc::reload_config_snapshot_with_timeout(self.config.as_ref()).await { + Ok(c) => c, + Err(e) => { + tracing::warn!( + tool = %self.action_name, + error = %e, + "[composio] per-action execute: load_config failed" + ); + return Ok(ToolResult::error(format!( + "{}: failed to load live config: {e}", + self.action_name + ))); + } + }; + + // Contract gate (#4853): the per-action tool is built from the thin + // spawn-time `list_tools` schema (often `{"type":"object"}` with no + // field descriptions), so the model guesses argument formats — most + // visibly sending unquoted Gmail `query` strings that return zero + // results. On the first call this turn, surface the action's FULL live + // contract (input schema + description) as a recoverable tool error and + // let the retry — now with the schema in context — execute. Degrades to + // a normal execute whenever the contract can't be resolved (see + // `contract_gate::consult`), so an unconfigured/offline client never + // blocks the action. Uses `live_config` so gate routing matches dispatch. + match super::contract_gate::consult(&self.gate, &live_config, &self.action_name).await { + super::contract_gate::GateDecision::Surface(contract) => { + tracing::info!( + tool = %self.action_name, + "[composio][contract-gate] returning full contract before first execute" + ); + return Ok(ToolResult::error(contract)); + } + super::contract_gate::GateDecision::Proceed => {} + } + // Inject `timeZone` / `singleEvents` defaults for Google // Calendar list slugs (issue #1714). The per-action surface is // the spawn-time tool an integrations sub-agent picks when it @@ -202,31 +256,11 @@ impl Tool for ComposioActionTool { &iana, ); - // Resolve the client through the mode-aware factory on every - // call so a direct-mode toggle takes effect immediately - // (#1710). The pre-baked-client variant of this code routed all - // executions through the backend tinyhumans tenant regardless - // of mode — silently breaking direct mode for tool execution. - // [#1710 Wave 4] Reload config fresh per execute so a mid-session - // `composio.mode` toggle takes effect at the very next tool call. - // Anchor the reload to this tool's original config path rather - // than re-resolving process-global `OPENHUMAN_WORKSPACE`; the - // tool is scoped to the user/workspace it was created for. - let live_config = - match config_rpc::reload_config_snapshot_with_timeout(self.config.as_ref()).await { - Ok(c) => c, - Err(e) => { - tracing::warn!( - tool = %self.action_name, - error = %e, - "[composio] per-action execute: load_config failed" - ); - return Ok(ToolResult::error(format!( - "{}: failed to load live config: {e}", - self.action_name - ))); - } - }; + // Resolve the client through the mode-aware factory on every call so a + // direct-mode toggle takes effect immediately (#1710), reusing the + // `live_config` snapshot reloaded above so the gate lookup and this + // dispatch share identical routing. The pre-baked-client variant routed + // all executions through the backend tinyhumans tenant regardless of mode. let kind = match create_composio_client(&live_config) { Ok(kind) => kind, Err(e) => { @@ -489,6 +523,77 @@ mod tests { ); } + // Seeds the flows/tinyflows live-catalog cache, so it only builds with the + // `flows` feature on (the gate degrades to a no-op when flows is off). + #[cfg(feature = "flows")] + #[tokio::test] + async fn contract_gate_surfaces_full_contract_then_proceeds_on_retry() { + // Regression for #4853: the FIRST per-action execute this turn must + // return the action's FULL live contract (so the model composes a + // well-formed query) instead of running with the thin spawn-time + // schema; the retry then proceeds to real dispatch. A unique toolkit is + // seeded so this is deterministic and never touches the network. + use crate::openhuman::config::TEST_ENV_LOCK; + use crate::openhuman::tinyflows::caps::{seed_live_catalog_cache, ToolContract}; + let _env_guard = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + + let toolkit = "cgateexec"; + let slug = "CGATEEXEC_FETCH_ITEMS"; + seed_live_catalog_cache( + toolkit, + vec![ToolContract { + slug: slug.to_string(), + toolkit: toolkit.to_string(), + description: Some("Search items. Quote multi-word phrases.".to_string()), + required_args: vec!["query".to_string()], + input_schema: Some(serde_json::json!({ + "type": "object", + "properties": { "query": { "type": "string" } }, + "required": ["query"] + })), + output_fields: Vec::new(), + output_schema: None, + primary_array_path: None, + is_curated: false, + }], + ); + + let tmp = tempfile::tempdir().expect("tempdir"); + let _workspace_guard = WorkspaceEnvGuard::set(tmp.path()); + let mut config = Config::default(); + config.config_path = tmp.path().join("config.toml"); + config.workspace_dir = tmp.path().join("workspace"); + config.save().await.expect("save fake config to disk"); + + let t = ComposioActionTool::new( + Arc::new(config), + slug.to_string(), + "search items".to_string(), + None, + ); + + // First call: gate surfaces the contract (recoverable tool error). + let first = t.execute(serde_json::json!({})).await.unwrap(); + assert!( + first.is_error, + "first call must surface a recoverable error" + ); + let first_msg = error_text(&first); + assert!( + first_msg.contains("Input JSON schema"), + "first call must carry the full contract, got: {first_msg}" + ); + + // Retry: gate proceeds; dispatch fails downstream (no session token) but + // crucially NOT with the contract text — proving the gate did not block. + let second = t.execute(serde_json::json!({})).await.unwrap(); + let second_msg = error_text(&second); + assert!( + !second_msg.contains("Input JSON schema"), + "retry must proceed past the gate to real dispatch, got: {second_msg}" + ); + } + // ── Factory routing (#1710) ────────────────────────────────────── // // Regression coverage for the bug fix: `ComposioActionTool` now diff --git a/src/openhuman/composio/contract_gate.rs b/src/openhuman/composio/contract_gate.rs new file mode 100644 index 0000000000..5eb7479fc7 --- /dev/null +++ b/src/openhuman/composio/contract_gate.rs @@ -0,0 +1,200 @@ +//! Contract gate for late-bound Composio actions (#4853). +//! +//! Per-action Composio tools handed to `integrations_agent` are built from +//! the lightweight `list_tools` response — a one-line description with a +//! parameter schema that is often thin or absent (see +//! `fetch_toolkit_actions`, consumed in the +//! sub-agent runner). The model therefore composes calls before the action's +//! FULL contract is in context and guesses argument formats — most visibly, it +//! sends Gmail `query` strings without the quoting Gmail search syntax requires, +//! so `GMAIL_FETCH_EMAILS` returns zero results. +//! +//! The gate makes the full contract enter context BEFORE execution: on the +//! first call to an action this turn, if a fuller live contract is available +//! (via the cached `fetch_live_toolkit_catalog`), it is returned as a +//! recoverable tool error instead of executing. The retry — now with the +//! schema/description in context — proceeds normally. This mirrors the +//! discover-then-call discipline the generic `composio_execute` dispatcher +//! already expects (`composio_list_tools` → `composio_execute`), but enforces +//! it on the per-action surface where the model never sees the full schema. +//! +//! Scope: this pass gates the per-action Composio surface +//! ([`super::action_tool::ComposioActionTool`]). Generalising the same gate to +//! the `composio_execute` dispatcher, the MCP bridges, and the Workflow +//! dispatchers — plus resetting the per-turn state on context compaction — is +//! tracked as follow-up (a shared `ToolMiddleware` at the turn-harness seam is +//! the natural home; see the PR description). + +use std::collections::HashSet; +use std::sync::Mutex; + +use crate::openhuman::config::Config; +// The live-contract lookup is sourced from the flows/tinyflows caps catalog, +// which is compiled out when the `flows` feature is off (#4912). The gate then +// simply has no fuller contract to surface and always proceeds, so the import +// and the lookup/format helpers below are gated in lockstep. +#[cfg(feature = "flows")] +use crate::openhuman::composio::providers::toolkit_from_slug; +#[cfg(feature = "flows")] +use crate::openhuman::tinyflows::caps::{fetch_live_toolkit_catalog, ToolContract}; + +/// Record of which action contracts have already been surfaced to the model, +/// so the gate blocks a given action at most once per gate instance. +/// +/// One [`ContractGate`] is held per [`super::action_tool::ComposioActionTool`] +/// instance; those tools are constructed fresh per `integrations_agent` spawn +/// and live for that spawn's tool loop. That loop is a single agent turn in the +/// common case, so "seen" behaves as per-turn state without any task-local +/// plumbing — but a long-lived spawn can span multiple turns, and this gate +/// does NOT reset when the surfaced schema drops out of context via compaction +/// (tracked as follow-up; see the module-level note). Interior-mutable so the +/// gate can record state through the tool's `&self` `execute`. +#[derive(Default)] +pub struct ContractGate { + seen: Mutex>, +} + +impl ContractGate { + pub fn new() -> Self { + Self::default() + } + + /// Insert `slug` (normalised to upper-case) into the seen-set. Returns + /// `true` when it was NOT already present — i.e. this is the first time the + /// gate has been consulted for this action for this gate's lifetime. + /// + /// The lock is taken and released entirely within this call, so no guard is + /// held across the caller's later `await`. + fn mark_seen(&self, slug: &str) -> bool { + let mut guard = self + .seen + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + guard.insert(slug.to_ascii_uppercase()) + } +} + +/// Outcome of consulting the gate for one action call. +pub enum GateDecision { + /// Return this text to the model as a recoverable tool error; the model + /// retries with the contract in context. + Surface(String), + /// Execute the action normally. + Proceed, +} + +/// Consult the gate before executing `action_slug`. +/// +/// On the FIRST consult for a slug this turn, if a fuller live contract can be +/// resolved, returns [`GateDecision::Surface`] with the formatted contract and +/// marks the slug seen. Every later consult — and any consult where no live +/// contract is available (unconfigured client, unknown action, network miss) — +/// returns [`GateDecision::Proceed`], so the gate never blocks an action more +/// than once and never blocks when it cannot help. +pub async fn consult(gate: &ContractGate, config: &Config, action_slug: &str) -> GateDecision { + // Mark first (releasing the lock) so the retry — and any concurrent + // sibling call — proceeds even if the contract lookup below is slow. + let first_time = gate.mark_seen(action_slug); + if !first_time { + tracing::debug!( + target: "composio", + slug = %action_slug, + "[composio][contract-gate] contract already surfaced this turn; proceeding" + ); + return GateDecision::Proceed; + } + + // The live catalog lives in the flows/tinyflows caps layer. With `flows` + // compiled out there is no catalog source, so the gate can never surface a + // fuller contract and always proceeds (the per-action tool still runs; it + // just does not get the pre-execute contract nudge). + #[cfg(feature = "flows")] + if let Some(contract) = lookup_contract(config, action_slug).await { + tracing::debug!( + target: "composio", + slug = %action_slug, + has_input_schema = contract.input_schema.is_some(), + required_arg_count = contract.required_args.len(), + "[composio][contract-gate] surfacing full contract before first execute" + ); + return GateDecision::Surface(format_contract(action_slug, &contract)); + } + + // `config` is only consulted through the flows-gated lookup above. + #[cfg(not(feature = "flows"))] + let _ = config; + + tracing::debug!( + target: "composio", + slug = %action_slug, + "[composio][contract-gate] no live contract available; proceeding without gating" + ); + GateDecision::Proceed +} + +/// Resolve the full live contract for `action_slug` from the process-cached +/// live toolkit catalog. Returns `None` when the toolkit can't be derived, the +/// catalog can't be fetched (unconfigured / offline — `fetch_live_toolkit_catalog` +/// degrades to `None`), or the action isn't in it. +#[cfg(feature = "flows")] +async fn lookup_contract(config: &Config, action_slug: &str) -> Option { + let toolkit = toolkit_from_slug(action_slug)?; + let contracts = fetch_live_toolkit_catalog(config, &toolkit).await?; + contracts + .into_iter() + .find(|c| c.slug.eq_ignore_ascii_case(action_slug)) +} + +/// Render the contract into a compact instruction for the model. Contains only +/// the provider's own action description + JSON schema — no user data / PII. +#[cfg(feature = "flows")] +fn format_contract(action_slug: &str, contract: &ToolContract) -> String { + let mut out = format!( + "Before running `{action_slug}`, read its full contract below and then re-issue \ + the call with arguments that match it exactly.\n\n" + ); + + if let Some(desc) = contract + .description + .as_deref() + .map(str::trim) + .filter(|d| !d.is_empty()) + { + out.push_str("Description:\n"); + out.push_str(desc); + out.push_str("\n\n"); + } + + match contract.input_schema.as_ref() { + Some(schema) => { + let pretty = + serde_json::to_string_pretty(schema).unwrap_or_else(|_| schema.to_string()); + out.push_str("Input JSON schema:\n"); + out.push_str(&pretty); + out.push('\n'); + } + None => out.push_str("Input JSON schema: not published by the provider for this action.\n"), + } + + if !contract.required_args.is_empty() { + out.push_str(&format!( + "\nRequired arguments: {}\n", + contract.required_args.join(", ") + )); + } + + out.push_str( + "\nCompose every argument to match this schema and any format rules in the \ + description. Text-search fields in particular often require the provider's exact \ + query syntax (for example, Gmail needs multi-word phrases quoted, like \ + subject:\"quarterly report\"). Then call the action again with the corrected \ + arguments.", + ); + out +} + +// The gate's unit tests seed the flows/tinyflows live-catalog cache, so they +// only compile and run with the `flows` feature on. +#[cfg(all(test, feature = "flows"))] +#[path = "contract_gate_tests.rs"] +mod tests; diff --git a/src/openhuman/composio/contract_gate_tests.rs b/src/openhuman/composio/contract_gate_tests.rs new file mode 100644 index 0000000000..f391281d3d --- /dev/null +++ b/src/openhuman/composio/contract_gate_tests.rs @@ -0,0 +1,134 @@ +//! Unit tests for the Composio contract gate (#4853). +//! +//! These exercise the gate purely against the process-level live-catalog cache +//! (seeded via [`seed_live_catalog_cache`]), so no Composio client is built and +//! no network call is made. Each test uses a unique toolkit slug so the shared +//! `LIVE_CATALOG_CACHE` can't cross-contaminate between tests. + +use super::{consult, ContractGate, GateDecision}; +use crate::openhuman::config::Config; +use crate::openhuman::tinyflows::caps::{seed_live_catalog_cache, ToolContract}; + +/// Build a full contract for `slug` in `toolkit` with a `query` input field and +/// a description that spells out the quoting rule — the exact detail the model +/// misses when it only sees the thin spawn-time schema. +fn full_contract(slug: &str, toolkit: &str) -> ToolContract { + ToolContract { + slug: slug.to_string(), + toolkit: toolkit.to_string(), + description: Some( + "Search the mailbox. Multi-word phrases in `query` must be quoted, \ + e.g. subject:\"quarterly report\"." + .to_string(), + ), + required_args: vec!["query".to_string()], + input_schema: Some(serde_json::json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Gmail search query (quote multi-word phrases)." + } + }, + "required": ["query"] + })), + output_fields: Vec::new(), + output_schema: None, + primary_array_path: None, + is_curated: false, + } +} + +#[tokio::test] +async fn first_call_surfaces_full_contract_then_retry_proceeds() { + // Toolkit derived from the slug prefix: `GMAILGATE_...` -> `gmailgate`. + let toolkit = "gmailgate"; + let slug = "GMAILGATE_FETCH_EMAILS"; + seed_live_catalog_cache(toolkit, vec![full_contract(slug, toolkit)]); + + let config = Config::default(); + let gate = ContractGate::new(); + + // First call: the gate short-circuits execution and hands back the full + // contract (this is the behaviour that is ABSENT before the fix — the thin + // per-action tool would execute immediately with a guessed query). + match consult(&gate, &config, slug).await { + GateDecision::Surface(message) => { + assert!(message.contains(slug), "contract names the action slug"); + assert!( + message.contains("query"), + "contract carries the input schema" + ); + assert!( + message.contains("Required arguments: query"), + "contract lists required args" + ); + assert!( + message.contains("quoted"), + "contract carries the provider description explaining quoting" + ); + } + GateDecision::Proceed => panic!("first call must surface the contract, not execute"), + } + + // The retry — now with the contract in context — proceeds to execution. + assert!( + matches!(consult(&gate, &config, slug).await, GateDecision::Proceed), + "retry must proceed once the contract has been surfaced this turn" + ); +} + +#[tokio::test] +async fn known_toolkit_but_unknown_action_proceeds_without_blocking() { + // Toolkit is cached but does NOT contain the requested action, so no + // fuller contract can be surfaced. The gate must degrade to Proceed rather + // than block the call forever. + let toolkit = "partialkit"; + seed_live_catalog_cache( + toolkit, + vec![full_contract("PARTIALKIT_OTHER_ACTION", toolkit)], + ); + + let config = Config::default(); + let gate = ContractGate::new(); + + assert!( + matches!( + consult(&gate, &config, "PARTIALKIT_FETCH_EMAILS").await, + GateDecision::Proceed + ), + "an action missing from the live catalog must not be gated" + ); +} + +#[tokio::test] +async fn distinct_actions_are_gated_independently() { + let toolkit = "multikit"; + let fetch = "MULTIKIT_FETCH_EMAILS"; + let send = "MULTIKIT_SEND_EMAIL"; + seed_live_catalog_cache( + toolkit, + vec![full_contract(fetch, toolkit), full_contract(send, toolkit)], + ); + + let config = Config::default(); + let gate = ContractGate::new(); + + // Each action surfaces its own contract exactly once, independently. + assert!(matches!( + consult(&gate, &config, fetch).await, + GateDecision::Surface(_) + )); + assert!(matches!( + consult(&gate, &config, send).await, + GateDecision::Surface(_) + )); + assert!(matches!( + consult(&gate, &config, fetch).await, + GateDecision::Proceed + )); + assert!(matches!( + consult(&gate, &config, send).await, + GateDecision::Proceed + )); +} diff --git a/src/openhuman/composio/mod.rs b/src/openhuman/composio/mod.rs index 9120e90851..cb5c2162a8 100644 --- a/src/openhuman/composio/mod.rs +++ b/src/openhuman/composio/mod.rs @@ -40,6 +40,7 @@ pub mod auth_retry; pub mod bus; pub mod client; mod connected_integrations; +pub mod contract_gate; pub(crate) mod direct_auth; pub mod error_mapping; pub mod execute_dispatch; From baeda24246ada8a125b7891849839aff05f5cc3f Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Sat, 18 Jul 2026 09:14:08 +0530 Subject: [PATCH 77/86] feat(tinyplace): add own-profile editing (name/bio/avatar) to Agent World (Closes #4930) (#4996) --- .../agentworld/pages/ProfilesSection.test.tsx | 140 ++++++++++++ app/src/agentworld/pages/ProfilesSection.tsx | 199 +++++++++++++++++- app/src/lib/i18n/ar.ts | 10 + app/src/lib/i18n/bn.ts | 10 + app/src/lib/i18n/de.ts | 11 + app/src/lib/i18n/en.ts | 10 + app/src/lib/i18n/es.ts | 10 + app/src/lib/i18n/fr.ts | 10 + app/src/lib/i18n/hi.ts | 10 + app/src/lib/i18n/id.ts | 10 + app/src/lib/i18n/it.ts | 10 + app/src/lib/i18n/ko.ts | 10 + app/src/lib/i18n/pl.ts | 10 + app/src/lib/i18n/pt.ts | 10 + app/src/lib/i18n/ru.ts | 10 + app/src/lib/i18n/zh-CN.ts | 10 + 16 files changed, 479 insertions(+), 1 deletion(-) diff --git a/app/src/agentworld/pages/ProfilesSection.test.tsx b/app/src/agentworld/pages/ProfilesSection.test.tsx index 5f8a449a7f..ddcea8893f 100644 --- a/app/src/agentworld/pages/ProfilesSection.test.tsx +++ b/app/src/agentworld/pages/ProfilesSection.test.tsx @@ -21,6 +21,7 @@ vi.mock('../AgentWorldShell', () => ({ follows: { stats: vi.fn() }, registry: { export: vi.fn(), assignPrimary: vi.fn() }, graphql: { user: vi.fn() }, + users: { get: vi.fn(), updateProfile: vi.fn() }, }, })); vi.mock('../../services/walletApi', () => ({ fetchWalletStatus: vi.fn() })); @@ -31,6 +32,8 @@ const followStats = vi.mocked(apiClient.follows.stats); const registryExport = vi.mocked(apiClient.registry.export); const assignPrimary = vi.mocked(apiClient.registry.assignPrimary); const graphqlUser = vi.mocked(apiClient.graphql.user); +const usersGet = vi.mocked(apiClient.users.get); +const updateProfile = vi.mocked(apiClient.users.updateProfile); const SOLANA_ADDR = 'WaLLetSoLanaAddr0123456789'; @@ -51,6 +54,26 @@ beforeEach(() => { graphqlUser.mockResolvedValue(null); reverse.mockResolvedValue({ cryptoId: SOLANA_ADDR, identities: [] }); followStats.mockResolvedValue({ agentId: '', followerCount: 0, followingCount: 0 }); + // Own-profile edit path (#4930): users.get seeds the writable fields, and + // updateProfile echoes back the saved User. + usersGet.mockResolvedValue({ + cryptoId: SOLANA_ADDR, + actorType: 'agent', + displayName: 'Test Agent', + bio: '', + emailVerified: false, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + } as unknown as Awaited>); + updateProfile.mockResolvedValue({ + cryptoId: SOLANA_ADDR, + actorType: 'agent', + displayName: 'Test Agent', + bio: '', + emailVerified: false, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + } as unknown as Awaited>); }); // ── Loading ─────────────────────────────────────────────────────────────────── @@ -483,3 +506,120 @@ describe('graphql-enriched profile card', () => { expect(screen.queryByText('Verified Accounts')).not.toBeInTheDocument(); }); }); + +// ── Own-profile editing (#4930) ────────────────────────────────────────────── + +describe('own-profile editing', () => { + test('offers an Edit profile button on the own graphql profile', async () => { + graphqlUser.mockResolvedValueOnce(makeProfile({ displayName: 'Agent Alice', bio: 'Old bio' })); + render(); + expect(await screen.findByText('Agent Alice')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /edit profile/i })).toBeInTheDocument(); + }); + + test('saves name + bio via users.updateProfile, then refetches so saved values show', async () => { + graphqlUser + .mockResolvedValueOnce(makeProfile({ displayName: 'Agent Alice', bio: 'Old bio' })) + .mockResolvedValue(makeProfile({ displayName: 'Agent Alice v2', bio: 'New bio' })); + usersGet.mockResolvedValue({ + cryptoId: SOLANA_ADDR, + actorType: 'agent', + displayName: 'Agent Alice', + bio: 'Old bio', + emailVerified: false, + createdAt: '', + updatedAt: '', + } as unknown as Awaited>); + + render(); + fireEvent.click(await screen.findByRole('button', { name: /edit profile/i })); + + const nameInput = await screen.findByRole('textbox', { name: /display name/i }); + // Prefilled from users.get (the authoritative writable record). + await waitFor(() => expect(nameInput).toHaveValue('Agent Alice')); + const bioInput = screen.getByRole('textbox', { name: /^bio$/i }); + + fireEvent.change(nameInput, { target: { value: 'Agent Alice v2' } }); + fireEvent.change(bioInput, { target: { value: 'New bio' } }); + fireEvent.click(screen.getByRole('button', { name: /^save$/i })); + + await waitFor(() => + expect(updateProfile).toHaveBeenCalledWith( + SOLANA_ADDR, + expect.objectContaining({ displayName: 'Agent Alice v2', bio: 'New bio' }) + ) + ); + // Refetch renders the saved values. + expect(await screen.findByText('Agent Alice v2')).toBeInTheDocument(); + }); + + test('a late prefill fetch does not clobber what the user already typed (#4930)', async () => { + graphqlUser.mockResolvedValue(makeProfile({ displayName: 'Agent Alice', bio: 'Old bio' })); + // Defer users.get so it resolves AFTER the user has started editing. + let resolveGet!: (value: unknown) => void; + const deferred = new Promise(res => { + resolveGet = res; + }); + usersGet.mockReturnValue(deferred as unknown as ReturnType); + + render(); + fireEvent.click(await screen.findByRole('button', { name: /edit profile/i })); + + const nameInput = await screen.findByRole('textbox', { name: /display name/i }); + // User types before the prefill lands. + fireEvent.change(nameInput, { target: { value: 'My New Name' } }); + + // Prefill resolves late with the authoritative (different) record. + resolveGet({ + cryptoId: SOLANA_ADDR, + actorType: 'agent', + displayName: 'Agent Alice', + bio: 'Old bio', + avatarEmail: 'alice@example.com', + emailVerified: false, + createdAt: '', + updatedAt: '', + }); + + // The user's edit survives; the prefill must not overwrite the touched field. + await waitFor(() => expect(nameInput).toHaveValue('My New Name')); + }); + + test('keeps the form open and surfaces an error when save fails', async () => { + graphqlUser.mockResolvedValue(makeProfile({ displayName: 'Agent Alice', bio: 'Old bio' })); + updateProfile.mockRejectedValueOnce(new Error('network down')); + + render(); + fireEvent.click(await screen.findByRole('button', { name: /edit profile/i })); + fireEvent.click(await screen.findByRole('button', { name: /^save$/i })); + + expect(await screen.findByText(/could not save your profile/i)).toBeInTheDocument(); + expect(screen.getByTestId('profile-edit-form')).toBeInTheDocument(); + expect(updateProfile).toHaveBeenCalledTimes(1); + }); + + test('will not save an empty display name (guards against blanking it)', async () => { + graphqlUser.mockResolvedValue(makeProfile({ displayName: 'Agent Alice', bio: 'Old bio' })); + usersGet.mockResolvedValue({ + cryptoId: SOLANA_ADDR, + actorType: 'agent', + displayName: 'Agent Alice', + bio: 'Old bio', + emailVerified: false, + createdAt: '', + updatedAt: '', + } as unknown as Awaited>); + + render(); + fireEvent.click(await screen.findByRole('button', { name: /edit profile/i })); + const nameInput = await screen.findByRole('textbox', { name: /display name/i }); + await waitFor(() => expect(nameInput).toHaveValue('Agent Alice')); + + // Clear the name → Save is disabled and never blanks the profile. + fireEvent.change(nameInput, { target: { value: ' ' } }); + const saveBtn = screen.getByRole('button', { name: /^save$/i }); + expect(saveBtn).toBeDisabled(); + fireEvent.click(saveBtn); + expect(updateProfile).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/agentworld/pages/ProfilesSection.tsx b/app/src/agentworld/pages/ProfilesSection.tsx index e98d315220..29268e8c97 100644 --- a/app/src/agentworld/pages/ProfilesSection.tsx +++ b/app/src/agentworld/pages/ProfilesSection.tsx @@ -7,7 +7,8 @@ * "register a handle" prompt when the wallet owns none, and a wallet-locked * notice when the wallet isn't set up. */ -import { useCallback, useEffect, useState } from 'react'; +import debug from 'debug'; +import { useCallback, useEffect, useRef, useState } from 'react'; import PanelScaffold from '../../components/layout/PanelScaffold'; import Button from '../../components/ui/Button'; @@ -18,9 +19,12 @@ import { type IdentityExport, PaymentRequiredError, } from '../../lib/agentworld/invokeApiClient'; +import { useT } from '../../lib/i18n/I18nContext'; import { fetchWalletStatus } from '../../services/walletApi'; import { apiClient } from '../AgentWorldShell'; +const log = debug('agentworld:profile'); + /** A handle registered to the wallet (subset of the directory.reverse identity). */ interface OwnedIdentity { username?: string; @@ -168,7 +172,175 @@ function useMyIdentity(reloadKey: number): ProfileState { // ── Sub-components ──────────────────────────────────────────────────────────── +/** + * Own-profile edit form (name / bio / avatar), wired to the already-plumbed + * `users.updateProfile` write path (#4930). Prefills the writable fields from + * the authoritative `users.get` record — including `avatarEmail`, which the + * display-side `GqlProfile` does not carry (the shown avatar URL is derived + * server-side from the Gravatar email). On success the parent refetches so the + * saved values render. + */ +function ProfileEditForm({ + cryptoId, + initialDisplayName, + initialBio, + onCancel, + onSaved, +}: { + cryptoId: string; + initialDisplayName: string; + initialBio: string; + onCancel: () => void; + onSaved: () => void; +}) { + const { t } = useT(); + const [displayName, setDisplayName] = useState(initialDisplayName); + const [bio, setBio] = useState(initialBio); + const [avatarEmail, setAvatarEmail] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + // Which fields the user has edited. The async prefill below must never + // overwrite a field the user already started typing in (its fetch can resolve + // after the user opens the form and begins editing — silently discarding + // their input otherwise, #4930 review). + const touched = useRef({ displayName: false, bio: false, avatarEmail: false }); + + // Seed the writable fields (notably avatarEmail, absent from GqlProfile) from + // the authoritative user record. Non-fatal: on failure we keep the values + // already seeded from the displayed profile. Untouched fields only. + useEffect(() => { + let cancelled = false; + void apiClient.users + .get(cryptoId) + .then(user => { + if (cancelled) return; + if (!touched.current.displayName && typeof user?.displayName === 'string') + setDisplayName(user.displayName); + if (!touched.current.bio && typeof user?.bio === 'string') setBio(user.bio); + if (!touched.current.avatarEmail && typeof user?.avatarEmail === 'string') + setAvatarEmail(user.avatarEmail); + }) + .catch((err: unknown) => { + log('prefill from users.get failed: %s', String(err)); + }); + return () => { + cancelled = true; + }; + }, [cryptoId]); + + const handleSave = useCallback(async () => { + // A display name is required: `displayName`/`bio` are always sent trimmed, + // so an empty name would blank the existing one. Guard here (and disable + // Save below) rather than silently clearing it. `bio` may be empty; only + // `avatarEmail` is omitted-when-empty (so an existing avatar isn't cleared). + if (!displayName.trim()) { + setError(t('agentWorld.profile.nameRequired', 'Display name cannot be empty.')); + return; + } + setSaving(true); + setError(null); + log('saving profile update'); + try { + await apiClient.users.updateProfile(cryptoId, { + displayName: displayName.trim(), + bio: bio.trim(), + // Only send avatarEmail when the user provided one, so an untouched + // (empty) field never blanks an existing avatar. It is a Gravatar + // email, not an image URL. + ...(avatarEmail.trim() ? { avatarEmail: avatarEmail.trim() } : {}), + }); + log('profile update saved'); + onSaved(); + } catch (err) { + log('profile update failed: %s', String(err)); + setError(t('agentWorld.profile.saveError', 'Could not save your profile. Try again.')); + } finally { + setSaving(false); + } + }, [avatarEmail, bio, cryptoId, displayName, onSaved, t]); + + const inputClass = + 'w-full rounded-md border border-line bg-surface px-2.5 py-1.5 text-sm text-content ' + + 'placeholder:text-content-faint focus:border-primary-500 focus:outline-none'; + + return ( +
{ + e.preventDefault(); + void handleSave(); + }}> + + +