diff --git a/app/src/components/chat/WorkflowProposalCard.test.tsx b/app/src/components/chat/WorkflowProposalCard.test.tsx new file mode 100644 index 000000000..5e3b086ec --- /dev/null +++ b/app/src/components/chat/WorkflowProposalCard.test.tsx @@ -0,0 +1,116 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { WorkflowProposal } from '../../store/chatRuntimeSlice'; +import { WorkflowProposalCard } from './WorkflowProposalCard'; + +// Echo i18n keys so we can assert on the stable key string. +vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) })); + +const mockCreateFlow = vi.fn(); +vi.mock('../../services/api/flowsApi', () => ({ + createFlow: (...args: unknown[]) => mockCreateFlow(...args), +})); + +const mockDispatch = vi.fn(); +vi.mock('../../store/hooks', () => ({ useAppDispatch: () => mockDispatch })); + +function proposal(partial: Partial = {}): WorkflowProposal { + return { + name: 'Daily standup summary', + graph: { nodes: [], edges: [] }, + requireApproval: true, + summary: { + trigger: 'schedule: 0 9 * * *', + steps: [ + { kind: 'agent', name: 'Summarize', config_hint: "Summarize yesterday's messages" }, + { kind: 'tool_call', name: 'Post to Slack' }, + ], + }, + ...partial, + }; +} + +describe('WorkflowProposalCard', () => { + beforeEach(() => { + mockCreateFlow.mockReset().mockResolvedValue({ id: 'f1', name: 'Daily standup summary' }); + mockDispatch.mockReset(); + }); + + it('renders the name, trigger, and steps with node-kind badges', () => { + render(); + expect(screen.getByText('Daily standup summary')).toBeInTheDocument(); + expect(screen.getByText('schedule: 0 9 * * *')).toBeInTheDocument(); + expect(screen.getByText('Summarize')).toBeInTheDocument(); + expect(screen.getByText('Post to Slack')).toBeInTheDocument(); + expect(screen.getByText('agent')).toBeInTheDocument(); + expect(screen.getByText('tool_call')).toBeInTheDocument(); + expect(screen.getAllByTestId('workflow-proposal-step-kind')).toHaveLength(2); + }); + + it('has the expected root test id', () => { + render(); + expect(screen.getByTestId('workflow-proposal-card')).toBeInTheDocument(); + }); + + it('saves via createFlow with the right args and clears optimistically', async () => { + const p = proposal(); + render(); + fireEvent.click(screen.getByText('chat.flowProposal.save')); + await waitFor(() => + expect(mockCreateFlow).toHaveBeenCalledWith(p.name, p.graph, p.requireApproval) + ); + expect(mockDispatch).toHaveBeenCalledTimes(1); + }); + + it('shows a loading state while saving', async () => { + let resolveCreate!: (value: unknown) => void; + mockCreateFlow.mockReturnValueOnce( + new Promise(resolve => { + resolveCreate = resolve; + }) + ); + render(); + fireEvent.click(screen.getByText('chat.flowProposal.save')); + await waitFor(() => expect(screen.getByText('chat.flowProposal.saving')).toBeInTheDocument()); + resolveCreate({ id: 'f1' }); + }); + + it('surfaces an error and stays mounted when createFlow fails', async () => { + mockCreateFlow.mockRejectedValueOnce(new Error('boom')); + render(); + fireEvent.click(screen.getByText('chat.flowProposal.save')); + await waitFor(() => expect(screen.getByText(/chat\.flowProposal\.error/)).toBeInTheDocument()); + // Not cleared on failure. + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('dismiss clears the proposal without calling createFlow', () => { + render(); + fireEvent.click(screen.getByText('chat.flowProposal.dismiss')); + expect(mockCreateFlow).not.toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledTimes(1); + }); + + it('renders a fallback message when there are no non-trigger steps', () => { + render( + + ); + expect(screen.getByText('chat.flowProposal.noSteps')).toBeInTheDocument(); + }); + + it('shows the require-approval hint only when requireApproval is true', () => { + const { rerender } = render( + + ); + expect(screen.getByText('chat.flowProposal.requireApprovalHint')).toBeInTheDocument(); + + rerender( + + ); + expect(screen.queryByText('chat.flowProposal.requireApprovalHint')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/components/chat/WorkflowProposalCard.tsx b/app/src/components/chat/WorkflowProposalCard.tsx new file mode 100644 index 000000000..8ca881080 --- /dev/null +++ b/app/src/components/chat/WorkflowProposalCard.tsx @@ -0,0 +1,143 @@ +import debug from 'debug'; +import React, { useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { createFlow } from '../../services/api/flowsApi'; +import { + clearWorkflowProposalForThread, + type WorkflowProposal, +} from '../../store/chatRuntimeSlice'; +import { useAppDispatch } from '../../store/hooks'; +import Button from '../ui/Button'; + +const log = debug('openhuman:chat:workflow-proposal-card'); + +interface Props { + threadId: string; + proposal: WorkflowProposal; +} + +/** + * Human-in-the-loop gate for the `propose_workflow` agent tool (issue B4 — + * agent-first Workflow authoring). The tool only VALIDATES a candidate + * `tinyflows` graph and returns a summary — it can NEVER create or enable a + * flow itself. This card is the only path from a proposal to a saved + * automation: "Save & enable" calls `openhuman.flows_create` directly from + * the client; the agent has no way to reach that RPC on its own. "Dismiss" + * just clears the proposal without saving anything. + * + * Mirrors {@link PlanReviewCard}'s placement/chrome above the composer, and + * the tool-timeline `StatusTag`/detail-chip visual language for the + * node-kind badges + config hints in the step list. + */ +export const WorkflowProposalCard: React.FC = ({ threadId, proposal }) => { + const { t } = useT(); + const dispatch = useAppDispatch(); + const [saving, setSaving] = useState(false); + const [errorMsg, setErrorMsg] = useState(null); + + const dismiss = () => { + dispatch(clearWorkflowProposalForThread({ threadId })); + }; + + const save = async () => { + if (saving) return; + setSaving(true); + setErrorMsg(null); + try { + await createFlow(proposal.name, proposal.graph, proposal.requireApproval); + dispatch(clearWorkflowProposalForThread({ threadId })); + } catch (e) { + log('createFlow failed: %o', e); + setErrorMsg(t('chat.flowProposal.error')); + setSaving(false); + } + }; + + return ( +
+
+ + ⚙️ + +
+

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

+

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

+ +

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

+ +
+

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

+ {proposal.summary.steps.length > 0 ? ( +
    + {proposal.summary.steps.map((step, i) => ( +
  1. + + {step.kind} + + {step.name} + {step.config_hint ? ( + + {step.config_hint} + + ) : null} +
  2. + ))} +
+ ) : ( +

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

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

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

+ )} + + {errorMsg &&

⚠ {errorMsg}

} + +
+ + +
+
+
+
+ ); +}; + +export default WorkflowProposalCard; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index afca73c85..0337659fd 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -3019,6 +3019,16 @@ const messages: TranslationMap = { 'chat.approval.fallback': 'العميل يريد القيام بعمل يحتاج إلى موافقتك', 'chat.approval.title': 'الموافقة المطلوبة', 'chat.approval.tool': 'Tool:', + 'chat.flowProposal.title': 'اقتراح مسار العمل', + 'chat.flowProposal.subtitle': 'راجع هذه الأتمتة قبل حفظها.', + 'chat.flowProposal.triggerLabel': 'المُشغّل', + 'chat.flowProposal.stepsLabel': 'الخطوات', + 'chat.flowProposal.noSteps': 'لا توجد خطوات إضافية.', + 'chat.flowProposal.requireApprovalHint': 'سيتطلب كل إجراء صادر موافقتك.', + 'chat.flowProposal.save': 'حفظ وتفعيل', + 'chat.flowProposal.saving': 'جارٍ الحفظ…', + 'chat.flowProposal.dismiss': 'إغلاق', + 'chat.flowProposal.error': 'تعذّر حفظ سير العمل. حاول مرة أخرى.', 'channels.authMode.managed_dm': 'قم بتسجيل الدخول باستخدام OpenHuman', 'channels.authMode.oauth': 'OAuth تسجيل الدخول', 'channels.authMode.bot_token': 'استخدم رمز الروبوت الخاص بك', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index c460c7c54..4b6ec406c 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -3084,6 +3084,17 @@ const messages: TranslationMap = { 'chat.approval.fallback': 'এজেন্ট এমন কাজ করতে চায় যা আপনার অনুমোদন প্রয়োজন.', 'chat.approval.title': 'অনুমোদন প্রয়োজন', 'chat.approval.tool': 'টুল:', + 'chat.flowProposal.title': 'ওয়ার্কফ্লো প্রস্তাব', + 'chat.flowProposal.subtitle': 'সংরক্ষণ করার আগে এই অটোমেশনটি পর্যালোচনা করুন।', + 'chat.flowProposal.triggerLabel': 'ট্রিগার', + 'chat.flowProposal.stepsLabel': 'ধাপসমূহ', + 'chat.flowProposal.noSteps': 'কোনো অতিরিক্ত ধাপ নেই।', + 'chat.flowProposal.requireApprovalHint': + 'প্রতিটি বহির্গামী কাজের জন্য আপনার অনুমোদন প্রয়োজন হবে।', + 'chat.flowProposal.save': 'সংরক্ষণ ও সক্রিয় করুন', + 'chat.flowProposal.saving': 'সংরক্ষণ করা হচ্ছে…', + 'chat.flowProposal.dismiss': 'খারিজ করুন', + 'chat.flowProposal.error': 'ওয়ার্কফ্লো সংরক্ষণ করা যায়নি। আবার চেষ্টা করুন।', 'channels.authMode.managed_dm': 'OpenHuman দিয়ে লগইন করুন', 'channels.authMode.oauth': 'OAuth সাইন-ইন করুন', 'channels.authMode.bot_token': 'আপনার নিজের বট টোকেন ব্যবহার করুন', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index c485e58f1..d113fe50d 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -3161,6 +3161,17 @@ const messages: TranslationMap = { 'Der Agent möchte eine Aktion ausführen, die Ihre Zustimmung erfordert.', 'chat.approval.title': 'Genehmigung erforderlich', 'chat.approval.tool': 'Werkzeug:', + 'chat.flowProposal.title': 'Workflow-Vorschlag', + 'chat.flowProposal.subtitle': 'Prüfen Sie diese Automatisierung, bevor Sie sie speichern.', + 'chat.flowProposal.triggerLabel': 'Auslöser', + 'chat.flowProposal.stepsLabel': 'Schritte', + 'chat.flowProposal.noSteps': 'Keine weiteren Schritte.', + 'chat.flowProposal.requireApprovalHint': 'Jede ausgehende Aktion benötigt Ihre Genehmigung.', + 'chat.flowProposal.save': 'Speichern & aktivieren', + 'chat.flowProposal.saving': 'Wird gespeichert…', + 'chat.flowProposal.dismiss': 'Verwerfen', + 'chat.flowProposal.error': + 'Der Workflow konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.', 'channels.authMode.managed_dm': 'Mit OpenHuman anmelden', 'channels.authMode.oauth': 'OAuth-Anmeldung', 'channels.authMode.bot_token': 'Eigenen Bot-Token verwenden', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index d4e044a41..982dc686e 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -3544,6 +3544,16 @@ const en: TranslationMap = { 'chat.approval.fallback': 'The agent wants to run an action that needs your approval.', 'chat.approval.title': 'Approval needed', 'chat.approval.tool': 'Tool:', + 'chat.flowProposal.title': 'Workflow proposal', + 'chat.flowProposal.subtitle': 'Review this automation before saving it.', + 'chat.flowProposal.triggerLabel': 'Trigger', + 'chat.flowProposal.stepsLabel': 'Steps', + 'chat.flowProposal.noSteps': 'No additional steps.', + 'chat.flowProposal.requireApprovalHint': 'Every outbound action will need your approval.', + 'chat.flowProposal.save': 'Save & enable', + 'chat.flowProposal.saving': 'Saving…', + 'chat.flowProposal.dismiss': 'Dismiss', + 'chat.flowProposal.error': 'Could not save the workflow. Please try again.', // Auth mode labels 'channels.authMode.managed_dm': 'Login with OpenHuman', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 0396f8e32..001bb6212 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -3139,6 +3139,16 @@ const messages: TranslationMap = { 'chat.approval.fallback': 'El agente quiere ejecutar una acción que necesita su aprobación.', 'chat.approval.title': 'Aprobación necesaria', 'chat.approval.tool': 'Herramienta:', + 'chat.flowProposal.title': 'Propuesta de flujo de trabajo', + 'chat.flowProposal.subtitle': 'Revisa esta automatización antes de guardarla.', + 'chat.flowProposal.triggerLabel': 'Disparador', + 'chat.flowProposal.stepsLabel': 'Pasos', + 'chat.flowProposal.noSteps': 'No hay pasos adicionales.', + 'chat.flowProposal.requireApprovalHint': 'Cada acción saliente necesitará tu aprobación.', + 'chat.flowProposal.save': 'Guardar y activar', + 'chat.flowProposal.saving': 'Guardando…', + 'chat.flowProposal.dismiss': 'Descartar', + 'chat.flowProposal.error': 'No se pudo guardar el flujo de trabajo. Inténtalo de nuevo.', 'channels.authMode.managed_dm': 'Iniciar sesión con OpenHuman', 'channels.authMode.oauth': 'OAuth Iniciar sesión', 'channels.authMode.bot_token': 'Utilice su propio token de bot', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 7ca9c7e80..088ffb856 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -3153,6 +3153,16 @@ const messages: TranslationMap = { 'chat.approval.fallback': "L'agent veut exécuter une action qui nécessite votre approbation.", 'chat.approval.title': 'Approbation requise', 'chat.approval.tool': 'Outil:', + 'chat.flowProposal.title': 'Proposition de workflow', + 'chat.flowProposal.subtitle': "Vérifiez cette automatisation avant de l'enregistrer.", + 'chat.flowProposal.triggerLabel': 'Déclencheur', + 'chat.flowProposal.stepsLabel': 'Étapes', + 'chat.flowProposal.noSteps': 'Aucune étape supplémentaire.', + 'chat.flowProposal.requireApprovalHint': 'Chaque action sortante nécessitera votre approbation.', + 'chat.flowProposal.save': 'Enregistrer et activer', + 'chat.flowProposal.saving': 'Enregistrement…', + 'chat.flowProposal.dismiss': 'Ignorer', + 'chat.flowProposal.error': "Impossible d'enregistrer le workflow. Veuillez réessayer.", 'channels.authMode.managed_dm': 'Connectez-vous avec OpenHuman', 'channels.authMode.oauth': 'OAuth Connectez-vous', 'channels.authMode.bot_token': 'Utiliser votre propre jeton de robot', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 415b1ecc8..bbab4ff65 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -3084,6 +3084,16 @@ const messages: TranslationMap = { 'chat.approval.fallback': 'एजेंट अपने अनुमोदन की जरूरत है कि एक कार्रवाई चलाने के लिए चाहता है।', 'chat.approval.title': 'आवश्यक अनुमोदन', 'chat.approval.tool': 'उपकरण:', + 'chat.flowProposal.title': 'वर्कफ़्लो प्रस्ताव', + 'chat.flowProposal.subtitle': 'सहेजने से पहले इस स्वचालन की समीक्षा करें।', + 'chat.flowProposal.triggerLabel': 'ट्रिगर', + 'chat.flowProposal.stepsLabel': 'चरण', + 'chat.flowProposal.noSteps': 'कोई अतिरिक्त चरण नहीं।', + 'chat.flowProposal.requireApprovalHint': 'हर बाहरी कार्रवाई के लिए आपकी स्वीकृति आवश्यक होगी।', + 'chat.flowProposal.save': 'सहेजें और सक्षम करें', + 'chat.flowProposal.saving': 'सहेजा जा रहा है…', + 'chat.flowProposal.dismiss': 'खारिज करें', + 'chat.flowProposal.error': 'वर्कफ़्लो सहेजा नहीं जा सका। कृपया फिर से प्रयास करें।', 'channels.authMode.managed_dm': 'OpenHuman से लॉगिन करें', 'channels.authMode.oauth': 'OAuth साइन-इन करें', 'channels.authMode.bot_token': 'अपने स्वयं के बॉट टोकन का उपयोग करें', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index f04622da4..3cffce023 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -3094,6 +3094,17 @@ const messages: TranslationMap = { 'chat.approval.fallback': 'Agen ingin melakukan tindakan yang membutuhkan persetujuanmu.', 'chat.approval.title': 'Perlu persetujuan', 'chat.approval.tool': 'Alat:', + 'chat.flowProposal.title': 'Proposal alur kerja', + 'chat.flowProposal.subtitle': 'Tinjau otomatisasi ini sebelum menyimpannya.', + 'chat.flowProposal.triggerLabel': 'Pemicu', + 'chat.flowProposal.stepsLabel': 'Langkah', + 'chat.flowProposal.noSteps': 'Tidak ada langkah tambahan.', + 'chat.flowProposal.requireApprovalHint': + 'Setiap tindakan keluar akan memerlukan persetujuan Anda.', + 'chat.flowProposal.save': 'Simpan & aktifkan', + 'chat.flowProposal.saving': 'Menyimpan…', + 'chat.flowProposal.dismiss': 'Abaikan', + 'chat.flowProposal.error': 'Alur kerja tidak dapat disimpan. Silakan coba lagi.', 'channels.authMode.managed_dm': 'Masuk dengan OpenHuman', 'channels.authMode.oauth': 'OAuth Masuk', 'channels.authMode.bot_token': 'Gunakan Token Bot Anda sendiri', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 9399512b5..91d7b02f8 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -3135,6 +3135,16 @@ const messages: TranslationMap = { "L'agente vuole eseguire un'azione che necessita della tua approvazione.", 'chat.approval.title': 'Approvazione necessaria', 'chat.approval.tool': 'Strumento:', + 'chat.flowProposal.title': 'Proposta di workflow', + 'chat.flowProposal.subtitle': 'Rivedi questa automazione prima di salvarla.', + 'chat.flowProposal.triggerLabel': 'Trigger', + 'chat.flowProposal.stepsLabel': 'Passaggi', + 'chat.flowProposal.noSteps': 'Nessun passaggio aggiuntivo.', + 'chat.flowProposal.requireApprovalHint': 'Ogni azione in uscita richiederà la tua approvazione.', + 'chat.flowProposal.save': 'Salva e attiva', + 'chat.flowProposal.saving': 'Salvataggio…', + 'chat.flowProposal.dismiss': 'Ignora', + 'chat.flowProposal.error': 'Impossibile salvare il workflow. Riprova.', 'channels.authMode.managed_dm': 'Accedi con OpenHuman', 'channels.authMode.oauth': 'OAuth Accedi', 'channels.authMode.bot_token': 'Usa il tuo token Bot', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 56c4d427a..bef2c9d2a 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -3053,6 +3053,16 @@ const messages: TranslationMap = { 'chat.approval.fallback': '에이전트가 승인이 필요한 작업을 실행하려고 합니다.', 'chat.approval.title': '승인 필요', 'chat.approval.tool': '도구:', + 'chat.flowProposal.title': '워크플로 제안', + 'chat.flowProposal.subtitle': '저장하기 전에 이 자동화를 검토하세요.', + 'chat.flowProposal.triggerLabel': '트리거', + 'chat.flowProposal.stepsLabel': '단계', + 'chat.flowProposal.noSteps': '추가 단계가 없습니다.', + 'chat.flowProposal.requireApprovalHint': '모든 외부 작업에는 승인이 필요합니다.', + 'chat.flowProposal.save': '저장 및 활성화', + 'chat.flowProposal.saving': '저장 중…', + 'chat.flowProposal.dismiss': '닫기', + 'chat.flowProposal.error': '워크플로를 저장할 수 없습니다. 다시 시도하세요.', 'channels.authMode.managed_dm': 'OpenHuman로 로그인', 'channels.authMode.oauth': 'OAuth 로그인', 'channels.authMode.bot_token': '자체 봇 토큰 사용', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 6a3d2993d..d8b8dcd54 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -3119,6 +3119,16 @@ const messages: TranslationMap = { 'chat.approval.fallback': 'Agent chce wykonać akcję wymagającą Twojej zgody.', 'chat.approval.title': 'Wymagana zgoda', 'chat.approval.tool': 'Narzędzie:', + 'chat.flowProposal.title': 'Propozycja przepływu pracy', + 'chat.flowProposal.subtitle': 'Sprawdź tę automatyzację przed zapisaniem.', + 'chat.flowProposal.triggerLabel': 'Wyzwalacz', + 'chat.flowProposal.stepsLabel': 'Kroki', + 'chat.flowProposal.noSteps': 'Brak dodatkowych kroków.', + 'chat.flowProposal.requireApprovalHint': 'Każda wychodząca akcja będzie wymagać Twojej zgody.', + 'chat.flowProposal.save': 'Zapisz i włącz', + 'chat.flowProposal.saving': 'Zapisywanie…', + 'chat.flowProposal.dismiss': 'Odrzuć', + 'chat.flowProposal.error': 'Nie udało się zapisać przepływu pracy. Spróbuj ponownie.', 'channels.authMode.managed_dm': 'Zaloguj się z OpenHuman', 'channels.authMode.oauth': 'Logowanie OAuth', 'channels.authMode.bot_token': 'Użyj własnego tokena bota', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index c573eb224..7cba653fa 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -3137,6 +3137,16 @@ const messages: TranslationMap = { 'chat.approval.fallback': 'O agente quer executar uma ação que precisa da sua aprovação.', 'chat.approval.title': 'Aprovação necessária', 'chat.approval.tool': 'Ferramenta:', + 'chat.flowProposal.title': 'Proposta de fluxo de trabalho', + 'chat.flowProposal.subtitle': 'Revise esta automação antes de salvá-la.', + 'chat.flowProposal.triggerLabel': 'Gatilho', + 'chat.flowProposal.stepsLabel': 'Etapas', + 'chat.flowProposal.noSteps': 'Nenhuma etapa adicional.', + 'chat.flowProposal.requireApprovalHint': 'Cada ação de saída exigirá sua aprovação.', + 'chat.flowProposal.save': 'Salvar e ativar', + 'chat.flowProposal.saving': 'Salvando…', + 'chat.flowProposal.dismiss': 'Dispensar', + 'chat.flowProposal.error': 'Não foi possível salvar o fluxo de trabalho. Tente novamente.', 'channels.authMode.managed_dm': 'Faça login com OpenHuman', 'channels.authMode.oauth': 'OAuth Faça login', 'channels.authMode.bot_token': 'Use seu próprio token de bot', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 0cbd614f2..7f23da2d0 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -3110,6 +3110,16 @@ const messages: TranslationMap = { 'chat.approval.fallback': 'Агент хочет выполнить действие, требующее вашего одобрения.', 'chat.approval.title': 'Требуется одобрение', 'chat.approval.tool': 'Инструмент:', + 'chat.flowProposal.title': 'Предложение рабочего процесса', + 'chat.flowProposal.subtitle': 'Проверьте эту автоматизацию перед сохранением.', + 'chat.flowProposal.triggerLabel': 'Триггер', + 'chat.flowProposal.stepsLabel': 'Шаги', + 'chat.flowProposal.noSteps': 'Дополнительных шагов нет.', + 'chat.flowProposal.requireApprovalHint': 'Каждое исходящее действие потребует вашего одобрения.', + 'chat.flowProposal.save': 'Сохранить и включить', + 'chat.flowProposal.saving': 'Сохранение…', + 'chat.flowProposal.dismiss': 'Скрыть', + 'chat.flowProposal.error': 'Не удалось сохранить рабочий процесс. Попробуйте еще раз.', 'channels.authMode.managed_dm': 'Войдите с помощью OpenHuman', 'channels.authMode.oauth': 'OAuth Вход в систему', 'channels.authMode.bot_token': 'Используйте свой собственный токен бота', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 9ae8cad14..748316b1a 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -2924,6 +2924,16 @@ const messages: TranslationMap = { 'chat.approval.fallback': '智能体想要运行一项需要你批准的操作。', 'chat.approval.title': '需要批准', 'chat.approval.tool': '工具:', + 'chat.flowProposal.title': '工作流建议', + 'chat.flowProposal.subtitle': '保存前请先查看此自动化流程。', + 'chat.flowProposal.triggerLabel': '触发器', + 'chat.flowProposal.stepsLabel': '步骤', + 'chat.flowProposal.noSteps': '没有其他步骤。', + 'chat.flowProposal.requireApprovalHint': '每个外发操作都需要你的批准。', + 'chat.flowProposal.save': '保存并启用', + 'chat.flowProposal.saving': '保存中…', + 'chat.flowProposal.dismiss': '忽略', + 'chat.flowProposal.error': '无法保存该工作流。请重试。', 'channels.authMode.managed_dm': '使用 OpenHuman 登录', 'channels.authMode.oauth': 'OAuth 登录', 'channels.authMode.bot_token': '使用你自己的 Bot Token', diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index 43d08c13c..779445197 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -15,6 +15,7 @@ import IntegrationConnectCard from '../components/chat/IntegrationConnectCard'; import QueuedFollowups from '../components/chat/QueuedFollowups'; import SuperContextToggle from '../components/chat/SuperContextToggle'; import { whenSuperContextWriteSettled } from '../components/chat/superContextWrite'; +import WorkflowProposalCard from '../components/chat/WorkflowProposalCard'; import { ConfirmationModal } from '../components/intelligence/ConfirmationModal'; import { SidebarContent } from '../components/layout/shell/SidebarSlot'; import { settingsNavState } from '../components/settings/modal/settingsOverlay'; @@ -352,6 +353,9 @@ const Conversations = ({ const pendingPlanReviewByThread = useAppSelector( state => state.chatRuntime.pendingPlanReviewByThread ); + const pendingWorkflowProposalsByThread = useAppSelector( + state => state.chatRuntime.pendingWorkflowProposalsByThread + ); const streamingAssistantByThread = useAppSelector( state => state.chatRuntime.streamingAssistantByThread ); @@ -1611,6 +1615,13 @@ const Conversations = ({ const pendingPlanReview = selectedThreadId ? (pendingPlanReviewByThread[selectedThreadId] ?? null) : null; + // A candidate automation the agent drafted via `propose_workflow` (issue B4), + // awaiting the user's Save/Dismiss decision on `WorkflowProposalCard`. Unlike + // `pendingPlanReview`, the underlying tool call already completed — this + // just controls whether the card is still showing. + const pendingWorkflowProposal = selectedThreadId + ? (pendingWorkflowProposalsByThread[selectedThreadId] ?? null) + : null; const visibleMessages = messages.filter(msg => !msg.extraMetadata?.hidden); const hasVisibleMessages = visibleMessages.length > 0; const latestVisibleMessage = visibleMessages[visibleMessages.length - 1] ?? null; @@ -2882,6 +2893,22 @@ const Conversations = ({ /> )} + {/* Agent-first Workflow authoring (issue B4): the agent drafted a + candidate automation via `propose_workflow`. The tool only + validates — it never creates the flow — so this card is the ONLY + path from proposal to saved automation via "Save & enable" + (`flows_create`), or the user can Dismiss it outright. */} + {selectedThreadId && pendingWorkflowProposal && ( + // Keyed by name so a second proposal in the same thread (before the + // first is resolved) remounts the card and resets its local + // saving/error state, matching the PlanReviewCard pattern above. + + )} + {selectedThreadId && ( ; + if (obj.type !== 'workflow_proposal') return null; + if (typeof obj.name !== 'string' || obj.graph == null) return null; + + const summary = (obj.summary ?? {}) as Record; + const rawSteps = Array.isArray(summary.steps) ? summary.steps : []; + const steps = rawSteps + .filter((s): s is Record => !!s && typeof s === 'object') + .map(s => ({ + kind: typeof s.kind === 'string' ? s.kind : 'unknown', + name: typeof s.name === 'string' ? s.name : '', + config_hint: typeof s.config_hint === 'string' ? s.config_hint : undefined, + })); + + return { + name: obj.name, + graph: obj.graph, + // The Rust tool defaults `require_approval` to `true` when the caller + // omits it, so treat anything other than an explicit `false` as `true` + // here too — keeps the client's fallback in lockstep with the server's. + requireApproval: obj.require_approval !== false, + summary: { trigger: typeof summary.trigger === 'string' ? summary.trigger : '', steps }, + }; +} + export function findPendingDelegationContext( entries: ToolTimelineEntry[], round: number @@ -629,6 +676,27 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => { } } + // Agent-first Workflow authoring (issue B4): a completed + // `propose_workflow` call carries a `workflow_proposal` JSON payload + // in `output` — surface it as a `WorkflowProposalCard` above the + // composer. The tool only validates; only the card's "Save & enable" + // action ever calls `flows_create`, so this dispatch alone can never + // create a flow. + if (event.tool_name === 'propose_workflow' && event.success) { + const proposal = parseWorkflowProposal(event.output); + if (proposal) { + rtLog('propose_workflow proposal parsed', { + thread: event.thread_id, + name: proposal.name, + }); + dispatch(setWorkflowProposalForThread({ threadId: event.thread_id, proposal })); + } else { + rtLog('propose_workflow result did not parse as a workflow_proposal', { + thread: event.thread_id, + }); + } + } + const current = store.getState().chatRuntime.inferenceStatusByThread[event.thread_id]; if (!current) return; dispatch( diff --git a/app/src/services/api/flowsApi.ts b/app/src/services/api/flowsApi.ts index 58b13fa5d..d428eb9a8 100644 --- a/app/src/services/api/flowsApi.ts +++ b/app/src/services/api/flowsApi.ts @@ -1,7 +1,10 @@ /** * Frontend client for the durable `openhuman.flows_*` run surface (issue B2 / - * B3). Wraps the subset of controllers the B3a approval card and the B3b run - * inspector need: + * B3 / B4). Wraps the subset of controllers the B3a approval card, the B3b + * run inspector, and the B4 agent-proposal card need: + * - `flows_create` — persist a new flow (B4 — only ever called from the + * user's own "Save & enable" click on `WorkflowProposalCard`; the agent's + * `propose_workflow` tool only validates and never reaches this RPC) * - `flows_resume` — resume a `pending_approval` run past its checkpoint * - `flows_list_runs` — recent runs for a flow, newest first (B3b) * - `flows_get_run` — a single run record by id (B3b) @@ -136,6 +139,35 @@ function unwrapCliEnvelope(payload: unknown): T { // RPC client. // --------------------------------------------------------------------------- +/** + * Create (and, by default, enable) a new saved flow via `openhuman.flows_create` + * (issue B4). This is the ONLY path that persists a flow — the agent's + * `propose_workflow` tool (`src/openhuman/flows/tools.rs`) only validates a + * candidate graph and returns a summary; `WorkflowProposalCard`'s "Save & + * enable" button is what calls this function, directly from the client, on + * the user's explicit action. `requireApproval` defaults server-side to + * `false` when omitted, but the B4 proposal flow always passes it explicitly + * (defaulting to `true` on the Rust tool side) so a saved agent-proposed flow + * starts with its outbound-action approval gate on. + */ +export async function createFlow( + name: string, + graph: unknown, + requireApproval?: boolean +): Promise { + log('createFlow: request name=%s requireApproval=%s', name, requireApproval ?? 'default'); + const response = await callCoreRpc({ + method: 'openhuman.flows_create', + params: + requireApproval === undefined + ? { name, graph } + : { name, graph, require_approval: requireApproval }, + }); + const flow = unwrapCliEnvelope(response); + log('createFlow: response id=%s name=%s enabled=%s', flow.id, flow.name, flow.enabled); + return flow; +} + /** * Resume a `pending_approval` flow run past its checkpoint via * `openhuman.flows_resume`. `approvals` should name the node ids from the @@ -251,6 +283,7 @@ export async function runFlow(id: string, input?: unknown): Promise; pendingApprovalByThread: Record; pendingPlanReviewByThread: Record; + /** + * Thread-scoped candidate workflow proposed by the `propose_workflow` agent + * tool (issue B4), awaiting the user's "Save & enable" / "Dismiss" decision + * on `WorkflowProposalCard`. Unlike `pendingApprovalByThread` / + * `pendingPlanReviewByThread`, this is NOT parked on a server-side gate — + * the underlying tool call already completed; this is purely a + * client-side "should the card render" flag, cleared on Save, Dismiss, or + * thread reset. + */ + pendingWorkflowProposalsByThread: Record; /** * Per-thread artifact ledger. Snapshots are upserted on * `artifact_ready` / `artifact_failed` socket events keyed on @@ -595,6 +639,7 @@ const initialState: ChatRuntimeState = { inferenceTurnLifecycleByThread: {}, pendingApprovalByThread: {}, pendingPlanReviewByThread: {}, + pendingWorkflowProposalsByThread: {}, artifactsByThread: {}, sessionTokenUsage: emptySessionTokenUsage(), usageByThread: {}, @@ -1097,6 +1142,15 @@ const chatRuntimeSlice = createSlice({ clearPendingPlanReviewForThread: (state, action: PayloadAction<{ threadId: string }>) => { delete state.pendingPlanReviewByThread[action.payload.threadId]; }, + setWorkflowProposalForThread: ( + state, + action: PayloadAction<{ threadId: string; proposal: WorkflowProposal }> + ) => { + state.pendingWorkflowProposalsByThread[action.payload.threadId] = action.payload.proposal; + }, + clearWorkflowProposalForThread: (state, action: PayloadAction<{ threadId: string }>) => { + delete state.pendingWorkflowProposalsByThread[action.payload.threadId]; + }, /** * Mark a producer-tool call as in-flight so the `ArtifactCard` can * render a spinner before any ready/failed event arrives. Caller @@ -1292,6 +1346,7 @@ const chatRuntimeSlice = createSlice({ delete state.inferenceTurnLifecycleByThread[action.payload.threadId]; delete state.pendingApprovalByThread[action.payload.threadId]; delete state.pendingPlanReviewByThread[action.payload.threadId]; + delete state.pendingWorkflowProposalsByThread[action.payload.threadId]; delete state.queueStatusByThread[action.payload.threadId]; delete state.queuedFollowupsByThread[action.payload.threadId]; delete state.pendingSendThreadIds[action.payload.threadId]; @@ -1313,6 +1368,7 @@ const chatRuntimeSlice = createSlice({ state.inferenceTurnLifecycleByThread = {}; state.pendingApprovalByThread = {}; state.pendingPlanReviewByThread = {}; + state.pendingWorkflowProposalsByThread = {}; state.artifactsByThread = {}; state.queueStatusByThread = {}; state.queuedFollowupsByThread = {}; @@ -1416,6 +1472,10 @@ const chatRuntimeSlice = createSlice({ // Likewise drop any stale parked plan review — its gate future cannot // survive a rehydrate, so the card must not linger. 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]; if (snapshot.taskBoard) { state.taskBoardByThread[threadId] = snapshot.taskBoard; } @@ -1518,6 +1578,8 @@ export const { clearPendingApprovalForThread, setPendingPlanReviewForThread, clearPendingPlanReviewForThread, + setWorkflowProposalForThread, + clearWorkflowProposalForThread, upsertArtifactInProgressForThread, upsertArtifactReadyForThread, upsertArtifactFailedForThread, diff --git a/app/src/utils/toolTimelineFormatting.ts b/app/src/utils/toolTimelineFormatting.ts index 527308651..3998b5549 100644 --- a/app/src/utils/toolTimelineFormatting.ts +++ b/app/src/utils/toolTimelineFormatting.ts @@ -72,6 +72,7 @@ const TOOL_DISPLAY_NAMES: Record = { audio_generate_and_email_podcast: 'Generating & emailing podcast', composio_list_connections: 'Viewing your Connections', agent_prepare_context: 'Preparing context', + propose_workflow: 'Proposing workflow', }; /** diff --git a/src/openhuman/flows/mod.rs b/src/openhuman/flows/mod.rs index 73fee8dd5..c11253ffa 100644 --- a/src/openhuman/flows/mod.rs +++ b/src/openhuman/flows/mod.rs @@ -11,6 +11,7 @@ pub mod bus; pub mod ops; mod schemas; mod store; +pub mod tools; mod types; pub use schemas::{ diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index 645be6007..f4f577f08 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -26,7 +26,14 @@ const FLOW_RUN_TIMEOUT_SECS: u64 = 600; /// an older-schema definition to current), deserializes it, and rejects a /// structurally invalid graph via `tinyflows::validate::validate` — so a bad /// graph is caught at the door, before it's ever persisted. -fn validate_and_migrate_graph(graph_json: Value) -> Result { +/// +/// `pub(crate)` (not private) so `flows::tools::ProposeWorkflowTool` (issue +/// B4 — agent-first workflow authoring) can run a candidate graph through the +/// exact same validate/migrate path `flows_create` uses below, without +/// duplicating it. The tool only calls this — never `flows_create` itself — +/// which is what keeps the "the agent can never create a flow" invariant +/// intact: this function validates and returns, it has no persistence effect. +pub(crate) fn validate_and_migrate_graph(graph_json: Value) -> Result { let migrated = tinyflows::migrate::migrate(graph_json).map_err(|e| e.to_string())?; let graph: WorkflowGraph = serde_json::from_value(migrated).map_err(|e| e.to_string())?; tinyflows::validate::validate(&graph).map_err(|e| e.to_string())?; diff --git a/src/openhuman/flows/tools.rs b/src/openhuman/flows/tools.rs new file mode 100644 index 000000000..fd3569fc9 --- /dev/null +++ b/src/openhuman/flows/tools.rs @@ -0,0 +1,337 @@ +//! Agent-facing tool for the `flows::` domain (issue B4 — agent-first +//! Workflow authoring): [`ProposeWorkflowTool`] ("propose_workflow"). +//! +//! The user asks the assistant in chat to build an automation; the agent +//! calls this tool with a candidate `tinyflows::model::WorkflowGraph`. The +//! tool runs the graph through the exact same +//! [`crate::openhuman::flows::ops::validate_and_migrate_graph`] path +//! `flows_create` uses, and returns a `workflow_proposal` summary for the +//! chat UI's `WorkflowProposalCard` — it never persists anything itself. +//! +//! **Human-in-the-loop invariant:** this tool must NEVER call +//! [`crate::openhuman::flows::ops::flows_create`] (or any other persistence +//! path). Only the user's "Save & enable" click in `WorkflowProposalCard` +//! creates the flow, via the `openhuman.flows_create` RPC directly from the +//! client. `permission_level() == PermissionLevel::None` and +//! `external_effect() == false` reflect that this call has no side effect — +//! it is pure validation. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::{json, Value}; +use tinyflows::model::{Node, NodeKind, WorkflowGraph}; + +use crate::openhuman::config::Config; +use crate::openhuman::flows::ops::validate_and_migrate_graph; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; + +/// Max characters kept for a `config_hint` before truncation, so a long +/// prompt/expression doesn't blow up the proposal summary sent to the LLM +/// and rendered in the chat card. +const MAX_CONFIG_HINT_CHARS: usize = 80; + +pub struct ProposeWorkflowTool { + config: Arc, +} + +impl ProposeWorkflowTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for ProposeWorkflowTool { + fn name(&self) -> &str { + "propose_workflow" + } + + fn description(&self) -> &str { + "Propose a candidate automation workflow for the user to review and save. This tool \ + ONLY VALIDATES the graph and returns a summary — it NEVER creates or enables the flow; \ + the user must click \"Save & enable\" in the UI before anything is persisted or can \ + run. Build a tinyflows WorkflowGraph: nodes[] ({id, kind, name, config}) + edges[] \ + ({from_node, to_node, from_port?, to_port?}; ports default \"main\"). Exactly ONE \ + trigger node is required. The 12 node kinds: trigger (config.trigger_kind: manual | \ + schedule | webhook | app_event | form | chat_message | evaluation | system | \ + execute_by_workflow; schedule needs config.schedule = {kind:\"cron\",expr,tz?} | \ + {kind:\"at\",at} | {kind:\"every\",every_ms}; app_event needs config.toolkit + \ + config.trigger_slug), agent (config.prompt), tool_call (config.slug REQUIRED + \ + config.args), http_request (config.method/url, optional headers/body), code \ + (config.language: \"javascript\"|\"python\" + config.source), condition (config.field; \ + routes ports \"true\"/\"false\"), switch (config.expression or config.field; routes to \ + the matching case port, or \"default\"), transform (config.set: {key: \"=expr\"} \ + merged onto each item), split_out (config.path to an array field; fans out one item per \ + element), merge (fan-in passthrough, no config), output_parser (passthrough today; no \ + config required), sub_workflow (config.workflow: an embedded child WorkflowGraph). If \ + validation fails, fix the graph and call this tool again." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Human-readable name for the proposed flow." + }, + "graph": { + "type": "object", + "description": "A tinyflows WorkflowGraph: { name?, nodes: [...], edges: [...] }. See the tool description for node kinds and their config shapes.", + "properties": { + "nodes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Unique id within the graph." }, + "kind": { + "type": "string", + "enum": [ + "trigger", "agent", "tool_call", "http_request", + "code", "condition", "switch", "merge", "split_out", + "transform", "output_parser", "sub_workflow" + ] + }, + "name": { "type": "string", "description": "Human-readable node name." }, + "config": { "description": "Kind-specific configuration; see tool description." } + }, + "required": ["id", "kind", "name"] + } + }, + "edges": { + "type": "array", + "items": { + "type": "object", + "properties": { + "from_node": { "type": "string" }, + "to_node": { "type": "string" }, + "from_port": { "type": "string", "description": "Defaults to \"main\"." }, + "to_port": { "type": "string", "description": "Defaults to \"main\"." } + }, + "required": ["from_node", "to_node"] + } + } + }, + "required": ["nodes", "edges"] + }, + "require_approval": { + "type": "boolean", + "description": "Force a human-approval gate on every outbound tool/HTTP action this flow takes once saved. Defaults to true for agent-proposed flows." + } + }, + "required": ["name", "graph"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + // Pure validation with no side effect — see module doc. + PermissionLevel::None + } + + fn external_effect(&self) -> bool { + // Never persists or executes anything; only `flows_create` (invoked + // from the client by the user's own "Save & enable" click) does. + false + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let name = match args.get("name").and_then(Value::as_str).map(str::trim) { + Some(name) if !name.is_empty() => name.to_string(), + _ => return Ok(ToolResult::error("Missing 'name' parameter".to_string())), + }; + + let graph_json = match args.get("graph") { + Some(v) if !v.is_null() => v.clone(), + _ => return Ok(ToolResult::error("Missing 'graph' parameter".to_string())), + }; + + let require_approval = args + .get("require_approval") + .and_then(Value::as_bool) + .unwrap_or(true); + + tracing::debug!( + target: "flows", + %name, + require_approval, + workspace = %self.config.workspace_dir.display(), + "[flows] propose_workflow: validating candidate graph" + ); + + let graph = match validate_and_migrate_graph(graph_json) { + Ok(graph) => graph, + Err(e) => { + tracing::debug!( + target: "flows", + %name, + error = %e, + "[flows] propose_workflow: validation failed" + ); + return Ok(ToolResult::error(format!( + "Workflow graph is invalid: {e}. Fix the graph and call propose_workflow \ + again." + ))); + } + }; + + let summary = build_summary(&graph); + let graph_value = serde_json::to_value(&graph)?; + + tracing::info!( + target: "flows", + %name, + node_count = graph.nodes.len(), + require_approval, + "[flows] propose_workflow: proposal ready for user review" + ); + + Ok(ToolResult::success(serde_json::to_string_pretty(&json!({ + "type": "workflow_proposal", + "name": name, + "graph": graph_value, + "require_approval": require_approval, + "summary": summary, + }))?)) + } +} + +/// Builds the `{ trigger, steps }` summary surfaced to both the LLM (in the +/// tool result) and the chat UI's `WorkflowProposalCard`. +fn build_summary(graph: &WorkflowGraph) -> Value { + let trigger = graph + .trigger() + .map(describe_trigger) + .unwrap_or_else(|| "no trigger".to_string()); + + let steps: Vec = graph + .nodes + .iter() + .filter(|n| n.kind != NodeKind::Trigger) + .map(|n| { + let mut step = json!({ + "kind": node_kind_str(&n.kind), + "name": n.name, + }); + if let Some(hint) = config_hint(n) { + step["config_hint"] = json!(hint); + } + step + }) + .collect(); + + json!({ "trigger": trigger, "steps": steps }) +} + +/// The `snake_case` wire string for a [`NodeKind`] (its `Serialize` impl), +/// for the summary/step JSON. Falls back to `"unknown"` only if serializing +/// ever somehow fails — `NodeKind`'s derive is infallible in practice. +fn node_kind_str(kind: &NodeKind) -> String { + serde_json::to_value(kind) + .ok() + .and_then(|v| v.as_str().map(str::to_string)) + .unwrap_or_else(|| "unknown".to_string()) +} + +/// One-line human description of a trigger node, for the summary's +/// `"trigger"` field — e.g. `"schedule: 0 9 * * *"`, `"app event: +/// gmail/GMAIL_NEW_GMAIL_MESSAGE"`, `"manual"`. +fn describe_trigger(node: &Node) -> String { + let trigger_kind = node + .config + .get("trigger_kind") + .and_then(Value::as_str) + .unwrap_or("manual"); + + match trigger_kind { + "schedule" => { + let schedule = node.config.get("schedule"); + if let Some(expr) = schedule.and_then(|s| s.get("expr")).and_then(Value::as_str) { + format!("schedule: {expr}") + } else if let Some(ms) = schedule + .and_then(|s| s.get("every_ms")) + .and_then(Value::as_u64) + { + format!("schedule: every {ms}ms") + } else if let Some(at) = schedule.and_then(|s| s.get("at")).and_then(Value::as_str) { + format!("schedule: once at {at}") + } else { + "schedule (unspecified)".to_string() + } + } + "app_event" => { + let toolkit = node + .config + .get("toolkit") + .and_then(Value::as_str) + .unwrap_or("?"); + let slug = node + .config + .get("trigger_slug") + .and_then(Value::as_str) + .unwrap_or("?"); + format!("app event: {toolkit}/{slug}") + } + other => other.to_string(), + } +} + +/// Short, human-readable hint for a non-trigger node's config, for the +/// step's optional `"config_hint"` field. `None` when the kind has nothing +/// worth surfacing (e.g. `merge`, `output_parser`). +fn config_hint(node: &Node) -> Option { + let cfg = &node.config; + match &node.kind { + NodeKind::Agent => cfg.get("prompt").and_then(Value::as_str).map(truncate_hint), + NodeKind::ToolCall => cfg.get("slug").and_then(Value::as_str).map(str::to_string), + NodeKind::HttpRequest => { + let method = cfg.get("method").and_then(Value::as_str).unwrap_or("GET"); + let url = cfg.get("url").and_then(Value::as_str).unwrap_or("?"); + Some(truncate_hint(&format!("{method} {url}"))) + } + NodeKind::Code => cfg + .get("language") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| Some("javascript".to_string())), + NodeKind::Condition => cfg + .get("field") + .and_then(Value::as_str) + .map(|f| format!("field: {f}")), + NodeKind::Switch => cfg + .get("expression") + .and_then(Value::as_str) + .or_else(|| cfg.get("field").and_then(Value::as_str)) + .map(truncate_hint), + NodeKind::Transform => cfg.get("set").and_then(Value::as_object).map(|set| { + let keys: Vec<&str> = set.keys().map(String::as_str).collect(); + truncate_hint(&format!("sets: {}", keys.join(", "))) + }), + NodeKind::SplitOut => cfg + .get("path") + .and_then(Value::as_str) + .map(|p| format!("path: {p}")), + NodeKind::SubWorkflow => Some("embedded sub-workflow".to_string()), + NodeKind::Merge | NodeKind::OutputParser | NodeKind::Trigger => None, + } +} + +/// Truncates a hint string to [`MAX_CONFIG_HINT_CHARS`], appending an +/// ellipsis when it was cut — mirrors +/// `crate::openhuman::tools::traits::render_context_value`'s truncation +/// behavior for tool-call timeline details. +fn truncate_hint(s: &str) -> String { + if s.chars().count() <= MAX_CONFIG_HINT_CHARS { + return s.to_string(); + } + let truncated: String = s + .chars() + .take(MAX_CONFIG_HINT_CHARS.saturating_sub(1)) + .collect(); + format!("{truncated}…") +} + +#[cfg(test)] +#[path = "tools_tests.rs"] +mod tests; diff --git a/src/openhuman/flows/tools_tests.rs b/src/openhuman/flows/tools_tests.rs new file mode 100644 index 000000000..650f47bda --- /dev/null +++ b/src/openhuman/flows/tools_tests.rs @@ -0,0 +1,264 @@ +use super::*; +use crate::openhuman::config::Config; +use serde_json::json; +use tempfile::TempDir; + +fn test_config(tmp: &TempDir) -> Arc { + let config = Config { + workspace_dir: tmp.path().join("workspace"), + action_dir: tmp.path().join("workspace"), + config_path: tmp.path().join("config.toml"), + ..Config::default() + }; + std::fs::create_dir_all(&config.workspace_dir).unwrap(); + Arc::new(config) +} + +fn valid_graph() -> Value { + json!({ + "nodes": [ + { + "id": "t", + "kind": "trigger", + "name": "Every morning", + "config": { "trigger_kind": "schedule", "schedule": { "kind": "cron", "expr": "0 9 * * *" } } + }, + { + "id": "a", + "kind": "agent", + "name": "Summarize", + "config": { "prompt": "Summarize yesterday's messages" } + }, + { + "id": "s", + "kind": "tool_call", + "name": "Post to Slack", + "config": { "slug": "slack.post_message", "args": { "channel": "#general" } } + } + ], + "edges": [ + { "from_node": "t", "to_node": "a" }, + { "from_node": "a", "to_node": "s" } + ] + }) +} + +#[tokio::test] +async fn valid_graph_returns_workflow_proposal_success() { + let tmp = TempDir::new().unwrap(); + let tool = ProposeWorkflowTool::new(test_config(&tmp)); + + let result = tool + .execute(json!({ "name": "Daily standup summary", "graph": valid_graph() })) + .await + .unwrap(); + + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).expect("valid JSON output"); + assert_eq!(parsed["type"], "workflow_proposal"); + assert_eq!(parsed["name"], "Daily standup summary"); + assert_eq!(parsed["graph"]["nodes"].as_array().unwrap().len(), 3); +} + +#[tokio::test] +async fn no_trigger_graph_is_an_error() { + let tmp = TempDir::new().unwrap(); + let tool = ProposeWorkflowTool::new(test_config(&tmp)); + + let graph_without_trigger = json!({ + "nodes": [ { "id": "a", "kind": "output_parser", "name": "A" } ], + "edges": [] + }); + + let result = tool + .execute(json!({ "name": "bad", "graph": graph_without_trigger })) + .await + .unwrap(); + + assert!(result.is_error); + assert!( + result.output().to_lowercase().contains("trigger"), + "expected a trigger-related validation error, got: {}", + result.output() + ); +} + +#[tokio::test] +async fn missing_name_is_an_error() { + let tmp = TempDir::new().unwrap(); + let tool = ProposeWorkflowTool::new(test_config(&tmp)); + + let result = tool + .execute(json!({ "graph": valid_graph() })) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output().contains("Missing 'name'")); +} + +#[tokio::test] +async fn missing_graph_is_an_error() { + let tmp = TempDir::new().unwrap(); + let tool = ProposeWorkflowTool::new(test_config(&tmp)); + + let result = tool + .execute(json!({ "name": "no graph here" })) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output().contains("Missing 'graph'")); +} + +#[tokio::test] +async fn omitted_require_approval_defaults_true_in_result() { + let tmp = TempDir::new().unwrap(); + let tool = ProposeWorkflowTool::new(test_config(&tmp)); + + let result = tool + .execute(json!({ "name": "demo", "graph": valid_graph() })) + .await + .unwrap(); + + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["require_approval"], true); +} + +#[tokio::test] +async fn explicit_require_approval_false_is_respected() { + let tmp = TempDir::new().unwrap(); + let tool = ProposeWorkflowTool::new(test_config(&tmp)); + + let result = tool + .execute(json!({ "name": "demo", "graph": valid_graph(), "require_approval": false })) + .await + .unwrap(); + + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["require_approval"], false); +} + +#[tokio::test] +async fn summary_step_count_and_kinds_are_correct() { + let tmp = TempDir::new().unwrap(); + let tool = ProposeWorkflowTool::new(test_config(&tmp)); + + let result = tool + .execute(json!({ "name": "demo", "graph": valid_graph() })) + .await + .unwrap(); + + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + let steps = parsed["summary"]["steps"].as_array().unwrap(); + // 3 nodes total, minus the 1 trigger = 2 steps. + assert_eq!(steps.len(), 2); + assert_eq!(steps[0]["kind"], "agent"); + assert_eq!(steps[0]["name"], "Summarize"); + assert_eq!(steps[0]["config_hint"], "Summarize yesterday's messages"); + assert_eq!(steps[1]["kind"], "tool_call"); + assert_eq!(steps[1]["name"], "Post to Slack"); + assert_eq!(steps[1]["config_hint"], "slack.post_message"); +} + +#[tokio::test] +async fn summary_trigger_describes_schedule() { + let tmp = TempDir::new().unwrap(); + let tool = ProposeWorkflowTool::new(test_config(&tmp)); + + let result = tool + .execute(json!({ "name": "demo", "graph": valid_graph() })) + .await + .unwrap(); + + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["summary"]["trigger"], "schedule: 0 9 * * *"); +} + +#[tokio::test] +async fn summary_trigger_describes_manual_default() { + let tmp = TempDir::new().unwrap(); + let tool = ProposeWorkflowTool::new(test_config(&tmp)); + + let graph = json!({ + "nodes": [ { "id": "t", "kind": "trigger", "name": "Manual start" } ], + "edges": [] + }); + + let result = tool + .execute(json!({ "name": "demo", "graph": graph })) + .await + .unwrap(); + + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["summary"]["trigger"], "manual"); + assert!(parsed["summary"]["steps"].as_array().unwrap().is_empty()); +} + +#[tokio::test] +async fn summary_trigger_describes_app_event() { + let tmp = TempDir::new().unwrap(); + let tool = ProposeWorkflowTool::new(test_config(&tmp)); + + let graph = json!({ + "nodes": [ + { + "id": "t", + "kind": "trigger", + "name": "On new email", + "config": { + "trigger_kind": "app_event", + "toolkit": "gmail", + "trigger_slug": "GMAIL_NEW_GMAIL_MESSAGE" + } + } + ], + "edges": [] + }); + + let result = tool + .execute(json!({ "name": "demo", "graph": graph })) + .await + .unwrap(); + + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!( + parsed["summary"]["trigger"], + "app event: gmail/GMAIL_NEW_GMAIL_MESSAGE" + ); +} + +#[test] +fn propose_workflow_never_creates_a_flow() { + // The tool must have no way to persist a flow — the human-in-the-loop + // invariant (issue B4) rests entirely on `external_effect() == false` and + // `permission_level() == None` (no gate would even fire if this ever + // regressed to true, but a saved flow must still only ever be created by + // the user's own `flows_create` click). + let tmp = TempDir::new().unwrap(); + let tool = ProposeWorkflowTool::new(test_config(&tmp)); + assert_eq!(tool.permission_level(), PermissionLevel::None); + assert!(!tool.external_effect()); +} + +#[test] +fn tool_name_and_schema_are_stable() { + let tmp = TempDir::new().unwrap(); + let tool = ProposeWorkflowTool::new(test_config(&tmp)); + assert_eq!(tool.name(), "propose_workflow"); + + let schema = tool.parameters_schema(); + let required = schema["required"].as_array().unwrap(); + assert!(required.iter().any(|v| v.as_str() == Some("name"))); + assert!(required.iter().any(|v| v.as_str() == Some("graph"))); +} + +#[test] +fn display_label_humanizes_the_tool_name() { + let tmp = TempDir::new().unwrap(); + let tool = ProposeWorkflowTool::new(test_config(&tmp)); + assert_eq!( + tool.display_label(&Value::Null).as_deref(), + Some("Propose Workflow") + ); +} diff --git a/src/openhuman/tools/mod.rs b/src/openhuman/tools/mod.rs index bd023ddd6..8d8625388 100644 --- a/src/openhuman/tools/mod.rs +++ b/src/openhuman/tools/mod.rs @@ -25,6 +25,7 @@ pub use crate::openhuman::credentials::tools::*; pub use crate::openhuman::cron::tools::*; pub use crate::openhuman::dashboard::tools::*; pub use crate::openhuman::doctor::tools::*; +pub use crate::openhuman::flows::tools::*; pub use crate::openhuman::health::tools::*; pub use crate::openhuman::integrations::tools::*; pub use crate::openhuman::learning::tools::*; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index a67cfb8e9..e0ae2c634 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -257,6 +257,11 @@ pub fn all_tools_with_runtime( Box::new(CronUpdateTool::new(config.clone(), security.clone())), Box::new(CronRunTool::new(config.clone())), Box::new(CronRunsTool::new(config.clone())), + // Agent-first Workflow authoring (issue B4): validates a candidate + // graph and returns a proposal summary — never creates/enables a + // flow itself. Only the chat UI's WorkflowProposalCard "Save & + // enable" action calls `flows_create`. + Box::new(ProposeWorkflowTool::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. Box::new(WalletStatusTool::new()),