feat(agent): implement phase 2 runtime and inline clarification
This commit is contained in:
296
tests/agent-node-runtime.test.ts
Normal file
296
tests/agent-node-runtime.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
115
tests/agent-output-node.test.ts
Normal file
115
tests/agent-output-node.test.ts
Normal 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" }]);
|
||||
});
|
||||
});
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
|
||||
189
tests/convex/openrouter-structured-output.test.ts
Normal file
189
tests/convex/openrouter-structured-output.test.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
61
tests/lib/agent-models.test.ts
Normal file
61
tests/lib/agent-models.test.ts
Normal 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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
96
tests/lib/agent-run-contract.test.ts
Normal file
96
tests/lib/agent-run-contract.test.ts
Normal 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("");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user