[eric] workflows: Test Agent endpoint + button + SaveBeforeTest modal, gear → facet editor, hide Convert on runner sessions, save-modal diff body

This commit is contained in:
ciregenz
2026-05-22 02:01:00 -07:00
parent 91aabbde00
commit 19a5cd8131
4 changed files with 280 additions and 28 deletions
+56
View File
@@ -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.
+14 -1
View File
@@ -296,6 +296,19 @@ const AgentCard: React.FC<Props> = ({
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<Props> = ({
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 && (
<Tooltip title="Turn this chat into a reusable, schedulable workflow">
<Box
role="button"
@@ -15,6 +15,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Dialog from '@mui/material/Dialog';
import Tooltip from '@mui/material/Tooltip';
import TextareaAutosize from '@mui/material/TextareaAutosize';
import DeleteOutlineRounded from '@mui/icons-material/DeleteOutlineRounded';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
@@ -22,9 +23,13 @@ import PlayArrowRounded from '@mui/icons-material/PlayArrowRounded';
import BuildRounded from '@mui/icons-material/BuildRounded';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { clearFixSeed, updateWorkflow, updateWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice';
import { clearFixSeed, setCardSidecar, updateWorkflow, updateWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice';
import { DEFAULT_CARD_W, DEFAULT_CARD_H, placeCard } from '@/shared/state/dashboardLayoutSlice';
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
import { fetchSession } from '@/shared/state/agentsSlice';
import StepList from './StepList';
import { API_BASE, getAuthToken } from '@/shared/config';
import ScienceOutlined from '@mui/icons-material/ScienceOutlined';
interface Props {
workflow: Workflow;
@@ -42,7 +47,10 @@ export default function EditAgentView({ workflow, steps, isFixMode = false }: Pr
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const card = useAppSelector((s) => 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<Workflow['steps']>(steps);
const [turns, setTurns] = useState<Turn[]>(() => 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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
{/* Inline header replacement. The card's default action bar is
@@ -312,6 +378,24 @@ export default function EditAgentView({ workflow, steps, isFixMode = false }: Pr
<Pill label="Claude Opus 4.6" />
<Pill label="High" />
<Box sx={{ flex: 1 }} />
<Tooltip title="Spawn a Test Agent that runs the current draft. Sits next to this card with an arrow chip while it works.">
<Box
onClick={onTestClick}
role="button"
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.3,
fontSize: '0.78rem', fontWeight: 700,
color: c.accent.primary, bgcolor: 'transparent',
px: 1, py: 0.4, borderRadius: 999,
border: `1px solid ${c.accent.primary}55`,
cursor: busy ? 'not-allowed' : 'pointer',
opacity: busy ? 0.5 : 1,
'&:hover': { bgcolor: c.accent.primary + '14' },
}}>
<ScienceOutlined sx={{ fontSize: 14 }} />
Test
</Box>
</Tooltip>
<Box
onClick={onSubmit}
role="button"
@@ -330,25 +414,102 @@ export default function EditAgentView({ workflow, steps, isFixMode = false }: Pr
</Box>
</Box>
<Dialog open={showSaveModal} onClose={() => setShowSaveModal(false)}>
<Box sx={{ p: 2.5, minWidth: 380, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Dialog open={showSaveBeforeTest} onClose={() => setShowSaveBeforeTest(false)} maxWidth="xs" fullWidth>
<Box sx={{ p: 2.5, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Typography sx={{ fontSize: '1rem', fontWeight: 700, color: c.text.primary }}>
Save before testing?
</Typography>
<Typography sx={{ fontSize: '0.9rem', color: c.text.secondary, lineHeight: 1.5 }}>
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.
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 0.5, flexWrap: 'wrap' }}>
<Box onClick={() => 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
</Box>
<Box
onClick={() => { 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
</Box>
<Box
onClick={async () => { 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
</Box>
</Box>
</Box>
</Dialog>
<Dialog open={showSaveModal} onClose={() => setShowSaveModal(false)} maxWidth="sm" fullWidth>
<Box sx={{ p: 2.5, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Typography sx={{ fontSize: '1rem', fontWeight: 700, color: c.text.primary }}>
Save changes to workflow?
</Typography>
<Typography sx={{ fontSize: '0.9rem', color: c.text.secondary, lineHeight: 1.5 }}>
You&apos;re replacing the saved steps with your edits. The next scheduled run will use the new version.
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4, mt: 0.5 }}>
{/* 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. */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mt: 0.5, maxHeight: 360, overflowY: 'auto', pr: 0.5 }}>
{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 (
<Box key={s.id} sx={{ display: 'flex', alignItems: 'center', gap: 0.6 }}>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: c.text.muted, opacity: 0.4, flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.82rem', color: c.text.muted, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
Step {i + 1}: {label}
</Typography>
</Box>
);
}
return (
<Typography key={s.id} sx={{
fontSize: '0.82rem',
color: changed ? c.accent.primary : c.text.muted,
fontWeight: changed ? 600 : 500,
<Box key={s.id} sx={{
display: 'flex', flexDirection: 'column', gap: 0.45,
p: 1, borderRadius: `${c.radius.md}px`,
border: `1px solid ${c.accent.primary}30`,
bgcolor: c.accent.primary + '08',
}}>
{changed ? '●' : '○'} Step {i + 1}: {s.label || (s.text || '').slice(0, 60)}
</Typography>
<Typography sx={{ fontSize: '0.78rem', fontWeight: 700, color: c.accent.primary }}>
Step {i + 1}: {label}
</Typography>
{beforeText && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.2 }}>
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: c.status.error, letterSpacing: '0.04em' }}>BEFORE</Typography>
<Typography sx={{
fontSize: '0.82rem', color: c.text.secondary,
textDecoration: 'line-through', lineHeight: 1.45,
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
}}>
{beforeText}
</Typography>
</Box>
)}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.2 }}>
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: c.status.success, letterSpacing: '0.04em' }}>AFTER</Typography>
<Typography sx={{
fontSize: '0.82rem', color: c.text.primary,
lineHeight: 1.45, whiteSpace: 'pre-wrap', wordBreak: 'break-word',
}}>
{afterText}
</Typography>
</Box>
</Box>
);
})}
</Box>
@@ -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
<CalendarTodayRounded sx={{ fontSize: 15, color: c.text.muted, flexShrink: 0 }} />
<Box component="span" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{scheduleLine}</Box>
</Box>
<Box
onClick={openEditAgent}
role="button"
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.45,
fontSize: '0.82rem', fontWeight: 600,
px: 1.25, py: 0.5,
borderRadius: 999,
cursor: 'pointer',
color: c.text.secondary,
bgcolor: 'transparent',
border: `1px solid ${c.border.medium}`,
'&:hover': { bgcolor: c.bg.elevated, borderColor: c.border.strong || c.border.medium, color: c.text.primary },
}}>
<EditOutlined sx={{ fontSize: 15 }} />
Edit
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
<Tooltip title="Permissions, actions, cost cap">
<Box
onClick={openFacetEditor}
role="button"
sx={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 28, height: 28, borderRadius: 999,
color: c.text.secondary, cursor: 'pointer',
'&:hover': { color: c.text.primary, bgcolor: c.bg.elevated },
}}>
<TuneRounded sx={{ fontSize: 16 }} />
</Box>
</Tooltip>
<Box
onClick={openEditAgent}
role="button"
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.45,
fontSize: '0.82rem', fontWeight: 600,
px: 1.25, py: 0.5,
borderRadius: 999,
cursor: 'pointer',
color: c.text.secondary,
bgcolor: 'transparent',
border: `1px solid ${c.border.medium}`,
'&:hover': { bgcolor: c.bg.elevated, borderColor: c.border.strong || c.border.medium, color: c.text.primary },
}}>
<EditOutlined sx={{ fontSize: 15 }} />
Edit
</Box>
</Box>
</Box>
</Box>