diff --git a/core/frontend/src/lib/run-inputs.test.ts b/core/frontend/src/lib/run-inputs.test.ts new file mode 100644 index 0000000000..4dbd8cd3cd --- /dev/null +++ b/core/frontend/src/lib/run-inputs.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; + +import type { NodeSpec } from "@/api/types"; +import type { GraphNode } from "@/components/graph-types"; + +import { + buildStructuredRunQuestions, + canShowRunButton, + getStructuredRunInputKeys, + hasAllStructuredRunInputs, +} from "./run-inputs"; + +function makeNodeSpec(overrides: Partial): NodeSpec { + return { + id: "node-1", + name: "Node 1", + description: "", + node_type: "event_loop", + input_keys: [], + output_keys: [], + nullable_output_keys: [], + tools: [], + routes: {}, + max_retries: 0, + max_node_visits: 0, + client_facing: false, + success_criteria: null, + system_prompt: "", + sub_agents: [], + ...overrides, + }; +} + +function makeGraphNode(overrides: Partial): GraphNode { + return { + id: "node-1", + label: "Node 1", + status: "pending", + ...overrides, + }; +} + +describe("getStructuredRunInputKeys", () => { + it("returns structured input keys from the first non-trigger graph node", () => { + const nodeSpecs = [ + makeNodeSpec({ + id: "receive-runtime-inputs", + input_keys: ["target_dir", "review_dir", "word_threshold"], + }), + ]; + const graphNodes = [ + makeGraphNode({ id: "__trigger_default", nodeType: "trigger" }), + makeGraphNode({ id: "receive-runtime-inputs", nodeType: "execution" }), + ]; + + expect(getStructuredRunInputKeys(nodeSpecs, graphNodes)).toEqual([ + "target_dir", + "review_dir", + "word_threshold", + ]); + }); + + it("filters out generic task-style entry keys", () => { + const nodeSpecs = [ + makeNodeSpec({ + id: "entry", + input_keys: ["user_request", "task", "feedback", "target_dir"], + }), + ]; + + expect(getStructuredRunInputKeys(nodeSpecs, [])).toEqual(["target_dir"]); + }); +}); + +describe("hasAllStructuredRunInputs", () => { + it("requires every structured key to be present and non-blank", () => { + expect( + hasAllStructuredRunInputs(["target_dir", "word_threshold"], { + target_dir: "/tmp/project", + word_threshold: "800", + }), + ).toBe(true); + + expect( + hasAllStructuredRunInputs(["target_dir", "word_threshold"], { + target_dir: " ", + word_threshold: "800", + }), + ).toBe(false); + + expect( + hasAllStructuredRunInputs(["target_dir", "word_threshold"], { + target_dir: "/tmp/project", + }), + ).toBe(false); + }); +}); + +describe("buildStructuredRunQuestions", () => { + it("creates free-text prompts for each required run input", () => { + expect(buildStructuredRunQuestions(["target_dir", "review_dir"])).toEqual([ + { id: "target_dir", prompt: "Provide target_dir for this run." }, + { id: "review_dir", prompt: "Provide review_dir for this run." }, + ]); + }); +}); + +describe("canShowRunButton", () => { + it("only exposes Run when a worker session is ready and staged/running", () => { + expect(canShowRunButton("sess-1", true, "staging")).toBe(true); + expect(canShowRunButton("sess-1", true, "running")).toBe(true); + + expect(canShowRunButton("sess-1", true, "planning")).toBe(false); + expect(canShowRunButton("sess-1", true, "building")).toBe(false); + expect(canShowRunButton("sess-1", false, "staging")).toBe(false); + expect(canShowRunButton(null, true, "staging")).toBe(false); + }); +}); diff --git a/core/frontend/src/lib/run-inputs.ts b/core/frontend/src/lib/run-inputs.ts new file mode 100644 index 0000000000..0fb220afcd --- /dev/null +++ b/core/frontend/src/lib/run-inputs.ts @@ -0,0 +1,47 @@ +import type { NodeSpec } from "@/api/types"; +import type { GraphNode } from "@/components/graph-types"; + +const GENERIC_ENTRY_KEYS = new Set(["task", "user_request", "feedback"]); +const RUNNABLE_PHASES = new Set(["staging", "running"]); + +type QueenPhase = "planning" | "building" | "staging" | "running"; + +function isMeaningfulValue(value: unknown): boolean { + if (typeof value === "string") return value.trim().length > 0; + return value !== undefined && value !== null; +} + +export function getStructuredRunInputKeys( + nodeSpecs: NodeSpec[], + graphNodes: GraphNode[], +): string[] { + const entryNodeId = + graphNodes.find((node) => node.nodeType !== "trigger")?.id ?? nodeSpecs[0]?.id; + if (!entryNodeId) return []; + + const entrySpec = nodeSpecs.find((node) => node.id === entryNodeId) ?? nodeSpecs[0]; + return (entrySpec?.input_keys ?? []).filter((key) => !GENERIC_ENTRY_KEYS.has(key)); +} + +export function hasAllStructuredRunInputs( + keys: string[], + inputData: Record | null | undefined, +): inputData is Record { + if (!inputData) return false; + return keys.every((key) => isMeaningfulValue(inputData[key])); +} + +export function buildStructuredRunQuestions(keys: string[]) { + return keys.map((key) => ({ + id: key, + prompt: `Provide ${key} for this run.`, + })); +} + +export function canShowRunButton( + sessionId: string | null | undefined, + ready: boolean | null | undefined, + queenPhase: QueenPhase | null | undefined, +): boolean { + return Boolean(sessionId && ready && queenPhase && RUNNABLE_PHASES.has(queenPhase)); +} diff --git a/core/frontend/src/pages/workspace.tsx b/core/frontend/src/pages/workspace.tsx index 25d396228c..b9676ff60b 100644 --- a/core/frontend/src/pages/workspace.tsx +++ b/core/frontend/src/pages/workspace.tsx @@ -18,6 +18,12 @@ import type { LiveSession, AgentEvent, DiscoverEntry, NodeSpec, DraftGraph as Dr import { sseEventToChatMessage, formatAgentDisplayName } from "@/lib/chat-helpers"; import { topologyToGraphNodes } from "@/lib/graph-converter"; import { cronToLabel } from "@/lib/graphUtils"; +import { + buildStructuredRunQuestions, + canShowRunButton, + getStructuredRunInputKeys, + hasAllStructuredRunInputs, +} from "@/lib/run-inputs"; import { ApiError } from "@/api/client"; const makeId = () => Math.random().toString(36).slice(2, 9); @@ -351,7 +357,9 @@ interface AgentBackendState { /** Multiple questions from ask_user_multiple */ pendingQuestions: { id: string; prompt: string; options?: string[] }[] | null; /** Whether the pending question came from queen or worker */ - pendingQuestionSource: "queen" | "worker" | null; + pendingQuestionSource: "queen" | "worker" | "run" | null; + /** Last structured input payload successfully used to start the worker. */ + lastRunInputData: Record | null; /** Per-node context window usage (from context_usage_updated events) */ contextUsage: Record; /** Whether the queen's LLM supports image content — false disables the attach button */ @@ -393,6 +401,7 @@ function defaultAgentState(): AgentBackendState { pendingOptions: null, pendingQuestions: null, pendingQuestionSource: null, + lastRunInputData: null, contextUsage: {}, queenSupportsImages: true, }; @@ -693,15 +702,71 @@ export default function Workspace() { } }, [sessionsByAgent, activeSessionByAgent, activeWorker, agentStates]); + const appendSystemMessage = useCallback((agentType: string, content: string) => { + setSessionsByAgent((prev) => { + const sessions = prev[agentType] || []; + const activeId = activeSessionRef.current[agentType] || sessions[0]?.id; + return { + ...prev, + [agentType]: sessions.map((s) => { + if (s.id !== activeId) return s; + const errorMsg: ChatMessage = { + id: makeId(), + agent: "System", + agentColor: "", + content, + timestamp: "", + type: "system", + thread: agentType, + createdAt: Date.now(), + }; + return { ...s, messages: [...s.messages, errorMsg] }; + }), + }; + }); + }, []); + const handleRun = useCallback(async () => { const state = agentStates[activeWorker]; if (!state?.sessionId || !state?.ready) return; + + const sessions = sessionsRef.current[activeWorker] || []; + const activeId = activeSessionRef.current[activeWorker] || sessions[0]?.id; + const activeSession = sessions.find((s) => s.id === activeId) || sessions[0]; + const requiredRunKeys = getStructuredRunInputKeys( + state.nodeSpecs, + activeSession?.graphNodes || [], + ); + + if ( + requiredRunKeys.length > 0 && + !hasAllStructuredRunInputs(requiredRunKeys, state.lastRunInputData) + ) { + updateAgentState(activeWorker, { + awaitingInput: true, + pendingQuestion: null, + pendingOptions: null, + pendingQuestions: buildStructuredRunQuestions(requiredRunKeys), + pendingQuestionSource: "run", + workerRunState: "idle", + }); + return; + } + + const inputData = + requiredRunKeys.length > 0 && state.lastRunInputData + ? state.lastRunInputData + : {}; + // Reset dismissed banner so a repeated 424 re-shows it setDismissedBanner(null); try { updateAgentState(activeWorker, { workerRunState: "deploying" }); - const result = await executionApi.trigger(state.sessionId, "default", {}); - updateAgentState(activeWorker, { currentExecutionId: result.execution_id }); + const result = await executionApi.trigger(state.sessionId, "default", inputData); + updateAgentState(activeWorker, { + currentExecutionId: result.execution_id, + lastRunInputData: inputData, + }); } catch (err) { // 424 = credentials required — open the credentials modal if (err instanceof ApiError && err.status === 424) { @@ -714,25 +779,16 @@ export default function Workspace() { } const errMsg = err instanceof Error ? err.message : String(err); - setSessionsByAgent((prev) => { - const sessions = prev[activeWorker] || []; - const activeId = activeSessionRef.current[activeWorker] || sessions[0]?.id; - return { - ...prev, - [activeWorker]: sessions.map((s) => { - if (s.id !== activeId) return s; - const errorMsg: ChatMessage = { - id: makeId(), agent: "System", agentColor: "", - content: `Failed to trigger run: ${errMsg}`, - timestamp: "", type: "system", thread: activeWorker, createdAt: Date.now(), - }; - return { ...s, messages: [...s.messages, errorMsg] }; - }), - }; - }); + appendSystemMessage(activeWorker, `Failed to trigger run: ${errMsg}`); updateAgentState(activeWorker, { workerRunState: "idle" }); } - }, [agentStates, activeWorker, updateAgentState]); + }, [agentStates, activeWorker, appendSystemMessage, updateAgentState]); + + const canRunLoadedWorker = canShowRunButton( + activeAgentState?.sessionId, + activeAgentState?.ready, + activeAgentState?.queenPhase, + ); // --- Fetch discovered agents for NewTabPopover --- const [discoverAgents, setDiscoverAgents] = useState([]); @@ -2826,6 +2882,40 @@ export default function Workspace() { // --- handleMultiQuestionAnswer: submit answers to ask_user_multiple --- const handleMultiQuestionAnswer = useCallback((answers: Record) => { + const state = agentStates[activeWorker]; + if (state?.pendingQuestionSource === "run") { + if (!state.sessionId || !state.ready) return; + updateAgentState(activeWorker, { + pendingQuestion: null, + pendingOptions: null, + pendingQuestions: null, + pendingQuestionSource: null, + awaitingInput: false, + workerRunState: "deploying", + lastRunInputData: answers, + }); + executionApi.trigger(state.sessionId, "default", answers).then((result) => { + updateAgentState(activeWorker, { + currentExecutionId: result.execution_id, + lastRunInputData: answers, + }); + }).catch((err: unknown) => { + if (err instanceof ApiError && err.status === 424) { + const errBody = err.body as Record; + const credPath = (errBody?.agent_path as string) || null; + if (credPath) setCredentialAgentPath(credPath); + updateAgentState(activeWorker, { workerRunState: "idle", error: "credentials_required" }); + setCredentialsOpen(true); + return; + } + + const errMsg = err instanceof Error ? err.message : String(err); + appendSystemMessage(activeWorker, `Failed to trigger run: ${errMsg}`); + updateAgentState(activeWorker, { workerRunState: "idle" }); + }); + return; + } + updateAgentState(activeWorker, { pendingQuestion: null, pendingOptions: null, pendingQuestions: null, pendingQuestionSource: null, @@ -2835,7 +2925,7 @@ export default function Workspace() { ([id, answer]) => `[${id}]: ${answer}`, ); handleSend(lines.join("\n"), activeWorker); - }, [activeWorker, handleSend, updateAgentState]); + }, [activeWorker, agentStates, appendSystemMessage, handleSend, updateAgentState]); // --- handleQuestionDismiss: user closed the question widget without answering --- // Injects a dismiss signal so the blocked node can continue. @@ -2854,6 +2944,11 @@ export default function Workspace() { awaitingInput: false, }); + if (source === "run") { + updateAgentState(activeWorker, { workerRunState: "idle" }); + return; + } + // Unblock the waiting node with a dismiss signal const dismissMsg = `[User dismissed the question: "${question}"]`; if (source === "worker") { @@ -3145,7 +3240,7 @@ export default function Workspace() { : null } building={activeAgentState?.queenBuilding} - onRun={handleRun} + onRun={canRunLoadedWorker ? handleRun : undefined} onPause={handlePause} runState={activeAgentState?.workerRunState ?? "idle"} flowchartMap={activeAgentState?.flowchartMap ?? undefined}