diff --git a/components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts b/components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts index 16aad93..b753ca1 100644 --- a/components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts +++ b/components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts @@ -346,6 +346,96 @@ describe("canvas flow reconciliation helpers", () => { expect(result.nextPendingLocalNodeDataPins.size).toBe(0); }); + it("keeps pinned local node size until convex catches up", () => { + const pinnedSize = { width: 419, height: 466 }; + + const result = reconcileCanvasFlowNodes({ + previousNodes: [ + { + id: "node-1", + type: "render", + position: { x: 120, y: 80 }, + data: {}, + style: pinnedSize, + }, + ], + incomingNodes: [ + { + id: "node-1", + type: "render", + position: { x: 120, y: 80 }, + data: {}, + style: { width: 640, height: 360 }, + }, + ], + convexNodes: [{ _id: asNodeId("node-1"), type: "render" }], + deletingNodeIds: new Set(), + resolvedRealIdByClientRequest: new Map(), + pendingConnectionCreateIds: new Set(), + preferLocalPositionNodeIds: new Set(), + pendingLocalPositionPins: new Map(), + pendingLocalNodeSizePins: new Map([["node-1", pinnedSize]]), + pendingMovePins: new Map(), + }); + + expect(result.nodes).toEqual([ + { + id: "node-1", + type: "render", + position: { x: 120, y: 80 }, + data: {}, + style: pinnedSize, + }, + ]); + expect(result.nextPendingLocalNodeSizePins).toEqual( + new Map([["node-1", pinnedSize]]), + ); + }); + + it("clears pinned local node size once convex matches the persisted size", () => { + const pinnedSize = { width: 419, height: 466 }; + + const result = reconcileCanvasFlowNodes({ + previousNodes: [ + { + id: "node-1", + type: "render", + position: { x: 120, y: 80 }, + data: {}, + style: pinnedSize, + }, + ], + incomingNodes: [ + { + id: "node-1", + type: "render", + position: { x: 120, y: 80 }, + data: {}, + style: pinnedSize, + }, + ], + convexNodes: [{ _id: asNodeId("node-1"), type: "render" }], + deletingNodeIds: new Set(), + resolvedRealIdByClientRequest: new Map(), + pendingConnectionCreateIds: new Set(), + preferLocalPositionNodeIds: new Set(), + pendingLocalPositionPins: new Map(), + pendingLocalNodeSizePins: new Map([["node-1", pinnedSize]]), + pendingMovePins: new Map(), + }); + + expect(result.nodes).toEqual([ + { + id: "node-1", + type: "render", + position: { x: 120, y: 80 }, + data: {}, + style: pinnedSize, + }, + ]); + expect(result.nextPendingLocalNodeSizePins.size).toBe(0); + }); + it("filters deleting nodes from incoming reconciliation results", () => { const result = reconcileCanvasFlowNodes({ previousNodes: [ diff --git a/components/canvas/__tests__/use-canvas-flow-reconciliation.test.ts b/components/canvas/__tests__/use-canvas-flow-reconciliation.test.ts index 1ddb7c8..f4b300e 100644 --- a/components/canvas/__tests__/use-canvas-flow-reconciliation.test.ts +++ b/components/canvas/__tests__/use-canvas-flow-reconciliation.test.ts @@ -39,6 +39,7 @@ type HarnessProps = { previousConvexNodeIdsSnapshot: Set; pendingLocalPositionPins?: Map; pendingLocalNodeDataPins?: Map; + pendingLocalNodeSizePins?: Map; preferLocalPositionNodeIds?: Set; isResizingRefOverride?: { current: boolean }; }; @@ -82,6 +83,9 @@ function HookHarness(props: HarnessProps) { const pendingLocalNodeDataUntilConvexMatchesRef = useRef( props.pendingLocalNodeDataPins ?? new Map(), ); + const pendingLocalNodeSizeUntilConvexMatchesRef = useRef( + props.pendingLocalNodeSizePins ?? new Map(), + ); const preferLocalPositionNodeIdsRef = useRef( props.preferLocalPositionNodeIds ?? new Set(), ); @@ -120,6 +124,7 @@ function HookHarness(props: HarnessProps) { pendingConnectionCreatesRef, pendingLocalPositionUntilConvexMatchesRef, pendingLocalNodeDataUntilConvexMatchesRef, + pendingLocalNodeSizeUntilConvexMatchesRef, preferLocalPositionNodeIdsRef, isDragging: isDraggingRef, isResizing: isResizingRef, diff --git a/components/canvas/__tests__/use-canvas-sync-engine.test.ts b/components/canvas/__tests__/use-canvas-sync-engine.test.ts index 1abd593..b866f49 100644 --- a/components/canvas/__tests__/use-canvas-sync-engine.test.ts +++ b/components/canvas/__tests__/use-canvas-sync-engine.test.ts @@ -160,7 +160,7 @@ describe("useCanvasSyncEngine", () => { getEnqueueSyncMutation: () => enqueueSyncMutation, getRunBatchRemoveNodes: () => vi.fn(async () => undefined), getRunSplitEdgeAtExistingNode: () => vi.fn(async () => undefined), - getSetNodes: () => setNodes, + getSetNodes: () => setNodes as never, }); await controller.queueNodeDataUpdate({ @@ -177,4 +177,46 @@ describe("useCanvasSyncEngine", () => { data: { blackPoint: 209 }, }); }); + + it("pins local node size immediately when queueing a resize", async () => { + const enqueueSyncMutation = vi.fn(async () => undefined); + let nodes = [ + { + id: "node-1", + type: "render", + position: { x: 0, y: 0 }, + data: {}, + style: { width: 640, height: 360 }, + }, + ]; + const setNodes = (updater: (current: typeof nodes) => typeof nodes) => { + nodes = updater(nodes); + return nodes; + }; + + const controller = createCanvasSyncEngineController({ + canvasId: asCanvasId("canvas-1"), + isSyncOnline: true, + getEnqueueSyncMutation: () => enqueueSyncMutation, + getRunBatchRemoveNodes: () => vi.fn(async () => undefined), + getRunSplitEdgeAtExistingNode: () => vi.fn(async () => undefined), + getSetNodes: () => setNodes as never, + }); + + await controller.queueNodeResize({ + nodeId: asNodeId("node-1"), + width: 419, + height: 466, + }); + + expect(nodes[0]?.style).toEqual({ width: 419, height: 466 }); + expect(controller.pendingLocalNodeSizeUntilConvexMatchesRef.current).toEqual( + new Map([["node-1", { width: 419, height: 466 }]]), + ); + expect(enqueueSyncMutation).toHaveBeenCalledWith("resizeNode", { + nodeId: asNodeId("node-1"), + width: 419, + height: 466, + }); + }); }); diff --git a/components/canvas/canvas-flow-reconciliation-helpers.ts b/components/canvas/canvas-flow-reconciliation-helpers.ts index 6d2ea0d..7149876 100644 --- a/components/canvas/canvas-flow-reconciliation-helpers.ts +++ b/components/canvas/canvas-flow-reconciliation-helpers.ts @@ -354,6 +354,49 @@ function applyLocalNodeDataPins(args: { }; } +function nodeStyleIncludesSizePin( + style: RFNode["style"] | undefined, + pin: { width: number; height: number }, +): boolean { + return style?.width === pin.width && style?.height === pin.height; +} + +function applyLocalNodeSizePins(args: { + nodes: RFNode[]; + pendingLocalNodeSizePins: ReadonlyMap; +}): { + nodes: RFNode[]; + nextPendingLocalNodeSizePins: Map; +} { + const nodeIds = new Set(args.nodes.map((node) => node.id)); + const nextPendingLocalNodeSizePins = new Map( + [...args.pendingLocalNodeSizePins].filter(([nodeId]) => nodeIds.has(nodeId)), + ); + const nodes = args.nodes.map((node) => { + const pin = nextPendingLocalNodeSizePins.get(node.id); + if (!pin) return node; + + if (nodeStyleIncludesSizePin(node.style, pin)) { + nextPendingLocalNodeSizePins.delete(node.id); + return node; + } + + return { + ...node, + style: { + ...(node.style ?? {}), + width: pin.width, + height: pin.height, + }, + }; + }); + + return { + nodes, + nextPendingLocalNodeSizePins, + }; +} + export function reconcileCanvasFlowNodes(args: { previousNodes: RFNode[]; incomingNodes: RFNode[]; @@ -364,12 +407,14 @@ export function reconcileCanvasFlowNodes(args: { preferLocalPositionNodeIds: ReadonlySet; pendingLocalPositionPins: ReadonlyMap; pendingLocalNodeDataPins?: ReadonlyMap; + pendingLocalNodeSizePins?: ReadonlyMap; pendingMovePins: ReadonlyMap; }): { nodes: RFNode[]; inferredRealIdByClientRequest: Map>; nextPendingLocalPositionPins: Map; nextPendingLocalNodeDataPins: Map; + nextPendingLocalNodeSizePins: Map; clearedPreferLocalPositionNodeIds: string[]; } { const inferredRealIdByClientRequest = inferPendingConnectionNodeHandoff({ @@ -392,8 +437,12 @@ export function reconcileCanvasFlowNodes(args: { nodes: mergedNodes, pendingLocalNodeDataPins: args.pendingLocalNodeDataPins ?? new Map(), }); - const pinnedNodes = applyLocalPositionPins({ + const sizePinnedNodes = applyLocalNodeSizePins({ nodes: dataPinnedNodes.nodes, + pendingLocalNodeSizePins: args.pendingLocalNodeSizePins ?? new Map(), + }); + const pinnedNodes = applyLocalPositionPins({ + nodes: sizePinnedNodes.nodes, pendingLocalPositionPins: args.pendingLocalPositionPins, }); const nodes = applyPinnedNodePositionsReadOnly( @@ -419,6 +468,7 @@ export function reconcileCanvasFlowNodes(args: { inferredRealIdByClientRequest, nextPendingLocalPositionPins: pinnedNodes.nextPendingLocalPositionPins, nextPendingLocalNodeDataPins: dataPinnedNodes.nextPendingLocalNodeDataPins, + nextPendingLocalNodeSizePins: sizePinnedNodes.nextPendingLocalNodeSizePins, clearedPreferLocalPositionNodeIds, }; } diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 5aa2813..0c4330b 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -119,6 +119,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { pendingConnectionCreatesRef, pendingLocalPositionUntilConvexMatchesRef, pendingLocalNodeDataUntilConvexMatchesRef, + pendingLocalNodeSizeUntilConvexMatchesRef, preferLocalPositionNodeIdsRef, }, actions: { @@ -454,6 +455,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { pendingConnectionCreatesRef, pendingLocalPositionUntilConvexMatchesRef, pendingLocalNodeDataUntilConvexMatchesRef, + pendingLocalNodeSizeUntilConvexMatchesRef, preferLocalPositionNodeIdsRef, isDragging, isResizing, diff --git a/components/canvas/use-canvas-flow-reconciliation.ts b/components/canvas/use-canvas-flow-reconciliation.ts index 8eb68fb..e9622af 100644 --- a/components/canvas/use-canvas-flow-reconciliation.ts +++ b/components/canvas/use-canvas-flow-reconciliation.ts @@ -21,6 +21,9 @@ type CanvasFlowReconciliationRefs = { Map >; pendingLocalNodeDataUntilConvexMatchesRef: MutableRefObject>; + pendingLocalNodeSizeUntilConvexMatchesRef: MutableRefObject< + Map + >; preferLocalPositionNodeIdsRef: MutableRefObject>; isDragging: MutableRefObject; isResizing: MutableRefObject; @@ -56,6 +59,7 @@ export function useCanvasFlowReconciliation(args: { pendingConnectionCreatesRef, pendingLocalPositionUntilConvexMatchesRef, pendingLocalNodeDataUntilConvexMatchesRef, + pendingLocalNodeSizeUntilConvexMatchesRef, preferLocalPositionNodeIdsRef, isDragging, isResizing, @@ -135,6 +139,8 @@ export function useCanvasFlowReconciliation(args: { pendingLocalPositionPins: pendingLocalPositionUntilConvexMatchesRef.current, pendingLocalNodeDataPins: pendingLocalNodeDataUntilConvexMatchesRef.current, + pendingLocalNodeSizePins: + pendingLocalNodeSizeUntilConvexMatchesRef.current, pendingMovePins, }); @@ -144,6 +150,8 @@ export function useCanvasFlowReconciliation(args: { reconciliation.nextPendingLocalPositionPins; pendingLocalNodeDataUntilConvexMatchesRef.current = reconciliation.nextPendingLocalNodeDataPins; + pendingLocalNodeSizeUntilConvexMatchesRef.current = + reconciliation.nextPendingLocalNodeSizePins; for (const nodeId of reconciliation.clearedPreferLocalPositionNodeIds) { preferLocalPositionNodeIdsRef.current.delete(nodeId); } @@ -162,6 +170,7 @@ export function useCanvasFlowReconciliation(args: { pendingConnectionCreatesRef, pendingLocalPositionUntilConvexMatchesRef, pendingLocalNodeDataUntilConvexMatchesRef, + pendingLocalNodeSizeUntilConvexMatchesRef, preferLocalPositionNodeIdsRef, resolvedRealIdByClientRequestRef, ]); diff --git a/components/canvas/use-canvas-sync-engine.ts b/components/canvas/use-canvas-sync-engine.ts index 86ec50f..9d2e048 100644 --- a/components/canvas/use-canvas-sync-engine.ts +++ b/components/canvas/use-canvas-sync-engine.ts @@ -214,6 +214,9 @@ export function createCanvasSyncEngineController({ const pendingLocalNodeDataUntilConvexMatchesRef = { current: new Map(), }; + const pendingLocalNodeSizeUntilConvexMatchesRef = { + current: new Map(), + }; const preferLocalPositionNodeIdsRef = { current: new Set() }; const flushPendingResizeForClientRequest = async ( @@ -223,6 +226,10 @@ export function createCanvasSyncEngineController({ const pendingResize = pendingResizeAfterCreateRef.current.get(clientRequestId); if (!pendingResize) return; pendingResizeAfterCreateRef.current.delete(clientRequestId); + pendingLocalNodeSizeUntilConvexMatchesRef.current.delete( + `${OPTIMISTIC_NODE_PREFIX}${clientRequestId}`, + ); + pinNodeSizeLocally(realId as string, pendingResize); await getEnqueueSyncMutation()("resizeNode", { nodeId: realId, width: pendingResize.width, @@ -245,6 +252,25 @@ export function createCanvasSyncEngineController({ ); }; + const pinNodeSizeLocally = (nodeId: string, size: { width: number; height: number }): void => { + pendingLocalNodeSizeUntilConvexMatchesRef.current.set(nodeId, size); + const setNodes = getSetNodes?.(); + setNodes?.((current) => + current.map((node) => + node.id === nodeId + ? { + ...node, + style: { + ...(node.style ?? {}), + width: size.width, + height: size.height, + }, + } + : node, + ), + ); + }; + const flushPendingDataForClientRequest = async ( clientRequestId: string, realId: Id<"nodes">, @@ -265,6 +291,10 @@ export function createCanvasSyncEngineController({ height: number; }): Promise => { const rawNodeId = args.nodeId as string; + pinNodeSizeLocally(rawNodeId, { + width: args.width, + height: args.height, + }); if (!isOptimisticNodeId(rawNodeId) || !getIsSyncOnline()) { await getEnqueueSyncMutation()("resizeNode", args); return; @@ -276,6 +306,11 @@ export function createCanvasSyncEngineController({ : undefined; if (resolvedRealId) { + pendingLocalNodeSizeUntilConvexMatchesRef.current.delete(rawNodeId); + pinNodeSizeLocally(resolvedRealId as string, { + width: args.width, + height: args.height, + }); await getEnqueueSyncMutation()("resizeNode", { nodeId: resolvedRealId, width: args.width, @@ -337,6 +372,7 @@ export function createCanvasSyncEngineController({ pendingMoveAfterCreateRef.current.delete(clientRequestId); pendingResizeAfterCreateRef.current.delete(clientRequestId); pendingDataAfterCreateRef.current.delete(clientRequestId); + pendingLocalNodeSizeUntilConvexMatchesRef.current.delete(realId as string); pendingLocalNodeDataUntilConvexMatchesRef.current.delete(realId as string); pendingEdgeSplitByClientRequestRef.current.delete(clientRequestId); pendingConnectionCreatesRef.current.delete(clientRequestId); @@ -487,6 +523,7 @@ export function createCanvasSyncEngineController({ pendingConnectionCreatesRef, pendingLocalPositionUntilConvexMatchesRef, pendingLocalNodeDataUntilConvexMatchesRef, + pendingLocalNodeSizeUntilConvexMatchesRef, preferLocalPositionNodeIdsRef, flushPendingResizeForClientRequest, flushPendingDataForClientRequest, @@ -1858,6 +1895,8 @@ export function useCanvasSyncEngine({ controller.pendingLocalPositionUntilConvexMatchesRef, pendingLocalNodeDataUntilConvexMatchesRef: controller.pendingLocalNodeDataUntilConvexMatchesRef, + pendingLocalNodeSizeUntilConvexMatchesRef: + controller.pendingLocalNodeSizeUntilConvexMatchesRef, preferLocalPositionNodeIdsRef: controller.preferLocalPositionNodeIdsRef, pendingCreatePromiseByClientRequestRef, }, diff --git a/convex/ai_errors.ts b/convex/ai_errors.ts index 24e7b7d..87aa3d9 100644 --- a/convex/ai_errors.ts +++ b/convex/ai_errors.ts @@ -145,12 +145,40 @@ export function categorizeError(error: unknown): { export function formatTerminalStatusMessage(error: unknown): string { const code = getErrorCode(error); - if (code) { + if (code === "OPENROUTER_STRUCTURED_OUTPUT_INVALID_JSON") { + return "Provider: Strukturierte Antwort konnte nicht gelesen werden"; + } + + if (code === "OPENROUTER_STRUCTURED_OUTPUT_MISSING_CONTENT") { + return "Provider: Strukturierte Antwort fehlt"; + } + + if (code && code !== "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR") { return code; } - const message = errorMessage(error).trim() || "Generation failed"; - const { category } = categorizeError(error); + const convexData = + error instanceof ConvexError ? (error.data as ErrorData | undefined) : undefined; + + const convexDataMessage = + typeof convexData?.message === "string" ? convexData.message.trim() : ""; + const convexDataStatus = + typeof convexData?.status === "number" && Number.isFinite(convexData.status) + ? convexData.status + : null; + + const message = + code === "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR" + ? convexDataMessage || + (convexDataStatus !== null + ? `HTTP ${convexDataStatus}` + : "Anfrage fehlgeschlagen") + : errorMessage(error).trim() || "Generation failed"; + + const { category } = + code === "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR" + ? { category: "provider" as const } + : categorizeError(error); const prefixByCategory: Record, string> = { credits: "Credits", diff --git a/convex/openrouter.ts b/convex/openrouter.ts index 2e0e245..646edbd 100644 --- a/convex/openrouter.ts +++ b/convex/openrouter.ts @@ -2,6 +2,155 @@ import { ConvexError } from "convex/values"; export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; +function parseJsonSafely(text: string): + | { ok: true; value: unknown } + | { ok: false } { + try { + return { ok: true, value: JSON.parse(text) }; + } catch { + return { ok: false }; + } +} + +function extractTextFromStructuredContent(content: unknown): string | undefined { + if (typeof content === "string") { + return content; + } + + if (!Array.isArray(content)) { + return undefined; + } + + const textParts: string[] = []; + for (const part of content) { + if (typeof part === "string") { + textParts.push(part); + continue; + } + if (!part || typeof part !== "object") { + continue; + } + + const partRecord = part as Record; + if (typeof partRecord.text === "string") { + textParts.push(partRecord.text); + } + } + + return textParts.length > 0 ? textParts.join("") : undefined; +} + +function extractFencedJsonPayload(text: string): string | undefined { + const fencedBlockRegex = /```(?:json)?\s*([\s\S]*?)\s*```/gi; + let match: RegExpExecArray | null; + while ((match = fencedBlockRegex.exec(text)) !== null) { + const payload = match[1]; + if (typeof payload === "string" && payload.trim() !== "") { + return payload; + } + } + return undefined; +} + +function extractBalancedJsonCandidate(text: string, startIndex: number): string | undefined { + const startChar = text[startIndex]; + if (startChar !== "{" && startChar !== "[") { + return undefined; + } + + const expectedClosings: string[] = []; + let inString = false; + let isEscaped = false; + + for (let i = startIndex; i < text.length; i += 1) { + const ch = text[i]!; + + if (inString) { + if (isEscaped) { + isEscaped = false; + continue; + } + if (ch === "\\") { + isEscaped = true; + continue; + } + if (ch === '"') { + inString = false; + } + continue; + } + + if (ch === '"') { + inString = true; + continue; + } + + if (ch === "{") { + expectedClosings.push("}"); + continue; + } + if (ch === "[") { + expectedClosings.push("]"); + continue; + } + + if (ch === "}" || ch === "]") { + const expected = expectedClosings.pop(); + if (expected !== ch) { + return undefined; + } + if (expectedClosings.length === 0) { + return text.slice(startIndex, i + 1); + } + } + } + + return undefined; +} + +function extractFirstBalancedJson(text: string): string | undefined { + for (let i = 0; i < text.length; i += 1) { + const ch = text[i]!; + if (ch !== "{" && ch !== "[") { + continue; + } + + const candidate = extractBalancedJsonCandidate(text, i); + if (candidate) { + return candidate; + } + } + + return undefined; +} + +function parseStructuredJsonFromMessageContent(contentText: string): + | { ok: true; value: unknown } + | { ok: false } { + const direct = parseJsonSafely(contentText); + if (direct.ok) { + return direct; + } + + const fencedPayload = extractFencedJsonPayload(contentText); + if (fencedPayload) { + const fenced = parseJsonSafely(fencedPayload); + if (fenced.ok) { + return fenced; + } + } + + const balancedPayload = extractFirstBalancedJson(contentText); + if (balancedPayload) { + const balanced = parseJsonSafely(balancedPayload); + if (balanced.ok) { + return balanced; + } + } + + return { ok: false }; +} + export async function generateStructuredObjectViaOpenRouter( apiKey: string, args: { @@ -33,6 +182,7 @@ export async function generateStructuredObjectViaOpenRouter( schema: args.schema, }, }, + plugins: [{ id: "response-healing" }], }), }); @@ -46,21 +196,31 @@ export async function generateStructuredObjectViaOpenRouter( } const data = await response.json(); - const content = data?.choices?.[0]?.message?.content; + const message = data?.choices?.[0]?.message as + | Record + | undefined; - if (typeof content !== "string" || content.trim() === "") { + const parsed = message?.parsed; + if (parsed && typeof parsed === "object") { + return parsed as T; + } + + const contentText = extractTextFromStructuredContent(message?.content); + + if (typeof contentText !== "string" || contentText.trim() === "") { throw new ConvexError({ code: "OPENROUTER_STRUCTURED_OUTPUT_MISSING_CONTENT", }); } - try { - return JSON.parse(content) as T; - } catch { + const parsedContent = parseStructuredJsonFromMessageContent(contentText); + if (!parsedContent.ok) { throw new ConvexError({ code: "OPENROUTER_STRUCTURED_OUTPUT_INVALID_JSON", }); } + + return parsedContent.value as T; } export interface OpenRouterModel { diff --git a/tests/convex/ai-errors.test.ts b/tests/convex/ai-errors.test.ts index d61439f..81b7adf 100644 --- a/tests/convex/ai-errors.test.ts +++ b/tests/convex/ai-errors.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { ConvexError } from "convex/values"; import { FreepikApiError } from "@/convex/freepik"; import { categorizeError, @@ -31,6 +32,42 @@ describe("ai error helpers", () => { ); }); + it("formats structured-output invalid json with human-readable provider message", () => { + expect( + formatTerminalStatusMessage( + new ConvexError({ code: "OPENROUTER_STRUCTURED_OUTPUT_INVALID_JSON" }), + ), + ).toBe("Provider: Strukturierte Antwort konnte nicht gelesen werden"); + }); + + it("formats structured-output missing content with human-readable provider message", () => { + expect( + formatTerminalStatusMessage( + new ConvexError({ code: "OPENROUTER_STRUCTURED_OUTPUT_MISSING_CONTENT" }), + ), + ).toBe("Provider: Strukturierte Antwort fehlt"); + }); + + it("formats structured-output http error with provider prefix and server message", () => { + expect( + formatTerminalStatusMessage( + new ConvexError({ + code: "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR", + status: 503, + message: "OpenRouter API error 503: Upstream timeout", + }), + ), + ).toBe("Provider: OpenRouter API error 503: Upstream timeout"); + }); + + it("formats structured-output http error without falling back to raw code", () => { + expect( + formatTerminalStatusMessage( + new ConvexError({ code: "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR" }), + ), + ).toBe("Provider: Anfrage fehlgeschlagen"); + }); + it("uses staged poll delays", () => { expect(getVideoPollDelayMs(1)).toBe(5000); expect(getVideoPollDelayMs(9)).toBe(10000); diff --git a/tests/convex/openrouter-structured-output.test.ts b/tests/convex/openrouter-structured-output.test.ts index c16e4b3..8c1301a 100644 --- a/tests/convex/openrouter-structured-output.test.ts +++ b/tests/convex/openrouter-structured-output.test.ts @@ -104,9 +104,108 @@ describe("generateStructuredObjectViaOpenRouter", () => { schema, }, }, + plugins: [{ id: "response-healing" }], }); }); + it("parses content when provider returns array text parts", async () => { + fetchMock.mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + json: { + choices: [ + { + message: { + content: [ + { type: "text", text: '{"title": "Lemon"' }, + { type: "text", text: ', "confidence": 0.75}' }, + ], + }, + }, + ], + }, + }), + ); + + const result = await generateStructuredObjectViaOpenRouter<{ + title: string; + confidence: number; + }>("test-api-key", { + model: "openai/gpt-5-mini", + messages: [{ role: "user", content: "hello" }], + schemaName: "test_schema", + schema: { type: "object" }, + }); + + expect(result).toEqual({ title: "Lemon", confidence: 0.75 }); + }); + + it("parses fenced json content", async () => { + fetchMock.mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + json: { + choices: [ + { + message: { + content: + "Here is the result:\n```json\n{\n \"title\": \"LemonSpace\",\n \"confidence\": 0.88\n}\n```\nThanks.", + }, + }, + ], + }, + }), + ); + + const result = await generateStructuredObjectViaOpenRouter<{ + title: string; + confidence: number; + }>("test-api-key", { + model: "openai/gpt-5-mini", + messages: [{ role: "user", content: "hello" }], + schemaName: "test_schema", + schema: { type: "object" }, + }); + + expect(result).toEqual({ title: "LemonSpace", confidence: 0.88 }); + }); + + it("returns message.parsed directly when provided", async () => { + fetchMock.mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + json: { + choices: [ + { + message: { + parsed: { + title: "Parsed Result", + confidence: 0.99, + }, + content: "not valid json", + }, + }, + ], + }, + }), + ); + + const result = await generateStructuredObjectViaOpenRouter<{ + title: string; + confidence: number; + }>("test-api-key", { + model: "openai/gpt-5-mini", + messages: [{ role: "user", content: "hello" }], + schemaName: "test_schema", + schema: { type: "object" }, + }); + + expect(result).toEqual({ title: "Parsed Result", confidence: 0.99 }); + }); + it("throws ConvexError code when response content is missing", async () => { fetchMock.mockResolvedValueOnce( createMockResponse({ diff --git a/tests/use-pipeline-preview.test.ts b/tests/use-pipeline-preview.test.ts index 3c5f13d..6545edc 100644 --- a/tests/use-pipeline-preview.test.ts +++ b/tests/use-pipeline-preview.test.ts @@ -1046,4 +1046,144 @@ describe("preview histogram call sites", () => { }), ); }); + + it("prefers preview aspect ratio for RenderNode resize when pipeline contains crop", async () => { + const queueNodeResize = vi.fn(async () => undefined); + + vi.doMock("@/hooks/use-pipeline-preview", () => ({ + usePipelinePreview: () => ({ + canvasRef: { current: null }, + histogram: emptyHistogram(), + isRendering: false, + hasSource: true, + previewAspectRatio: 1, + error: null, + }), + })); + vi.doMock("@xyflow/react", () => ({ + Handle: () => null, + Position: { Left: "left", Right: "right" }, + })); + vi.doMock("convex/react", () => ({ + useMutation: () => vi.fn(async () => undefined), + })); + vi.doMock("lucide-react", () => ({ + AlertCircle: () => null, + ArrowDown: () => null, + CheckCircle2: () => null, + CloudUpload: () => null, + Loader2: () => null, + Maximize2: () => null, + X: () => null, + })); + vi.doMock("@/components/canvas/nodes/base-node-wrapper", () => ({ + default: ({ children }: { children: React.ReactNode }) => createElement("div", null, children), + })); + vi.doMock("@/components/canvas/nodes/adjustment-controls", () => ({ + SliderRow: () => null, + })); + vi.doMock("@/components/ui/select", () => ({ + Select: ({ children }: { children: React.ReactNode }) => createElement("div", null, children), + SelectContent: ({ children }: { children: React.ReactNode }) => createElement("div", null, children), + SelectItem: ({ children }: { children: React.ReactNode }) => createElement("div", null, children), + SelectTrigger: ({ children }: { children: React.ReactNode }) => createElement("div", null, children), + SelectValue: () => null, + })); + vi.doMock("@/components/canvas/canvas-sync-context", () => ({ + useCanvasSync: () => ({ + queueNodeDataUpdate: vi.fn(async () => undefined), + queueNodeResize, + status: { isOffline: false }, + }), + })); + vi.doMock("@/hooks/use-debounced-callback", () => ({ + useDebouncedCallback: (callback: () => void) => callback, + })); + vi.doMock("@/components/canvas/canvas-graph-context", () => ({ + useCanvasGraph: () => ({ + nodes: [], + edges: [], + previewNodeDataOverrides: new Map(), + }), + })); + vi.doMock("@/lib/canvas-render-preview", () => ({ + resolveRenderPreviewInputFromGraph: () => ({ + sourceUrl: "https://cdn.example.com/source.png", + steps: [ + { + nodeId: "crop-1", + type: "crop", + params: { cropRect: { x: 0.1, y: 0.1, width: 0.8, height: 0.8 } }, + }, + ], + }), + findSourceNodeFromGraph: () => ({ + id: "image-1", + type: "image", + data: { width: 1200, height: 800 }, + }), + shouldFastPathPreviewPipeline: () => false, + })); + vi.doMock("@/lib/canvas-utils", () => ({ + resolveMediaAspectRatio: () => null, + })); + vi.doMock("@/lib/image-formats", () => ({ + parseAspectRatioString: () => ({ w: 1, h: 1 }), + })); + vi.doMock("@/lib/image-pipeline/contracts", async () => { + const actual = await vi.importActual( + "@/lib/image-pipeline/contracts", + ); + return { + ...actual, + hashPipeline: () => "pipeline-hash", + }; + }); + vi.doMock("@/lib/image-pipeline/worker-client", () => ({ + isPipelineAbortError: () => false, + renderFullWithWorkerFallback: vi.fn(), + })); + vi.doMock("@/components/ui/dialog", () => ({ + Dialog: ({ children }: { children: React.ReactNode }) => createElement("div", null, children), + DialogContent: ({ children }: { children: React.ReactNode }) => createElement("div", null, children), + DialogTitle: ({ children }: { children: React.ReactNode }) => createElement("div", null, children), + })); + + const renderNodeModule = await import("@/components/canvas/nodes/render-node"); + const RenderNode = renderNodeModule.default; + + await act(async () => { + root?.render( + createElement(RenderNode, { + id: "render-1", + data: {}, + selected: false, + dragging: false, + zIndex: 0, + isConnectable: true, + type: "render", + xPos: 0, + yPos: 0, + width: 450, + height: 300, + sourcePosition: undefined, + targetPosition: undefined, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + } as never), + ); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(queueNodeResize).toHaveBeenCalledWith( + expect.objectContaining({ + nodeId: "render-1", + width: 450, + height: 450, + }), + ); + }); });