mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(flows): Workflows B4 — agent proposes a workflow (#4472)
This commit is contained in:
@@ -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> = {}): 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(<WorkflowProposalCard threadId="t1" proposal={proposal()} />);
|
||||
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(<WorkflowProposalCard threadId="t1" proposal={proposal()} />);
|
||||
expect(screen.getByTestId('workflow-proposal-card')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves via createFlow with the right args and clears optimistically', async () => {
|
||||
const p = proposal();
|
||||
render(<WorkflowProposalCard threadId="t1" proposal={p} />);
|
||||
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(<WorkflowProposalCard threadId="t1" proposal={proposal()} />);
|
||||
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(<WorkflowProposalCard threadId="t1" proposal={proposal()} />);
|
||||
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(<WorkflowProposalCard threadId="t1" proposal={proposal()} />);
|
||||
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(
|
||||
<WorkflowProposalCard
|
||||
threadId="t1"
|
||||
proposal={proposal({ summary: { trigger: 'manual', steps: [] } })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('chat.flowProposal.noSteps')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the require-approval hint only when requireApproval is true', () => {
|
||||
const { rerender } = render(
|
||||
<WorkflowProposalCard threadId="t1" proposal={proposal({ requireApproval: true })} />
|
||||
);
|
||||
expect(screen.getByText('chat.flowProposal.requireApprovalHint')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<WorkflowProposalCard threadId="t1" proposal={proposal({ requireApproval: false })} />
|
||||
);
|
||||
expect(screen.queryByText('chat.flowProposal.requireApprovalHint')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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<Props> = ({ threadId, proposal }) => {
|
||||
const { t } = useT();
|
||||
const dispatch = useAppDispatch();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(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 (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t('chat.flowProposal.title')}
|
||||
data-testid="workflow-proposal-card"
|
||||
className="mb-2 rounded-xl border border-ocean-300 bg-surface p-3 text-sm shadow-md dark:border-ocean-700">
|
||||
<div className="flex items-start gap-2">
|
||||
<span aria-hidden className="text-base leading-none text-ocean-700 dark:text-ocean-200">
|
||||
⚙️
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-ocean-900 dark:text-ocean-100">
|
||||
{proposal.name || t('chat.flowProposal.title')}
|
||||
</p>
|
||||
<p className="mt-1 break-words text-ocean-800/90 dark:text-ocean-200/90">
|
||||
{t('chat.flowProposal.subtitle')}
|
||||
</p>
|
||||
|
||||
<p className="mt-2 text-xs break-words text-content-secondary">
|
||||
<span className="font-medium text-content-muted">
|
||||
{t('chat.flowProposal.triggerLabel')}:
|
||||
</span>{' '}
|
||||
{proposal.summary.trigger}
|
||||
</p>
|
||||
|
||||
<div className="mt-2">
|
||||
<p className="text-xs font-medium text-content-muted">
|
||||
{t('chat.flowProposal.stepsLabel')}
|
||||
</p>
|
||||
{proposal.summary.steps.length > 0 ? (
|
||||
<ol className="mt-1 max-h-56 list-decimal overflow-y-auto pl-6 text-content-secondary">
|
||||
{proposal.summary.steps.map((step, i) => (
|
||||
<li key={i} className="break-words">
|
||||
<span
|
||||
data-testid="workflow-proposal-step-kind"
|
||||
className="mr-1.5 inline-block rounded-full bg-ocean-100 px-1.5 py-0.5 text-[10px] font-medium text-ocean-700 dark:bg-ocean-500/15 dark:text-ocean-300">
|
||||
{step.kind}
|
||||
</span>
|
||||
<span>{step.name}</span>
|
||||
{step.config_hint ? (
|
||||
<span
|
||||
title={step.config_hint}
|
||||
className="ml-1.5 inline-block max-w-full truncate rounded bg-surface-subtle px-1 py-px align-middle font-mono text-[11px] text-content-muted">
|
||||
{step.config_hint}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-content-faint">{t('chat.flowProposal.noSteps')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{proposal.requireApproval && (
|
||||
<p className="mt-2 text-xs text-content-faint">
|
||||
{t('chat.flowProposal.requireApprovalHint')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{errorMsg && <p className="mt-2 text-xs text-coral">⚠ {errorMsg}</p>}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
data-analytics-id="workflow-proposal-save"
|
||||
onClick={() => void save()}
|
||||
disabled={saving}>
|
||||
{saving ? t('chat.flowProposal.saving') : t('chat.flowProposal.save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
data-analytics-id="workflow-proposal-dismiss"
|
||||
onClick={dismiss}
|
||||
disabled={saving}>
|
||||
{t('chat.flowProposal.dismiss')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowProposalCard;
|
||||
@@ -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': 'استخدم رمز الروبوت الخاص بك',
|
||||
|
||||
@@ -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': 'আপনার নিজের বট টোকেন ব্যবহার করুন',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'अपने स्वयं के बॉट टोकन का उपयोग करें',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '자체 봇 토큰 사용',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Используйте свой собственный токен бота',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
<WorkflowProposalCard
|
||||
key={pendingWorkflowProposal.name}
|
||||
threadId={selectedThreadId}
|
||||
proposal={pendingWorkflowProposal}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedThreadId && (
|
||||
<ThreadTodoStrip
|
||||
board={selectedTaskBoard}
|
||||
|
||||
@@ -51,12 +51,14 @@ import {
|
||||
setStreamingAssistantForThread,
|
||||
setTaskBoardForThread,
|
||||
setToolTimelineForThread,
|
||||
setWorkflowProposalForThread,
|
||||
type StreamingAssistantState,
|
||||
type ToolTimelineEntry,
|
||||
type ToolTimelineEntryStatus,
|
||||
upsertArtifactFailedForThread,
|
||||
upsertArtifactInProgressForThread,
|
||||
upsertArtifactReadyForThread,
|
||||
type WorkflowProposal,
|
||||
} from '../store/chatRuntimeSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import { selectSocketStatus } from '../store/socketSelectors';
|
||||
@@ -251,6 +253,51 @@ function chatTurnUsagePayload(event: ChatDoneEvent): {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a completed `propose_workflow` tool call's JSON `output` into a
|
||||
* `WorkflowProposal` for `WorkflowProposalCard` (issue B4 — agent-first
|
||||
* Workflow authoring). The tool's `execute()`
|
||||
* (`src/openhuman/flows/tools.rs`) returns
|
||||
* `{ type: "workflow_proposal", name, graph, require_approval, summary }` as
|
||||
* its `ToolResult` body; this maps that wire shape onto the store's camelCase
|
||||
* `WorkflowProposal`. Returns `null` for anything that fails to parse or
|
||||
* doesn't match the expected shape — defensive, since a malformed proposal
|
||||
* must never crash the chat runtime, it should just silently not render a
|
||||
* card.
|
||||
*/
|
||||
function parseWorkflowProposal(output: string): WorkflowProposal | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(output);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (obj.type !== 'workflow_proposal') return null;
|
||||
if (typeof obj.name !== 'string' || obj.graph == null) return null;
|
||||
|
||||
const summary = (obj.summary ?? {}) as Record<string, unknown>;
|
||||
const rawSteps = Array.isArray(summary.steps) ? summary.steps : [];
|
||||
const steps = rawSteps
|
||||
.filter((s): s is Record<string, unknown> => !!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(
|
||||
|
||||
@@ -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<T>(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<Flow> {
|
||||
log('createFlow: request name=%s requireApproval=%s', name, requireApproval ?? 'default');
|
||||
const response = await callCoreRpc<unknown>({
|
||||
method: 'openhuman.flows_create',
|
||||
params:
|
||||
requireApproval === undefined
|
||||
? { name, graph }
|
||||
: { name, graph, require_approval: requireApproval },
|
||||
});
|
||||
const flow = unwrapCliEnvelope<Flow>(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<FlowResumeRe
|
||||
}
|
||||
|
||||
export const flowsApi = {
|
||||
createFlow,
|
||||
resumeFlow,
|
||||
listFlowRuns,
|
||||
getFlowRun,
|
||||
|
||||
@@ -432,6 +432,40 @@ export interface PendingPlanReview {
|
||||
steps: string[];
|
||||
}
|
||||
|
||||
/** One step in a `WorkflowProposal`'s summary — a non-trigger node. */
|
||||
export interface WorkflowProposalStep {
|
||||
/** tinyflows node kind (e.g. `"agent"`, `"tool_call"`, `"http_request"`). */
|
||||
kind: string;
|
||||
/** Human-readable node name. */
|
||||
name: string;
|
||||
/** Optional short description of the node's config (e.g. a tool slug, prompt). */
|
||||
config_hint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A candidate automation workflow the agent proposed via the `propose_workflow`
|
||||
* tool (issue B4 — agent-first Workflow authoring). VALIDATED but never
|
||||
* created — the agent's tool can only validate and summarize a graph; the
|
||||
* user must click "Save & enable" on `WorkflowProposalCard` to actually
|
||||
* persist it via `openhuman.flows_create`. Parsed from the `propose_workflow`
|
||||
* tool call's completed-result JSON (`tool_result` socket event) in
|
||||
* `ChatRuntimeProvider`.
|
||||
*/
|
||||
export interface WorkflowProposal {
|
||||
/** Proposed flow name. */
|
||||
name: string;
|
||||
/** The validated tinyflows WorkflowGraph, ready to hand to `flows_create` as-is. */
|
||||
graph: unknown;
|
||||
/** Whether the flow should require approval on every outbound action once saved. */
|
||||
requireApproval: boolean;
|
||||
summary: {
|
||||
/** One-line description of the trigger (e.g. `"schedule: 0 9 * * *"`). */
|
||||
trigger: string;
|
||||
/** Ordered non-trigger steps. */
|
||||
steps: WorkflowProposalStep[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle status of a single agent-generated artifact, as projected
|
||||
* onto the chat runtime per thread.
|
||||
@@ -529,6 +563,16 @@ interface ChatRuntimeState {
|
||||
inferenceTurnLifecycleByThread: Record<string, InferenceTurnLifecycle>;
|
||||
pendingApprovalByThread: Record<string, PendingApproval>;
|
||||
pendingPlanReviewByThread: Record<string, PendingPlanReview>;
|
||||
/**
|
||||
* 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<string, WorkflowProposal>;
|
||||
/**
|
||||
* 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,
|
||||
|
||||
@@ -72,6 +72,7 @@ const TOOL_DISPLAY_NAMES: Record<string, string> = {
|
||||
audio_generate_and_email_podcast: 'Generating & emailing podcast',
|
||||
composio_list_connections: 'Viewing your Connections',
|
||||
agent_prepare_context: 'Preparing context',
|
||||
propose_workflow: 'Proposing workflow',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod bus;
|
||||
pub mod ops;
|
||||
mod schemas;
|
||||
mod store;
|
||||
pub mod tools;
|
||||
mod types;
|
||||
|
||||
pub use schemas::{
|
||||
|
||||
@@ -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<WorkflowGraph, String> {
|
||||
///
|
||||
/// `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<WorkflowGraph, String> {
|
||||
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())?;
|
||||
|
||||
@@ -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<Config>,
|
||||
}
|
||||
|
||||
impl ProposeWorkflowTool {
|
||||
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
|
||||
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<Value> = 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<String> {
|
||||
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;
|
||||
@@ -0,0 +1,264 @@
|
||||
use super::*;
|
||||
use crate::openhuman::config::Config;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn test_config(tmp: &TempDir) -> Arc<Config> {
|
||||
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")
|
||||
);
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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()),
|
||||
|
||||
Reference in New Issue
Block a user