-
Notifications
You must be signed in to change notification settings - Fork 3.3k
feat(flows): Workflows B4 — agent proposes a workflow #4472
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
graycyrus
merged 1 commit into
tinyhumansai:main
from
graycyrus:feat/flows-b4-agent-proposal
Jul 3, 2026
+1,211
−3
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| 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; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the agent includes
require_approval: false, the parser storesrequireApproval: falseand this save path forwards it unchanged toflows_create. The approval gate treats workflow runs withrequire_approval: falseas 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 totrueor make the no-approval state explicit and separately confirmed.Useful? React with 👍 / 👎.