feat(agent): implement phase 2 runtime and inline clarification

This commit is contained in:
2026-04-09 14:28:27 +02:00
parent b08e448be0
commit 29c93eeb35
18 changed files with 2376 additions and 5 deletions

View File

@@ -0,0 +1,296 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
queueNodeDataUpdate: vi.fn(async () => undefined),
runAgent: vi.fn(async () => ({ queued: true })),
resumeAgent: vi.fn(async () => ({ queued: true })),
toastWarning: vi.fn(),
subscription: { tier: "starter" as const },
isOffline: false,
}));
vi.mock("convex/react", () => ({
useAction: (reference: unknown) => {
if (reference === "agents.resumeAgent") {
return mocks.resumeAgent;
}
return mocks.runAgent;
},
}));
vi.mock("@/convex/_generated/api", () => ({
api: {
credits: {
getSubscription: "credits.getSubscription",
},
agents: {
runAgent: "agents.runAgent",
resumeAgent: "agents.resumeAgent",
},
},
}));
vi.mock("@/hooks/use-auth-query", () => ({
useAuthQuery: () => mocks.subscription,
}));
vi.mock("@/components/canvas/canvas-sync-context", () => ({
useCanvasSync: () => ({
queueNodeDataUpdate: mocks.queueNodeDataUpdate,
status: { isOffline: mocks.isOffline, isSyncing: false, pendingCount: 0 },
}),
}));
vi.mock("@/lib/toast", () => ({
toast: {
warning: mocks.toastWarning,
},
}));
vi.mock("@/components/ui/label", () => ({
Label: ({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) =>
React.createElement("label", { htmlFor }, children),
}));
vi.mock("@/components/ui/select", () => ({
Select: ({
value,
onValueChange,
children,
}: {
value: string;
onValueChange: (value: string) => void;
children: React.ReactNode;
}) =>
React.createElement(
"select",
{
"aria-label": "agent-model",
value,
onChange: (event: Event) => {
onValueChange((event.target as HTMLSelectElement).value);
},
},
children,
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => children,
SelectValue: () => null,
SelectContent: ({ children }: { children: React.ReactNode }) => children,
SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) =>
React.createElement("option", { value }, children),
}));
vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({
default: ({ children }: { children: React.ReactNode }) => React.createElement("div", null, children),
}));
vi.mock("@xyflow/react", () => ({
Handle: () => null,
Position: { Left: "left", Right: "right" },
}));
import AgentNode from "@/components/canvas/nodes/agent-node";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("AgentNode runtime", () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
beforeEach(() => {
mocks.subscription = { tier: "starter" };
mocks.isOffline = false;
mocks.queueNodeDataUpdate.mockClear();
mocks.runAgent.mockClear();
mocks.resumeAgent.mockClear();
mocks.toastWarning.mockClear();
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
container?.remove();
container = null;
root = null;
});
it("renders tier-aware model picker, updates node data, and triggers run/resume actions", async () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(AgentNode, {
id: "agent-1",
selected: false,
dragging: false,
draggable: true,
selectable: true,
deletable: true,
zIndex: 1,
isConnectable: true,
type: "agent",
data: {
canvasId: "canvas-1",
templateId: "campaign-distributor",
modelId: "openai/gpt-5.4-mini",
clarificationQuestions: [
{ id: "audience", prompt: "Target audience?", required: true },
],
clarificationAnswers: {},
} as Record<string, unknown>,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
}),
);
});
const modelSelect = container.querySelector('select[aria-label="agent-model"]');
if (!(modelSelect instanceof HTMLSelectElement)) {
throw new Error("Agent model select not found");
}
const modelOptionValues = Array.from(modelSelect.querySelectorAll("option")).map(
(option) => (option as HTMLOptionElement).value,
);
expect(modelOptionValues).toContain("openai/gpt-5.4-mini");
expect(modelOptionValues).not.toContain("openai/gpt-5.4-pro");
expect(container.textContent).toContain("GPT-5.4 Mini");
expect(container.textContent).toContain("15 Cr");
expect(container.textContent).toContain("Channels");
expect(container.textContent).toContain("Expected Inputs");
expect(container.textContent).toContain("Expected Outputs");
await act(async () => {
modelSelect.value = "openai/gpt-5.4";
modelSelect.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith(
expect.objectContaining({
nodeId: "agent-1",
data: expect.objectContaining({ modelId: "openai/gpt-5.4" }),
}),
);
const clarificationInput = container.querySelector('input[name="clarification-audience"]');
if (!(clarificationInput instanceof HTMLInputElement)) {
throw new Error("Clarification input not found");
}
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
valueSetter?.call(clarificationInput, "SaaS founders");
clarificationInput.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith(
expect.objectContaining({
nodeId: "agent-1",
data: expect.objectContaining({
clarificationAnswers: expect.objectContaining({ audience: "SaaS founders" }),
}),
}),
);
const runButton = Array.from(container.querySelectorAll("button")).find((element) =>
element.textContent?.includes("Run agent"),
);
if (!(runButton instanceof HTMLButtonElement)) {
throw new Error("Run button not found");
}
await act(async () => {
runButton.click();
});
expect(mocks.runAgent).toHaveBeenCalledWith({
canvasId: "canvas-1",
nodeId: "agent-1",
modelId: "openai/gpt-5.4",
});
const submitButton = Array.from(container.querySelectorAll("button")).find((element) =>
element.textContent?.includes("Submit clarification"),
);
if (!(submitButton instanceof HTMLButtonElement)) {
throw new Error("Submit clarification button not found");
}
await act(async () => {
submitButton.click();
});
expect(mocks.resumeAgent).toHaveBeenCalledWith({
canvasId: "canvas-1",
nodeId: "agent-1",
clarificationAnswers: { audience: "SaaS founders" },
});
});
it("warns and skips actions when offline", async () => {
mocks.isOffline = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(AgentNode, {
id: "agent-2",
selected: false,
dragging: false,
draggable: true,
selectable: true,
deletable: true,
zIndex: 1,
isConnectable: true,
type: "agent",
data: {
canvasId: "canvas-1",
templateId: "campaign-distributor",
modelId: "openai/gpt-5.4-mini",
clarificationQuestions: [{ id: "q1", prompt: "Goal?", required: true }],
clarificationAnswers: { q1: "More signups" },
} as Record<string, unknown>,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
}),
);
});
const runButton = Array.from(container.querySelectorAll("button")).find((element) =>
element.textContent?.includes("Run agent"),
);
const submitButton = Array.from(container.querySelectorAll("button")).find((element) =>
element.textContent?.includes("Submit clarification"),
);
if (!(runButton instanceof HTMLButtonElement) || !(submitButton instanceof HTMLButtonElement)) {
throw new Error("Runtime action buttons not found");
}
await act(async () => {
runButton.click();
submitButton.click();
});
expect(mocks.toastWarning).toHaveBeenCalledTimes(2);
expect(mocks.runAgent).not.toHaveBeenCalled();
expect(mocks.resumeAgent).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,115 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const handleCalls: Array<{ type: string; id?: string }> = [];
vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({
default: ({ children }: { children: React.ReactNode }) => React.createElement("div", null, children),
}));
vi.mock("@xyflow/react", () => ({
Handle: ({ type, id }: { type: string; id?: string }) => {
handleCalls.push({ type, id });
return React.createElement("div", {
"data-handle-type": type,
"data-handle-id": id,
});
},
Position: { Left: "left", Right: "right" },
}));
import AgentOutputNode from "@/components/canvas/nodes/agent-output-node";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("AgentOutputNode", () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
beforeEach(() => {
handleCalls.length = 0;
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
container?.remove();
container = null;
root = null;
});
it("renders title, channel, output type, and body", async () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(AgentOutputNode, {
id: "agent-output-1",
selected: false,
dragging: false,
draggable: true,
selectable: true,
deletable: true,
zIndex: 1,
isConnectable: true,
type: "agent-output",
data: {
title: "Instagram Caption",
channel: "instagram-feed",
outputType: "caption",
body: "A short punchy caption with hashtags",
_status: "done",
} as Record<string, unknown>,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
}),
);
});
expect(container.textContent).toContain("Instagram Caption");
expect(container.textContent).toContain("instagram-feed");
expect(container.textContent).toContain("caption");
expect(container.textContent).toContain("A short punchy caption with hashtags");
});
it("renders input-only handle agent-output-in", async () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(AgentOutputNode, {
id: "agent-output-2",
selected: false,
dragging: false,
draggable: true,
selectable: true,
deletable: true,
zIndex: 1,
isConnectable: true,
type: "agent-output",
data: {
title: "LinkedIn Post",
channel: "linkedin",
outputType: "post",
body: "Body",
} as Record<string, unknown>,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
}),
);
});
expect(handleCalls).toEqual([{ type: "target", id: "agent-output-in" }]);
});
});

View File

@@ -180,6 +180,26 @@ describe("canvas connection policy", () => {
).toBeNull();
});
it("allows agent to agent-output", () => {
expect(
validateCanvasConnectionPolicy({
sourceType: "agent",
targetType: "agent-output",
targetIncomingCount: 0,
}),
).toBeNull();
});
it("blocks non-agent sources to agent-output", () => {
expect(
validateCanvasConnectionPolicy({
sourceType: "text",
targetType: "agent-output",
targetIncomingCount: 0,
}),
).toBe("agent-output-source-invalid");
});
it("blocks prompt to agent", () => {
expect(
validateCanvasConnectionPolicy({
@@ -197,4 +217,10 @@ describe("canvas connection policy", () => {
"Agent-Nodes akzeptieren nur Content- und Kontext-Inputs, keine Generierungs-Steuerknoten wie Prompt.",
);
});
it("describes invalid agent-output source message", () => {
expect(
getCanvasConnectionValidationMessage("agent-output-source-invalid"),
).toBe("Agent-Ausgabe akzeptiert nur Eingaben von Agent-Nodes.");
});
});

View File

@@ -0,0 +1,189 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { generateStructuredObjectViaOpenRouter } from "@/convex/openrouter";
type MockResponseInit = {
ok: boolean;
status: number;
json?: unknown;
text?: string;
};
function createMockResponse(init: MockResponseInit): Response {
return {
ok: init.ok,
status: init.status,
json: vi.fn(async () => init.json),
text: vi.fn(async () => init.text ?? JSON.stringify(init.json ?? {})),
} as unknown as Response;
}
describe("generateStructuredObjectViaOpenRouter", () => {
const fetchMock = vi.fn<typeof fetch>();
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("posts chat completion request with strict json_schema and parses response content", async () => {
fetchMock.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
json: {
choices: [
{
message: {
content: '{"title":"LemonSpace","confidence":0.92}',
},
},
],
},
}),
);
const schema = {
type: "object",
additionalProperties: false,
required: ["title", "confidence"],
properties: {
title: { type: "string" },
confidence: { type: "number" },
},
};
const result = await generateStructuredObjectViaOpenRouter<{
title: string;
confidence: number;
}>("test-api-key", {
model: "openai/gpt-5-mini",
messages: [
{ role: "system", content: "Extract a JSON object." },
{ role: "user", content: "Title is LemonSpace with confidence 0.92" },
],
schemaName: "extract_title",
schema,
});
expect(result).toEqual({ title: "LemonSpace", confidence: 0.92 });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"https://openrouter.ai/api/v1/chat/completions",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
Authorization: "Bearer test-api-key",
"Content-Type": "application/json",
"HTTP-Referer": "https://app.lemonspace.io",
"X-Title": "LemonSpace",
}),
}),
);
const firstCallArgs = fetchMock.mock.calls[0];
const init = firstCallArgs?.[1] as RequestInit | undefined;
const bodyRaw = init?.body;
const body = JSON.parse(typeof bodyRaw === "string" ? bodyRaw : "{}");
expect(body).toMatchObject({
model: "openai/gpt-5-mini",
messages: [
{ role: "system", content: "Extract a JSON object." },
{ role: "user", content: "Title is LemonSpace with confidence 0.92" },
],
response_format: {
type: "json_schema",
json_schema: {
name: "extract_title",
strict: true,
schema,
},
},
});
});
it("throws ConvexError code when response content is missing", async () => {
fetchMock.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
json: {
choices: [
{
message: {},
},
],
},
}),
);
await expect(
generateStructuredObjectViaOpenRouter("test-api-key", {
model: "openai/gpt-5-mini",
messages: [{ role: "user", content: "hello" }],
schemaName: "test_schema",
schema: { type: "object" },
}),
).rejects.toMatchObject({
data: { code: "OPENROUTER_STRUCTURED_OUTPUT_MISSING_CONTENT" },
});
});
it("throws ConvexError code when response content is invalid JSON", async () => {
fetchMock.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
json: {
choices: [
{
message: {
content: "not valid json",
},
},
],
},
}),
);
await expect(
generateStructuredObjectViaOpenRouter("test-api-key", {
model: "openai/gpt-5-mini",
messages: [{ role: "user", content: "hello" }],
schemaName: "test_schema",
schema: { type: "object" },
}),
).rejects.toMatchObject({
data: { code: "OPENROUTER_STRUCTURED_OUTPUT_INVALID_JSON" },
});
});
it("throws ConvexError code when OpenRouter responds with non-ok status", async () => {
fetchMock.mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 503,
text: "service unavailable",
}),
);
await expect(
generateStructuredObjectViaOpenRouter("test-api-key", {
model: "openai/gpt-5-mini",
messages: [{ role: "user", content: "hello" }],
schemaName: "test_schema",
schema: { type: "object" },
}),
).rejects.toMatchObject({
data: {
code: "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR",
status: 503,
},
});
});
});

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
AGENT_MODELS,
DEFAULT_AGENT_MODEL_ID,
getAgentModel,
getAvailableAgentModels,
isAgentModelAvailableForTier,
} from "@/lib/agent-models";
import { NODE_DEFAULTS } from "@/lib/canvas-utils";
describe("agent models registry", () => {
it("contains approved models in stable order", () => {
expect(Object.keys(AGENT_MODELS)).toEqual([
"openai/gpt-5.4-nano",
"openai/gpt-5.4-mini",
"openai/gpt-5.4",
"openai/gpt-5.4-pro",
]);
expect(DEFAULT_AGENT_MODEL_ID).toBe("openai/gpt-5.4-mini");
});
it("resolves model lookup and pricing", () => {
expect(getAgentModel("openai/gpt-5.4-nano")?.creditCost).toBe(6);
expect(getAgentModel("openai/gpt-5.4-mini")?.creditCost).toBe(15);
expect(getAgentModel("openai/gpt-5.4")?.creditCost).toBe(38);
expect(getAgentModel("openai/gpt-5.4-pro")?.creditCost).toBe(180);
expect(getAgentModel("unknown-model")).toBeUndefined();
});
it("filters models by tier", () => {
expect(getAvailableAgentModels("free").map((model) => model.id)).toEqual([]);
expect(getAvailableAgentModels("starter").map((model) => model.id)).toEqual([
"openai/gpt-5.4-nano",
"openai/gpt-5.4-mini",
"openai/gpt-5.4",
]);
expect(getAvailableAgentModels("max").map((model) => model.id)).toEqual([
"openai/gpt-5.4-nano",
"openai/gpt-5.4-mini",
"openai/gpt-5.4",
"openai/gpt-5.4-pro",
]);
});
it("guards access by tier", () => {
expect(isAgentModelAvailableForTier("starter", "openai/gpt-5.4")).toBe(true);
expect(isAgentModelAvailableForTier("starter", "openai/gpt-5.4-pro")).toBe(false);
expect(isAgentModelAvailableForTier("max", "openai/gpt-5.4-pro")).toBe(true);
});
it("uses the registry default in agent node defaults", () => {
expect(NODE_DEFAULTS.agent?.data).toMatchObject({
templateId: "campaign-distributor",
modelId: DEFAULT_AGENT_MODEL_ID,
clarificationQuestions: [],
clarificationAnswers: {},
outputNodeIds: [],
});
});
});

View File

@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import {
areClarificationAnswersComplete,
normalizeAgentOutputDraft,
type AgentClarificationAnswerMap,
type AgentClarificationQuestion,
} from "@/lib/agent-run-contract";
describe("agent run contract helpers", () => {
describe("areClarificationAnswersComplete", () => {
it("returns true when every required question has a non-empty answer", () => {
const questions: AgentClarificationQuestion[] = [
{ id: "goal", prompt: "What is the goal?", required: true },
{ id: "tone", prompt: "Preferred tone?", required: false },
{ id: "audience", prompt: "Who is the audience?", required: true },
];
const answers: AgentClarificationAnswerMap = {
goal: "Generate launch captions",
audience: "SaaS founders",
};
expect(areClarificationAnswersComplete(questions, answers)).toBe(true);
});
it("returns false when a required question is missing", () => {
const questions: AgentClarificationQuestion[] = [
{ id: "goal", prompt: "What is the goal?", required: true },
{ id: "audience", prompt: "Who is the audience?", required: true },
];
const answers: AgentClarificationAnswerMap = {
goal: "Generate launch captions",
};
expect(areClarificationAnswersComplete(questions, answers)).toBe(false);
});
it("returns false when required answers are blank after trimming", () => {
const questions: AgentClarificationQuestion[] = [
{ id: "goal", prompt: "What is the goal?", required: true },
];
const answers: AgentClarificationAnswerMap = {
goal: " ",
};
expect(areClarificationAnswersComplete(questions, answers)).toBe(false);
});
});
describe("normalizeAgentOutputDraft", () => {
it("trims draft fields and keeps non-empty values", () => {
const normalized = normalizeAgentOutputDraft({
title: " Launch Caption Pack ",
channel: " Instagram Feed ",
outputType: " caption-package ",
body: " 3 variants with hook-first copy. ",
});
expect(normalized).toEqual({
title: "Launch Caption Pack",
channel: "Instagram Feed",
outputType: "caption-package",
body: "3 variants with hook-first copy.",
});
});
it("uses safe fallback values and guarantees body string", () => {
const normalized = normalizeAgentOutputDraft({
title: " ",
channel: "",
outputType: " ",
});
expect(normalized).toEqual({
title: "Untitled",
channel: "general",
outputType: "text",
body: "",
});
});
it("coerces non-string body values to empty string", () => {
const normalized = normalizeAgentOutputDraft({
title: "Recap",
channel: "Email",
outputType: "summary",
body: null as unknown as string,
});
expect(normalized.body).toBe("");
});
});
});