feat(canvas): finalize mixer reconnect swap and related updates

This commit is contained in:
2026-04-11 07:42:42 +02:00
parent f3dcaf89f2
commit 028fce35c2
52 changed files with 3859 additions and 272 deletions

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { __testables } from "@/convex/agents";
import { __testables as openrouterTestables } from "@/convex/openrouter";
describe("agent orchestration contract helpers", () => {
it("builds skeleton output data with rich execution-plan metadata", () => {
@@ -59,7 +60,8 @@ describe("agent orchestration contract helpers", () => {
{ id: "hook", label: "Hook", content: "Lead with proof." },
{ id: "cta", label: "CTA", content: "Invite comments." },
],
metadata: { audience: "SaaS founders" },
metadata: { tonalitaet: "freundlich", audience: "SaaS founders" },
metadataLabels: { tonalitaet: "tonalität", audience: "audience" },
qualityChecks: [],
body: "",
},
@@ -69,6 +71,7 @@ describe("agent orchestration contract helpers", () => {
expect(data.body).toBe("Hook:\nLead with proof.\n\nCTA:\nInvite comments.");
expect(data.previewText).toBe("Lead with proof.");
expect(data.qualityChecks).toEqual(["channel-fit", "clear-cta"]);
expect(data.metadataLabels).toEqual({ tonalitaet: "tonalität", audience: "audience" });
});
it("requires rich execution-step fields in analyze schema", () => {
@@ -87,6 +90,25 @@ describe("agent orchestration contract helpers", () => {
);
});
it("builds provider-safe execute schema without dynamic metadata maps", () => {
const schema = __testables.buildExecuteSchema(["step-1"]);
const diagnostics = openrouterTestables.getStructuredSchemaDiagnostics({
schema,
messages: [
{ role: "system", content: "system" },
{ role: "user", content: "user" },
],
});
const stepOne = (((schema.properties as Record<string, unknown>).stepOutputs as Record<string, unknown>)
.properties as Record<string, unknown>)["step-1"] as Record<string, unknown>;
expect(stepOne.required).toContain("metadataEntries");
expect(stepOne.required).not.toContain("metadata");
expect(diagnostics.hasAnyOf).toBe(false);
expect(diagnostics.hasDynamicAdditionalProperties).toBe(false);
});
it("resolves persisted summaries consistently across analyze and execute", () => {
const promptSummary = __testables.resolveExecutionPlanSummary({
executionPlanSummary: "",

View File

@@ -60,6 +60,19 @@ describe("ai error helpers", () => {
).toBe("Provider: OpenRouter API error 503: Upstream timeout");
});
it("formats structured-output http error with extracted provider details from JSON payload", () => {
expect(
formatTerminalStatusMessage(
new ConvexError({
code: "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR",
status: 502,
message:
'{"error":{"message":"Provider returned error","code":"provider_error","type":"upstream_error"}}',
}),
),
).toBe("Provider: OpenRouter 502: Provider returned error [code=provider_error, type=upstream_error]");
});
it("formats structured-output http error without falling back to raw code", () => {
expect(
formatTerminalStatusMessage(

View File

@@ -0,0 +1,383 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/convex/helpers", () => ({
requireAuth: vi.fn(),
}));
import type { Id } from "@/convex/_generated/dataModel";
import { create, swapMixerInputs } from "@/convex/edges";
import { requireAuth } from "@/convex/helpers";
type MockCanvas = {
_id: Id<"canvases">;
ownerId: string;
updatedAt: number;
};
type MockNode = {
_id: Id<"nodes">;
canvasId: Id<"canvases">;
type: string;
};
type MockEdge = {
_id: Id<"edges">;
canvasId: Id<"canvases">;
sourceNodeId: Id<"nodes">;
targetNodeId: Id<"nodes">;
sourceHandle?: string;
targetHandle?: string;
};
function createMockCtx(args?: {
canvases?: MockCanvas[];
nodes?: MockNode[];
edges?: MockEdge[];
}) {
const canvases = new Map((args?.canvases ?? []).map((canvas) => [canvas._id, { ...canvas }]));
const nodes = new Map((args?.nodes ?? []).map((node) => [node._id, { ...node }]));
const edges = new Map((args?.edges ?? []).map((edge) => [edge._id, { ...edge }]));
const deletes: Id<"edges">[] = [];
let insertedEdgeCount = 0;
const ctx = {
db: {
get: vi.fn(async (id: string) => {
if (canvases.has(id as Id<"canvases">)) {
return canvases.get(id as Id<"canvases">) ?? null;
}
if (nodes.has(id as Id<"nodes">)) {
return nodes.get(id as Id<"nodes">) ?? null;
}
if (edges.has(id as Id<"edges">)) {
return edges.get(id as Id<"edges">) ?? null;
}
return null;
}),
query: vi.fn((table: "mutationRequests" | "edges") => {
if (table === "mutationRequests") {
return {
withIndex: vi.fn(() => ({
first: vi.fn(async () => null),
})),
};
}
if (table === "edges") {
return {
withIndex: vi.fn(
(
_index: "by_target",
apply: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
) => {
const clauses: Array<{ field: string; value: unknown }> = [];
const queryBuilder = {
eq(field: string, value: unknown) {
clauses.push({ field, value });
return this;
},
};
apply(queryBuilder);
const targetNodeId = clauses.find((clause) => clause.field === "targetNodeId")
?.value as Id<"nodes"> | undefined;
const incoming = Array.from(edges.values()).filter(
(edge) => edge.targetNodeId === targetNodeId,
);
return {
take: vi.fn(async (count: number) => incoming.slice(0, count)),
};
},
),
};
}
throw new Error(`Unexpected query table: ${table}`);
}),
insert: vi.fn(async (table: "edges" | "mutationRequests", value: Record<string, unknown>) => {
if (table === "mutationRequests") {
return "mutation_request_1";
}
insertedEdgeCount += 1;
const edgeId = `edge-new-${insertedEdgeCount}` as Id<"edges">;
edges.set(edgeId, {
_id: edgeId,
canvasId: value.canvasId as Id<"canvases">,
sourceNodeId: value.sourceNodeId as Id<"nodes">,
targetNodeId: value.targetNodeId as Id<"nodes">,
sourceHandle: value.sourceHandle as string | undefined,
targetHandle: value.targetHandle as string | undefined,
});
return edgeId;
}),
patch: vi.fn(async (id: string, patch: Record<string, unknown>) => {
if (canvases.has(id as Id<"canvases">)) {
const canvas = canvases.get(id as Id<"canvases">);
if (!canvas) {
throw new Error("Canvas missing");
}
canvases.set(id as Id<"canvases">, { ...canvas, ...patch });
return;
}
if (edges.has(id as Id<"edges">)) {
const edge = edges.get(id as Id<"edges">);
if (!edge) {
throw new Error("Edge missing");
}
edges.set(id as Id<"edges">, { ...edge, ...patch });
return;
}
throw new Error("Record missing");
}),
delete: vi.fn(async (edgeId: Id<"edges">) => {
deletes.push(edgeId);
edges.delete(edgeId);
}),
},
};
return {
ctx,
listEdges: () => Array.from(edges.values()),
listCanvases: () => Array.from(canvases.values()),
deletes,
};
}
describe("edges.create", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("treats edgeIdToIgnore as replacement and removes ignored edge", async () => {
vi.mocked(requireAuth).mockResolvedValue({ userId: "user-1" } as never);
const canvasId = "canvas-1" as Id<"canvases">;
const targetNodeId = "node-target" as Id<"nodes">;
const oldEdgeId = "edge-old" as Id<"edges">;
const mock = createMockCtx({
canvases: [{ _id: canvasId, ownerId: "user-1", updatedAt: 1 }],
nodes: [
{ _id: "node-source-1" as Id<"nodes">, canvasId, type: "image" },
{ _id: "node-source-2" as Id<"nodes">, canvasId, type: "image" },
{ _id: targetNodeId, canvasId, type: "mixer" },
],
edges: [
{
_id: oldEdgeId,
canvasId,
sourceNodeId: "node-source-1" as Id<"nodes">,
targetNodeId,
targetHandle: "base",
},
],
});
await (create as unknown as {
_handler: (
ctx: unknown,
args: {
canvasId: Id<"canvases">;
sourceNodeId: Id<"nodes">;
targetNodeId: Id<"nodes">;
targetHandle?: string;
edgeIdToIgnore?: Id<"edges">;
},
) => Promise<Id<"edges">>;
})._handler(mock.ctx, {
canvasId,
sourceNodeId: "node-source-2" as Id<"nodes">,
targetNodeId,
targetHandle: "base",
edgeIdToIgnore: oldEdgeId,
});
const incomingToTarget = mock
.listEdges()
.filter((edge) => edge.targetNodeId === targetNodeId && edge.targetHandle === "base");
expect(incomingToTarget).toHaveLength(1);
expect(incomingToTarget[0]?._id).not.toBe(oldEdgeId);
expect(mock.deletes).toEqual([oldEdgeId]);
});
});
describe("edges.swapMixerInputs", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("swaps handles between two mixer input edges and updates canvas timestamp", async () => {
vi.mocked(requireAuth).mockResolvedValue({ userId: "user-1" } as never);
const canvasId = "canvas-1" as Id<"canvases">;
const mixerNodeId = "node-mixer" as Id<"nodes">;
const mock = createMockCtx({
canvases: [{ _id: canvasId, ownerId: "user-1", updatedAt: 1 }],
nodes: [{ _id: mixerNodeId, canvasId, type: "mixer" }],
edges: [
{
_id: "edge-base" as Id<"edges">,
canvasId,
sourceNodeId: "source-1" as Id<"nodes">,
targetNodeId: mixerNodeId,
targetHandle: "base",
},
{
_id: "edge-overlay" as Id<"edges">,
canvasId,
sourceNodeId: "source-2" as Id<"nodes">,
targetNodeId: mixerNodeId,
targetHandle: "overlay",
},
],
});
await (swapMixerInputs as unknown as {
_handler: (
ctx: unknown,
args: {
canvasId: Id<"canvases">;
edgeId: Id<"edges">;
otherEdgeId: Id<"edges">;
},
) => Promise<void>;
})._handler(mock.ctx, {
canvasId,
edgeId: "edge-base" as Id<"edges">,
otherEdgeId: "edge-overlay" as Id<"edges">,
});
const swappedEdges = mock.listEdges();
expect(swappedEdges.find((edge) => edge._id === ("edge-base" as Id<"edges">))?.targetHandle).toBe(
"overlay",
);
expect(
swappedEdges.find((edge) => edge._id === ("edge-overlay" as Id<"edges">))?.targetHandle,
).toBe("base");
expect(mock.listCanvases()[0]?.updatedAt).toBeGreaterThan(1);
});
it("fails when one of the edge IDs does not exist", async () => {
vi.mocked(requireAuth).mockResolvedValue({ userId: "user-1" } as never);
const canvasId = "canvas-1" as Id<"canvases">;
const mock = createMockCtx({
canvases: [{ _id: canvasId, ownerId: "user-1", updatedAt: 1 }],
edges: [
{
_id: "edge-base" as Id<"edges">,
canvasId,
sourceNodeId: "source-1" as Id<"nodes">,
targetNodeId: "node-mixer" as Id<"nodes">,
targetHandle: "base",
},
],
});
await expect(
(swapMixerInputs as unknown as {
_handler: (
ctx: unknown,
args: {
canvasId: Id<"canvases">;
edgeId: Id<"edges">;
otherEdgeId: Id<"edges">;
},
) => Promise<void>;
})._handler(mock.ctx, {
canvasId,
edgeId: "edge-base" as Id<"edges">,
otherEdgeId: "edge-missing" as Id<"edges">,
}),
).rejects.toThrow("Edge not found");
});
it("fails when edges are not exactly one base and one overlay handle", async () => {
vi.mocked(requireAuth).mockResolvedValue({ userId: "user-1" } as never);
const canvasId = "canvas-1" as Id<"canvases">;
const mixerNodeId = "node-mixer" as Id<"nodes">;
const mock = createMockCtx({
canvases: [{ _id: canvasId, ownerId: "user-1", updatedAt: 1 }],
nodes: [{ _id: mixerNodeId, canvasId, type: "mixer" }],
edges: [
{
_id: "edge-1" as Id<"edges">,
canvasId,
sourceNodeId: "source-1" as Id<"nodes">,
targetNodeId: mixerNodeId,
targetHandle: "base",
},
{
_id: "edge-2" as Id<"edges">,
canvasId,
sourceNodeId: "source-2" as Id<"nodes">,
targetNodeId: mixerNodeId,
targetHandle: "base",
},
],
});
await expect(
(swapMixerInputs as unknown as {
_handler: (
ctx: unknown,
args: {
canvasId: Id<"canvases">;
edgeId: Id<"edges">;
otherEdgeId: Id<"edges">;
},
) => Promise<void>;
})._handler(mock.ctx, {
canvasId,
edgeId: "edge-1" as Id<"edges">,
otherEdgeId: "edge-2" as Id<"edges">,
}),
).rejects.toThrow("Mixer swap requires one base and one overlay edge");
});
it("fails when edges do not belong to the same mixer target on the same canvas", async () => {
vi.mocked(requireAuth).mockResolvedValue({ userId: "user-1" } as never);
const canvasId = "canvas-1" as Id<"canvases">;
const mock = createMockCtx({
canvases: [{ _id: canvasId, ownerId: "user-1", updatedAt: 1 }],
nodes: [
{ _id: "node-mixer-a" as Id<"nodes">, canvasId, type: "mixer" },
{ _id: "node-mixer-b" as Id<"nodes">, canvasId, type: "mixer" },
],
edges: [
{
_id: "edge-base" as Id<"edges">,
canvasId,
sourceNodeId: "source-1" as Id<"nodes">,
targetNodeId: "node-mixer-a" as Id<"nodes">,
targetHandle: "base",
},
{
_id: "edge-overlay" as Id<"edges">,
canvasId,
sourceNodeId: "source-2" as Id<"nodes">,
targetNodeId: "node-mixer-b" as Id<"nodes">,
targetHandle: "overlay",
},
],
});
await expect(
(swapMixerInputs as unknown as {
_handler: (
ctx: unknown,
args: {
canvasId: Id<"canvases">;
edgeId: Id<"edges">;
otherEdgeId: Id<"edges">;
},
) => Promise<void>;
})._handler(mock.ctx, {
canvasId,
edgeId: "edge-base" as Id<"edges">,
otherEdgeId: "edge-overlay" as Id<"edges">,
}),
).rejects.toThrow("Edges must target the same mixer node");
});
});

View File

@@ -1,6 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { generateStructuredObjectViaOpenRouter } from "@/convex/openrouter";
import {
__testables,
generateStructuredObjectViaOpenRouter,
} from "@/convex/openrouter";
type MockResponseInit = {
ok: boolean;
@@ -285,4 +288,65 @@ describe("generateStructuredObjectViaOpenRouter", () => {
},
});
});
it("extracts provider details from OpenRouter JSON error payload", async () => {
fetchMock.mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 502,
text: '{"error":{"message":"Provider returned error","code":"provider_error","type":"upstream_error"}}',
}),
);
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: 502,
providerCode: "provider_error",
providerType: "upstream_error",
providerMessage: "Provider returned error",
message: "OpenRouter 502: Provider returned error [code=provider_error, type=upstream_error]",
},
});
});
it("detects schema features that are likely to matter for structured-output debugging", () => {
const diagnostics = __testables.getStructuredSchemaDiagnostics({
schema: {
type: "object",
required: ["summary", "metadata"],
properties: {
summary: { type: "string" },
metadata: {
type: "object",
additionalProperties: {
anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }],
},
},
},
},
messages: [
{ role: "system", content: "system prompt" },
{ role: "user", content: "user prompt" },
],
});
expect(diagnostics).toMatchObject({
topLevelType: "object",
topLevelRequiredCount: 2,
topLevelPropertyCount: 2,
messageCount: 2,
messageLengths: [13, 11],
hasAnyOf: true,
hasDynamicAdditionalProperties: true,
hasPatternProperties: false,
});
});
});