diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index 1426fa1a..4a4f6de7 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -503,6 +503,62 @@ async def propose_edit(workflow_id: str, body: dict): return out +@workflows.router.post("/{workflow_id}/test-run") +async def test_run_workflow(workflow_id: str, body: dict): + """Spawn a Test Agent session running the (possibly-unsaved) draft. + + Powers Image #39: EditAgentView's Test button. Takes an optional + draft `steps` array overriding the saved workflow's steps so the + user can validate edits before persisting. The spawned session is + a normal agent session; nothing is recorded as a WorkflowRun so + History stays clean. Returns the new session id; the FE wires it + to the workflow card via setCardSidecar(kind='testing') and the + dashboard draws the labeled arrow chip between the two cards. + """ + wf = storage.get_workflow(workflow_id) + if not wf: + raise HTTPException(status_code=404, detail="Workflow not found") + draft_steps = (body or {}).get("steps") + steps_texts: list[str] + if isinstance(draft_steps, list) and draft_steps: + steps_texts = [str(s.get("text") or "") for s in draft_steps if isinstance(s, dict) and s.get("text")] + else: + steps_texts = [s.text for s in wf.steps if s.text and s.text.strip()] + if not steps_texts: + raise HTTPException(status_code=400, detail="Workflow has no steps to test") + + from backend.apps.agents.models import AgentConfig + from backend.apps.agents.agent_manager import agent_manager + from backend.apps.workflows import executor + + config = AgentConfig( + name=f"{wf.title or 'Workflow'} (test)", + model=wf.model or "sonnet", + mode=wf.mode or "agent", + provider=wf.provider or "anthropic", + system_prompt=executor._resolve_system_prompt(wf), + allowed_tools=executor._resolve_allowed_tools(wf) or [ + "Read", "Edit", "Write", "Bash", "Glob", "Grep", "AskUserQuestion", + ], + dashboard_id=wf.dashboard_id, + ) + session = await agent_manager.launch_agent(config) + + async def _drive_test() -> None: + try: + for step in steps_texts: + await agent_manager.send_message(session.id, step) + await executor._await_session_idle(session.id) + sess_state = agent_manager.sessions.get(session.id) + if sess_state is not None and getattr(sess_state, "status", None) == "error": + return + except Exception: + logger.exception("test-run drive loop failed") + asyncio.create_task(_drive_test()) + + return {"session_id": session.id} + + @workflows.router.post("/{workflow_id}/parse-schedule") async def parse_schedule(workflow_id: str, body: dict): """Aux-LLM-parse natural language into a ScheduleConfig. diff --git a/frontend/src/app/pages/Dashboard/AgentCard.tsx b/frontend/src/app/pages/Dashboard/AgentCard.tsx index 2832034a..5eb77001 100644 --- a/frontend/src/app/pages/Dashboard/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/AgentCard.tsx @@ -296,6 +296,19 @@ const AgentCard: React.FC = ({ const hasApiKey = !!useAppSelector((s) => s.settings.data.anthropic_api_key); const modelsByProvider = useAppSelector((s) => s.models.byProvider); const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds); + // If this session is a workflow's runner (executor.execute spawned it), + // hide the "Make workflow" button per user note on Image #44; the chat + // is already inside a workflow loop, converting it back into a fresh + // workflow would be a confusing identity collapse. + const workflowRunsMap = useAppSelector((s) => s.workflows.runs); + const isWorkflowRunnerSession = useMemo(() => { + for (const arr of Object.values(workflowRunsMap || {})) { + for (const r of arr || []) { + if (r.session_id === session.id) return true; + } + } + return false; + }, [workflowRunsMap, session.id]); // Curated picker label with a tidy fallback for unknowns. const friendlyModelLabel = useMemo(() => { const value = session.model; @@ -924,7 +937,7 @@ const AgentCard: React.FC = ({ onPointerDown={(e) => e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0, ml: 0.5 }} > - {(session.status === 'completed' || session.status === 'stopped') && session.messages.length >= 2 && ( + {(session.status === 'completed' || session.status === 'stopped') && session.messages.length >= 2 && !isWorkflowRunnerSession && ( s.workflows.openCards[workflow.id]); + const wfCardPos = useAppSelector((s) => s.dashboardLayout.workflowCards[workflow.id]); + const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds); const fixSeed = card?.fixSeed || null; + const [showSaveBeforeTest, setShowSaveBeforeTest] = useState(false); const [draftSteps, setDraftSteps] = useState(steps); const [turns, setTurns] = useState(() => isFixMode && fixSeed ? [ @@ -153,6 +161,64 @@ export default function EditAgentView({ workflow, steps, isFixMode = false }: Pr } }, [dispatch, workflow.id, workflow.updated_at, draftSteps]); + // Test button: spawn a Test Agent session running the (possibly-unsaved) + // draft via /workflows/{id}/test-run. The sibling lands to the right of + // the workflow card with an arrow chip labeled "Testing" between them + // (Image #39). Session footer auto-flips to Force Stop Agent because + // the agent is running. + const onTest = useCallback(async () => { + if (busy) return; + setBusy(true); + try { + const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); + const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflow.id)}/test-run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) }, + body: JSON.stringify({ steps: draftSteps.map((s) => ({ id: s.id, text: s.text, label: s.label || null })) }), + }); + if (!res.ok) { + setTurns((t) => [...t, { kind: 'assistant', text: `Couldn't spawn the Test Agent. (${res.status})` }]); + return; + } + const data = await res.json(); + const sessionId = data?.session_id as string | undefined; + if (!sessionId) return; + try { + const { store } = await import('@/shared/state/store'); + if (!store.getState().agents.sessions[sessionId]) { + try { await dispatch(fetchSession(sessionId)).unwrap(); } catch { /* not fatal */ } + } + if (!store.getState().dashboardLayout.cards[sessionId] && wfCardPos) { + dispatch(placeCard({ + sessionId, + x: wfCardPos.x + wfCardPos.width + 60, + y: wfCardPos.y, + width: DEFAULT_CARD_W, + height: DEFAULT_CARD_H, + expandedSessionIds, + })); + } + dispatch(setPendingFocusAgentId(sessionId)); + } catch { /* best-effort */ } + dispatch(setCardSidecar({ workflowId: workflow.id, sessionId, kind: 'testing' })); + } catch (e) { + setTurns((t) => [...t, { kind: 'assistant', text: (e as Error)?.message || 'Network error.' }]); + } finally { + setBusy(false); + } + }, [busy, workflow.id, draftSteps, dispatch, wfCardPos, expandedSessionIds]); + + const onTestClick = useCallback(() => { + // If user has unsaved edits, ask first so they know the Test Agent + // runs the DRAFT, not the persisted workflow. The little modal the + // user asked for in the Image #38 thread. + if (dirty) { + setShowSaveBeforeTest(true); + } else { + void onTest(); + } + }, [dirty, onTest]); + return ( {/* Inline header replacement. The card's default action bar is @@ -312,6 +378,24 @@ export default function EditAgentView({ workflow, steps, isFixMode = false }: Pr + + + + Test + + - setShowSaveModal(false)}> - + setShowSaveBeforeTest(false)} maxWidth="xs" fullWidth> + + + Save before testing? + + + The Test Agent will run your DRAFT steps, not the saved version. You can save now so the changes stick if the test goes well, or test without saving and decide after. + + + setShowSaveBeforeTest(false)} role="button" sx={{ fontSize: '0.86rem', color: c.text.secondary, px: 1, py: 0.6, cursor: 'pointer', '&:hover': { color: c.text.primary } }}> + Cancel + + { setShowSaveBeforeTest(false); void onTest(); }} + role="button" + sx={{ + fontSize: '0.86rem', fontWeight: 700, color: c.accent.primary, bgcolor: 'transparent', + px: 1.2, py: 0.55, borderRadius: 999, cursor: 'pointer', + border: `1px solid ${c.accent.primary}55`, + '&:hover': { bgcolor: c.accent.primary + '14' }, + }}> + Test draft only + + { setShowSaveBeforeTest(false); await onConfirmSave(); void onTest(); }} + role="button" + sx={{ + fontSize: '0.86rem', fontWeight: 700, color: '#fff', bgcolor: c.status.success, + px: 1.4, py: 0.55, borderRadius: 999, cursor: 'pointer', + '&:hover': { filter: 'brightness(1.05)' }, + }}> + Save & test + + + + + + setShowSaveModal(false)} maxWidth="sm" fullWidth> + Save changes to workflow? You're replacing the saved steps with your edits. The next scheduled run will use the new version. - + {/* Diff body: each changed step shows BEFORE struck through and + AFTER in green. Unchanged steps render as a quiet line so the + user can see the full step list in context, not just the deltas. */} + {draftSteps.map((s, i) => { - const changed = (s.text || '') !== (steps[i]?.text || ''); + const beforeText = steps[i]?.text || ''; + const afterText = s.text || ''; + const changed = afterText !== beforeText; + const label = s.label || steps[i]?.label || afterText.slice(0, 60); + if (!changed) { + return ( + + + + Step {i + 1}: {label} + + + ); + } return ( - - {changed ? '●' : '○'} Step {i + 1}: {s.label || (s.text || '').slice(0, 60)} - + + Step {i + 1}: {label} + + {beforeText && ( + + BEFORE + + {beforeText} + + + )} + + AFTER + + {afterText} + + + ); })} diff --git a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx index 9f4d6445..7b02ead6 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx @@ -7,6 +7,7 @@ import InputBase from '@mui/material/InputBase'; import HistoryIcon from '@mui/icons-material/HistoryToggleOffRounded'; import CalendarTodayRounded from '@mui/icons-material/CalendarTodayRounded'; import EditOutlined from '@mui/icons-material/EditOutlined'; +import TuneRounded from '@mui/icons-material/TuneRounded'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { @@ -261,6 +262,12 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo const openScheduling = useCallback(() => { dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'scheduling' } })); }, [dispatch, workflow.id]); + const openFacetEditor = useCallback(() => { + // Legacy General/Actions/Schedule facet picker. The new chat-based + // EditAgentView replaces it for step iteration; this is the escape + // hatch for permissions, cost cap, action allowlists, etc. + dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'edit', editFacet: 'Actions' } })); + }, [dispatch, workflow.id]); const onToggleStep = useCallback((stepId: string) => { dispatch(toggleExpandedStep({ workflowId: workflow.id, stepId })); }, [dispatch, workflow.id]); @@ -291,22 +298,37 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo {scheduleLine} - - - Edit + + + + + + + + + Edit +