Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions app/src/components/chat/WorkflowProposalCard.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
143 changes: 143 additions & 0 deletions app/src/components/chat/WorkflowProposalCard.tsx
Original file line number Diff line number Diff line change
@@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Force approval on agent-proposed flows

When the agent includes require_approval: false, the parser stores requireApproval: false and this save path forwards it unchanged to flows_create. The approval gate treats workflow runs with require_approval: false as trusted and allows predeclared external actions without prompting, while the card only shows a message when approval is true. A prompt-injected proposal can therefore become an enabled flow whose future outbound tool/HTTP steps skip HITL after a generic Save click; force agent-authored proposals to true or make the no-approval state explicit and separately confirmed.

Useful? React with 👍 / 👎.

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;
10 changes: 10 additions & 0 deletions app/src/lib/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': 'استخدم رمز الروبوت الخاص بك',
Expand Down
11 changes: 11 additions & 0 deletions app/src/lib/i18n/bn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': 'আপনার নিজের বট টোকেন ব্যবহার করুন',
Expand Down
11 changes: 11 additions & 0 deletions app/src/lib/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
10 changes: 10 additions & 0 deletions app/src/lib/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
10 changes: 10 additions & 0 deletions app/src/lib/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
10 changes: 10 additions & 0 deletions app/src/lib/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
10 changes: 10 additions & 0 deletions app/src/lib/i18n/hi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': 'अपने स्वयं के बॉट टोकन का उपयोग करें',
Expand Down
11 changes: 11 additions & 0 deletions app/src/lib/i18n/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading