Skip to content
Closed
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
118 changes: 118 additions & 0 deletions core/frontend/src/lib/run-inputs.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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>): 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);
});
});
47 changes: 47 additions & 0 deletions core/frontend/src/lib/run-inputs.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | null | undefined,
): inputData is Record<string, unknown> {
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));
}
139 changes: 117 additions & 22 deletions core/frontend/src/pages/workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<string, unknown> | null;
/** Per-node context window usage (from context_usage_updated events) */
contextUsage: Record<string, { usagePct: number; messageCount: number; estimatedTokens: number; maxTokens: number }>;
/** Whether the queen's LLM supports image content — false disables the attach button */
Expand Down Expand Up @@ -393,6 +401,7 @@ function defaultAgentState(): AgentBackendState {
pendingOptions: null,
pendingQuestions: null,
pendingQuestionSource: null,
lastRunInputData: null,
contextUsage: {},
queenSupportsImages: true,
};
Expand Down Expand Up @@ -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) {
Expand All @@ -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<DiscoverEntry[]>([]);
Expand Down Expand Up @@ -2826,6 +2882,40 @@ export default function Workspace() {

// --- handleMultiQuestionAnswer: submit answers to ask_user_multiple ---
const handleMultiQuestionAnswer = useCallback((answers: Record<string, string>) => {
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<string, unknown>;
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,
Expand All @@ -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.
Expand All @@ -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") {
Expand Down Expand Up @@ -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}
Expand Down
Loading