mirror of
https://github.com/run-llama/llama-ui.git
synced 2026-08-24 19:23:15 -04:00
SSE (#89)
* refact: Add better SSE support, so you can debug network frames * test changes * ws dep * clean lints * tests * add changeset * no ci change * no bump * nor that * onfinish * back out of compat * fix storybook, and reduce completion delay --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@llamaindex/ui": patch
|
||||
---
|
||||
|
||||
Use SSE for streaming handler events
|
||||
@@ -20,6 +20,10 @@ const config: StorybookConfig = {
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
"@": resolve(__dirname, "../"),
|
||||
"@llamaindex/workflows-client": resolve(
|
||||
__dirname,
|
||||
"../../workflows-client/src"
|
||||
),
|
||||
// Fix react-pdf compatibility with React 19
|
||||
react: resolve(__dirname, "../node_modules/react"),
|
||||
"react-dom": resolve(__dirname, "../node_modules/react-dom"),
|
||||
|
||||
@@ -14,6 +14,32 @@ const createdHandlers = new Map<
|
||||
}
|
||||
>();
|
||||
|
||||
// Track completed SSE streams to prevent infinite reconnections
|
||||
const completedStreams = new Set<string>();
|
||||
|
||||
// Helper function to mark a handler as completed
|
||||
function completeHandler(handlerId: string) {
|
||||
const handler = createdHandlers.get(handlerId);
|
||||
if (handler && handler.status === "running") {
|
||||
handler.status = "completed";
|
||||
handler.result = {
|
||||
message: "Workflow completed successfully",
|
||||
processed_files: Math.floor(Math.random() * 5) + 1,
|
||||
extracted_data: {
|
||||
total_items: Math.floor(Math.random() * 100) + 10,
|
||||
high_confidence: Math.floor(Math.random() * 80) + 10,
|
||||
low_confidence: Math.floor(Math.random() * 20),
|
||||
},
|
||||
};
|
||||
// Also mark stream as completed to prevent reconnections
|
||||
completedStreams.add(handlerId);
|
||||
console.log(
|
||||
`MSW: Handler ${handlerId} completed with result:`,
|
||||
handler.result
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Mock agent data for item grid
|
||||
const mockAgentData = Array.from({ length: 50 }, (_, index) => ({
|
||||
id: `item-${index + 1}`,
|
||||
@@ -142,14 +168,13 @@ export const handlers = {
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Remove from completed streams if it was previously completed (for retriggers)
|
||||
completedStreams.delete(handlerId);
|
||||
|
||||
// Simulate completion after some time
|
||||
setTimeout(() => {
|
||||
const handler = createdHandlers.get(handlerId);
|
||||
if (handler && handler.status === "running") {
|
||||
handler.status = "completed";
|
||||
handler.result = { message: "Workflow completed successfully" };
|
||||
}
|
||||
}, 10000); // Complete after 10 seconds
|
||||
completeHandler(handlerId);
|
||||
}, 10000); // Complete after 10 seconds, whether or not the events are subscribed to
|
||||
|
||||
// Return the response format matching Python server
|
||||
const response = {
|
||||
@@ -165,15 +190,33 @@ export const handlers = {
|
||||
}
|
||||
),
|
||||
|
||||
// Mock handler events streaming
|
||||
// Mock handler events streaming for EventSource (SSE)
|
||||
http.get("*/events/:handler_id", async (info) => {
|
||||
const { handler_id: handlerId } = info.params;
|
||||
const url = new URL(info.request.url);
|
||||
const isSSE = url.searchParams.get("sse") === "true";
|
||||
|
||||
console.log("MSW: Intercepted handler events request", {
|
||||
params: info.params,
|
||||
handlerId,
|
||||
url: info.request.url,
|
||||
isSSE,
|
||||
});
|
||||
|
||||
if (!isSSE) {
|
||||
// Non-SSE request, return empty for now
|
||||
return HttpResponse.json([]);
|
||||
}
|
||||
|
||||
const handlerIdString = (handlerId ?? "") as string;
|
||||
// Check if this stream has already completed - return 204 to stop reconnections
|
||||
if (completedStreams.has(handlerIdString)) {
|
||||
console.log(
|
||||
`MSW: Stream for handler ${handlerId} already completed, returning 204 to stop reconnection`
|
||||
);
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
// Create a readable stream for Server-Sent Events
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
@@ -237,6 +280,11 @@ export const handlers = {
|
||||
|
||||
const sendNextEvent = () => {
|
||||
if (eventsSent >= events.length) {
|
||||
console.log(
|
||||
`MSW: All events sent for handler ${handlerId}, completing handler and closing stream`
|
||||
);
|
||||
// Complete the handler (sets status, result, and marks stream as completed)
|
||||
completeHandler(handlerIdString);
|
||||
setTimeout(() => controller.close(), 100);
|
||||
return;
|
||||
}
|
||||
@@ -252,9 +300,10 @@ export const handlers = {
|
||||
`MSW: Sending event ${eventsSent + 1}/${events.length}:`,
|
||||
rawEvent
|
||||
);
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(JSON.stringify(rawEvent) + "\n")
|
||||
);
|
||||
|
||||
// Proper SSE format: "data: " + JSON + "\n\n"
|
||||
const sseMessage = `data: ${JSON.stringify(rawEvent)}\n\n`;
|
||||
controller.enqueue(new TextEncoder().encode(sseMessage));
|
||||
|
||||
eventsSent++;
|
||||
setTimeout(sendNextEvent, 1000); // Send next event after 1000ms
|
||||
@@ -267,7 +316,7 @@ export const handlers = {
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
@@ -297,6 +346,35 @@ export const handlers = {
|
||||
return HttpResponse.json({ handlers: allHandlers });
|
||||
}),
|
||||
|
||||
// Mock get results by handler ID
|
||||
http.get("*/results/:handler_id", async ({ params }) => {
|
||||
const { handler_id: handlerId } = params;
|
||||
console.log(
|
||||
"MSW: Intercepted get results request for handler:",
|
||||
handlerId
|
||||
);
|
||||
|
||||
const handler = createdHandlers.get(handlerId as string);
|
||||
|
||||
if (!handler) {
|
||||
console.log(`MSW: Handler ${handlerId} not found`);
|
||||
return new HttpResponse("Handler not found", { status: 404 });
|
||||
}
|
||||
|
||||
if (handler.status === "completed" && handler.result !== null) {
|
||||
console.log(
|
||||
`MSW: Returning result for completed handler ${handlerId}:`,
|
||||
handler.result
|
||||
);
|
||||
return HttpResponse.json({ result: handler.result });
|
||||
} else {
|
||||
console.log(
|
||||
`MSW: Result not ready yet for handler ${handlerId} (status: ${handler.status})`
|
||||
);
|
||||
return HttpResponse.json({}, { status: 202 });
|
||||
}
|
||||
}),
|
||||
|
||||
// Mock post event to handler
|
||||
http.post("*/events/:handler_id", async ({ params, request }) => {
|
||||
const { handler_id: handlerId } = params;
|
||||
@@ -463,3 +541,11 @@ export const handlers = {
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
// Export a function to reset mock state (useful for story switching)
|
||||
export function resetMockState() {
|
||||
createdHandlers.clear();
|
||||
completedStreams.clear();
|
||||
taskCounter = 1;
|
||||
console.log("MSW: Mock state reset");
|
||||
}
|
||||
|
||||
@@ -348,6 +348,7 @@
|
||||
"eslint-plugin-react-hooks": "^5",
|
||||
"glob": "^11.0.3",
|
||||
"jsdom": "^26.1.0",
|
||||
"@llamaindex/workflows-client": "workspace:*",
|
||||
"llama-cloud-services": "^0.3.6",
|
||||
"msw": "^2.10.2",
|
||||
"msw-storybook-addon": "^2.0.5",
|
||||
|
||||
@@ -62,7 +62,7 @@ export function useWorkflowHandler(
|
||||
event,
|
||||
});
|
||||
},
|
||||
[handlerId, client, sendEventToHandler]
|
||||
[handlerId, client]
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
import { Client } from "@llamaindex/workflows-client";
|
||||
import type { Client } from "@llamaindex/workflows-client";
|
||||
import { workflowStreamingManager } from "../../lib/shared-streaming";
|
||||
import {
|
||||
createHandler as createHandlerAPI,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
Client,
|
||||
type Client,
|
||||
postWorkflowsByNameRunNowait,
|
||||
getHandlers,
|
||||
postEventsByHandlerId,
|
||||
getEventsByHandlerId,
|
||||
getResultsByHandlerId,
|
||||
} from "@llamaindex/workflows-client";
|
||||
import {
|
||||
RawEvent,
|
||||
@@ -93,76 +93,52 @@ export async function fetchHandlerEvents<E extends WorkflowEvent>(
|
||||
subscriber: StreamSubscriber<E>,
|
||||
signal: AbortSignal
|
||||
): Promise<E[]> => {
|
||||
const resp = await getEventsByHandlerId({
|
||||
client: params.client,
|
||||
path: { handler_id: params.handlerId },
|
||||
// NDJSON stream
|
||||
query: { sse: false },
|
||||
// Ensure we get a ReadableStream without auto-parsing
|
||||
parseAs: "stream",
|
||||
});
|
||||
const response = resp.response;
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(`HTTP error! status: ${response.status}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error("No reader available");
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
subscriber.onStart?.();
|
||||
const accumulatedEvents: E[] = [];
|
||||
let retryParsedLines: string[] = [];
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (signal.aborted) {
|
||||
throw new Error("Stream aborted");
|
||||
}
|
||||
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
let chunk = decoder.decode(value, { stream: true });
|
||||
|
||||
if (retryParsedLines.length > 0) {
|
||||
// if there are lines that failed to parse, append them to the current chunk
|
||||
chunk = `${retryParsedLines.join("")}${chunk}`;
|
||||
retryParsedLines = []; // reset for next iteration
|
||||
}
|
||||
|
||||
const { events, failedLines } = toWorkflowEvents<E>(chunk);
|
||||
retryParsedLines.push(...failedLines);
|
||||
|
||||
if (!events.length) continue;
|
||||
|
||||
accumulatedEvents.push(...events);
|
||||
|
||||
// Send events to SharedStreamingManager subscriber
|
||||
events.forEach((event) => subscriber.onData?.(event));
|
||||
|
||||
const stopEvent = events.find(
|
||||
(event) => event.type === WorkflowEventType.StopEvent.toString()
|
||||
);
|
||||
if (stopEvent) {
|
||||
// For compatibility with existing callback interface
|
||||
if (callback?.onStopEvent) {
|
||||
callback.onStopEvent(stopEvent);
|
||||
}
|
||||
break; // Stop event received, end the stream
|
||||
}
|
||||
const onMessage = (event: RawEvent): boolean => {
|
||||
const workflowEvent = {
|
||||
type: event.qualified_name,
|
||||
data: event.value,
|
||||
} as E;
|
||||
accumulatedEvents.push(workflowEvent);
|
||||
try {
|
||||
subscriber.onData?.(workflowEvent);
|
||||
} catch (error) {
|
||||
console.error("Error in subscriber onData:", error); // eslint-disable-line no-console
|
||||
}
|
||||
const stopEvent = [workflowEvent].find(
|
||||
(event) => event.type === WorkflowEventType.StopEvent.toString()
|
||||
);
|
||||
if (stopEvent) {
|
||||
// For compatibility with existing callback interface
|
||||
if (callback?.onStopEvent) {
|
||||
callback.onStopEvent(stopEvent);
|
||||
}
|
||||
return true; // Stop event received, end the stream
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Notify completion
|
||||
subscriber.onFinish?.(accumulatedEvents);
|
||||
const baseUrl = (params.client.getConfig().baseUrl ?? "").replace(
|
||||
/\/$/,
|
||||
""
|
||||
);
|
||||
const eventSource = new EventSource(
|
||||
`${baseUrl}/events/${encodeURIComponent(params.handlerId)}?sse=true`,
|
||||
{
|
||||
withCredentials: true,
|
||||
}
|
||||
);
|
||||
|
||||
return accumulatedEvents;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
await processUntilClosed(eventSource, onMessage, signal, () =>
|
||||
// EventSource does not complete until the backoff reconnect gets a 204. Proactively check for completion.
|
||||
getResultsByHandlerId({
|
||||
client: params.client,
|
||||
path: { handler_id: params.handlerId },
|
||||
}).then((res) => (res.data?.result ?? null) !== null)
|
||||
);
|
||||
subscriber.onFinish?.(accumulatedEvents);
|
||||
return accumulatedEvents;
|
||||
};
|
||||
|
||||
// Convert callback to SharedStreamingManager subscriber
|
||||
@@ -203,67 +179,6 @@ export async function sendEventToHandler<E extends WorkflowEvent>(params: {
|
||||
return data.data;
|
||||
}
|
||||
|
||||
function toWorkflowEvents<E extends WorkflowEvent>(
|
||||
chunk: string
|
||||
): {
|
||||
events: E[];
|
||||
failedLines: string[];
|
||||
} {
|
||||
if (typeof chunk !== "string") {
|
||||
return { events: [], failedLines: [] };
|
||||
}
|
||||
|
||||
// One chunk can contain multiple events, so we need to parse each line
|
||||
const lines = chunk
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim() !== "");
|
||||
|
||||
const parsedLines = lines
|
||||
.map((line) => parseChunkLine<E>(line))
|
||||
.filter(Boolean);
|
||||
|
||||
// successfully parsed events
|
||||
const events = parsedLines.map((line) => line?.event).filter(Boolean) as E[];
|
||||
|
||||
// failed lines that could not be parsed into events
|
||||
// will be merged into next chunk to re-try parsing
|
||||
const failedLines = parsedLines
|
||||
.filter((l) => !l?.event)
|
||||
.map((line) => line?.line || "")
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
events,
|
||||
failedLines,
|
||||
};
|
||||
}
|
||||
|
||||
function parseChunkLine<E extends WorkflowEvent>(
|
||||
line: string
|
||||
): {
|
||||
line: string;
|
||||
event?: E | null;
|
||||
} | null {
|
||||
try {
|
||||
const event = JSON.parse(line) as RawEvent;
|
||||
if (!isRawEvent(event)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
line,
|
||||
event: { type: event.qualified_name, data: event.value } as E,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
// eslint-disable-next-line no-console -- needed
|
||||
console.error(`Failed to parse chunk in line: ${line}`, error);
|
||||
return {
|
||||
line,
|
||||
event: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function toRawEvent(event: WorkflowEvent): RawEvent {
|
||||
return {
|
||||
__is_pydantic: true,
|
||||
@@ -272,13 +187,61 @@ function toRawEvent(event: WorkflowEvent): RawEvent {
|
||||
};
|
||||
}
|
||||
|
||||
function isRawEvent(event: object): event is RawEvent {
|
||||
return (
|
||||
event &&
|
||||
typeof event === "object" &&
|
||||
"__is_pydantic" in event &&
|
||||
"value" in event &&
|
||||
"qualified_name" in event &&
|
||||
typeof event.qualified_name === "string"
|
||||
);
|
||||
function processUntilClosed(
|
||||
eventSource: EventSource,
|
||||
callback: (event: RawEvent) => boolean,
|
||||
abortSignal: AbortSignal,
|
||||
checkComplete?: () => Promise<boolean>
|
||||
): Promise<void> {
|
||||
let resolve: () => void = () => {};
|
||||
const onAbort = () => {
|
||||
eventSource.close();
|
||||
resolve();
|
||||
};
|
||||
const promise = new Promise<void>((_resolve) => {
|
||||
resolve = _resolve;
|
||||
});
|
||||
|
||||
abortSignal.addEventListener("abort", onAbort);
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
try {
|
||||
if (callback(JSON.parse(event.data) as RawEvent)) {
|
||||
eventSource.close();
|
||||
resolve();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Unexpected error in processUntilClosed callback:", error); // eslint-disable-line no-console
|
||||
}
|
||||
};
|
||||
eventSource.addEventListener("message", onMessage);
|
||||
// this error event is really noisy. Fires during reconnects, which is up to the browser, and pretty frequent.
|
||||
// Will reconnect until it gets a 204 response or manually closed.
|
||||
let checkCompletePromise: Promise<boolean> | null = null;
|
||||
let lastCheckTime = 0;
|
||||
const onError = (_: Event) => {
|
||||
if (eventSource.readyState == EventSource.CLOSED) {
|
||||
resolve();
|
||||
}
|
||||
// only check up to every 10 seconds
|
||||
const now = Date.now();
|
||||
if (!checkCompletePromise && checkComplete && now - lastCheckTime > 10000) {
|
||||
lastCheckTime = now;
|
||||
checkCompletePromise = checkComplete();
|
||||
}
|
||||
checkCompletePromise?.then((complete) => {
|
||||
if (complete) {
|
||||
eventSource.close();
|
||||
resolve();
|
||||
} else {
|
||||
checkCompletePromise = null;
|
||||
}
|
||||
});
|
||||
};
|
||||
eventSource.addEventListener("error", onError);
|
||||
|
||||
return promise.then(() => {
|
||||
eventSource.removeEventListener("message", onMessage);
|
||||
eventSource.removeEventListener("error", onError);
|
||||
abortSignal.removeEventListener("abort", onAbort);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { within, waitFor, expect } from "@storybook/test";
|
||||
import { WorkflowProgressBar } from "../src/workflows";
|
||||
import { ApiProvider, createMockClients } from "../src/lib";
|
||||
import { __setHandlerStoreState } from "../src/workflows/hooks/use-handler-store";
|
||||
|
||||
import {
|
||||
__setHandlerStoreState,
|
||||
useHandlerStore,
|
||||
} from "../src/workflows/hooks/use-handler-store";
|
||||
import { resetMockState } from "../.storybook/mocks/handlers";
|
||||
const meta: Meta<typeof WorkflowProgressBar> = {
|
||||
title: "Components/WorkflowProgressBar",
|
||||
component: WorkflowProgressBar,
|
||||
@@ -12,16 +15,30 @@ const meta: Meta<typeof WorkflowProgressBar> = {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<ApiProvider clients={createMockClients()}>
|
||||
<div style={{ padding: "16px", width: "100%" }}>
|
||||
<Story />
|
||||
</div>
|
||||
</ApiProvider>
|
||||
),
|
||||
(Story) => {
|
||||
return (
|
||||
<ApiProvider clients={createMockClients()}>
|
||||
<Resetter />
|
||||
<div style={{ padding: "16px", width: "100%" }}>
|
||||
<Story />
|
||||
</div>
|
||||
</ApiProvider>
|
||||
);
|
||||
},
|
||||
],
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
/**
|
||||
* Resets global/mock state between stories
|
||||
*/
|
||||
const Resetter = () => {
|
||||
resetMockState();
|
||||
const clear = useHandlerStore((x) => x.clearCompleted);
|
||||
useEffect(() => {
|
||||
clear();
|
||||
}, []);
|
||||
return null;
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
@@ -143,53 +143,47 @@ describe("Helper Functions Tests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchHandlerEvents (NDJSON streaming)", () => {
|
||||
function makeReaderFromChunks(chunks: string[]) {
|
||||
let index = 0;
|
||||
return {
|
||||
read: async () => {
|
||||
if (index < chunks.length) {
|
||||
const value = new TextEncoder().encode(chunks[index++]);
|
||||
return { done: false, value } as const;
|
||||
}
|
||||
return { done: true, value: undefined } as const;
|
||||
},
|
||||
releaseLock: vi.fn(),
|
||||
describe("fetchHandlerEvents (streaming EventSource)", () => {
|
||||
afterEach(() => {
|
||||
// Cleanup EventSource if we set it
|
||||
delete (globalThis as any).EventSource;
|
||||
});
|
||||
class MockEventSource {
|
||||
url: string;
|
||||
listeners: Record<string, Set<(e: any) => void>> = {
|
||||
message: new Set(),
|
||||
error: new Set(),
|
||||
};
|
||||
static CLOSED = 2;
|
||||
readyState = 0;
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
// expose instance for test to emit
|
||||
(MockEventSource as any).last = this;
|
||||
}
|
||||
addEventListener(type: string, cb: (e: any) => void) {
|
||||
this.listeners[type]?.add(cb);
|
||||
}
|
||||
removeEventListener(type: string, cb: (e: any) => void) {
|
||||
this.listeners[type]?.delete(cb);
|
||||
}
|
||||
close() {
|
||||
this.readyState = MockEventSource.CLOSED;
|
||||
}
|
||||
emit(type: "message" | "error", data: any) {
|
||||
const payload = type === "message" ? { data } : { data };
|
||||
for (const cb of this.listeners[type] ?? []) cb(payload);
|
||||
}
|
||||
}
|
||||
|
||||
it("streams NDJSON, emits onData, stops on StopEvent", async () => {
|
||||
const { getEventsByHandlerId } = await import(
|
||||
"@llamaindex/workflows-client"
|
||||
it("emits events via EventSource and stops on StopEvent", async () => {
|
||||
(globalThis as any).EventSource = MockEventSource;
|
||||
|
||||
const { fetchHandlerEvents } = await import(
|
||||
"../../../src/workflows/store/helper"
|
||||
);
|
||||
|
||||
const ndjsonLines = [
|
||||
JSON.stringify({
|
||||
__is_pydantic: true,
|
||||
value: { step: "step1" },
|
||||
qualified_name: "workflow.step.start",
|
||||
}) + "\n",
|
||||
JSON.stringify({
|
||||
__is_pydantic: true,
|
||||
value: { step: "step1", result: "success" },
|
||||
qualified_name: "workflow.step.complete",
|
||||
}) + "\n",
|
||||
JSON.stringify({
|
||||
__is_pydantic: true,
|
||||
value: {},
|
||||
qualified_name: "workflow.events.StopEvent",
|
||||
}) + "\n",
|
||||
];
|
||||
|
||||
vi.mocked(getEventsByHandlerId as any).mockResolvedValue({
|
||||
data: undefined,
|
||||
response: {
|
||||
ok: true,
|
||||
body: { getReader: () => makeReaderFromChunks(ndjsonLines) },
|
||||
},
|
||||
} as any);
|
||||
|
||||
// Make subscribe call executor directly to exercise parsing
|
||||
// Execute executor directly through subscribe
|
||||
vi.mocked(workflowStreamingManager.subscribe).mockImplementation(
|
||||
(
|
||||
_key: string,
|
||||
@@ -212,106 +206,44 @@ describe("Helper Functions Tests", () => {
|
||||
onStopEvent: vi.fn(),
|
||||
};
|
||||
|
||||
const { fetchHandlerEvents } = await import(
|
||||
"../../../src/workflows/store/helper"
|
||||
);
|
||||
|
||||
const result = await fetchHandlerEvents(
|
||||
{
|
||||
client: mockClient,
|
||||
handlerId: "handler-123",
|
||||
},
|
||||
const promise = fetchHandlerEvents(
|
||||
{ client: mockClient, handlerId: "handler-ES" },
|
||||
mockCallback
|
||||
);
|
||||
|
||||
// Emit messages from EventSource
|
||||
const es: MockEventSource = (MockEventSource as any).last;
|
||||
const mk = (obj: any) => JSON.stringify(obj);
|
||||
es.emit(
|
||||
"message",
|
||||
mk({
|
||||
__is_pydantic: true,
|
||||
value: { step: "a" },
|
||||
qualified_name: "workflow.step.start",
|
||||
})
|
||||
);
|
||||
es.emit(
|
||||
"message",
|
||||
mk({
|
||||
__is_pydantic: true,
|
||||
value: { step: "a", done: true },
|
||||
qualified_name: "workflow.step.complete",
|
||||
})
|
||||
);
|
||||
es.emit(
|
||||
"message",
|
||||
mk({
|
||||
__is_pydantic: true,
|
||||
value: {},
|
||||
qualified_name: "workflow.events.StopEvent",
|
||||
})
|
||||
);
|
||||
|
||||
const result = await promise;
|
||||
|
||||
expect(mockCallback.onData).toHaveBeenCalledTimes(3);
|
||||
expect(mockCallback.onStopEvent).toHaveBeenCalledTimes(1);
|
||||
expect(mockCallback.onFinish).toHaveBeenCalledWith(result);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("supports AbortSignal to cancel stream", async () => {
|
||||
const { getEventsByHandlerId } = await import(
|
||||
"@llamaindex/workflows-client"
|
||||
);
|
||||
|
||||
// Mock an aborted signal
|
||||
const abortController = new AbortController();
|
||||
abortController.abort(); // Abort immediately
|
||||
|
||||
vi.mocked(getEventsByHandlerId as any).mockResolvedValue({
|
||||
data: undefined,
|
||||
response: {
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: async () => {
|
||||
// Simulate reading being interrupted by abort
|
||||
throw new Error("Stream aborted");
|
||||
},
|
||||
releaseLock: vi.fn(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
vi.mocked(workflowStreamingManager.subscribe).mockImplementation(
|
||||
(
|
||||
_key: string,
|
||||
subscriber: any,
|
||||
executor: any,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const promise = executor(
|
||||
subscriber,
|
||||
signal ?? new AbortController().signal
|
||||
);
|
||||
return { promise, unsubscribe: vi.fn() } as any;
|
||||
}
|
||||
);
|
||||
|
||||
const { fetchHandlerEvents } = await import(
|
||||
"../../../src/workflows/store/helper"
|
||||
);
|
||||
|
||||
await expect(
|
||||
fetchHandlerEvents({
|
||||
client: mockClient,
|
||||
handlerId: "handler-123",
|
||||
signal: abortController.signal,
|
||||
})
|
||||
).rejects.toThrow("Stream aborted");
|
||||
});
|
||||
|
||||
it("propagates errors from network", async () => {
|
||||
const { getEventsByHandlerId } = await import(
|
||||
"@llamaindex/workflows-client"
|
||||
);
|
||||
|
||||
vi.mocked(getEventsByHandlerId as any).mockResolvedValue({
|
||||
data: undefined,
|
||||
response: { ok: false, status: 500 },
|
||||
} as any);
|
||||
|
||||
vi.mocked(workflowStreamingManager.subscribe).mockImplementation(
|
||||
(
|
||||
_key: string,
|
||||
_subscriber: any,
|
||||
executor: any,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const promise = executor({}, signal ?? new AbortController().signal);
|
||||
return { promise, unsubscribe: vi.fn() } as any;
|
||||
}
|
||||
);
|
||||
|
||||
const { fetchHandlerEvents } = await import(
|
||||
"../../../src/workflows/store/helper"
|
||||
);
|
||||
|
||||
await expect(
|
||||
fetchHandlerEvents({ client: mockClient, handlerId: "handler-500" })
|
||||
).rejects.toThrow();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"baseUrl": ".",
|
||||
"outDir": "./dist",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": ["./*"],
|
||||
"@llamaindex/workflows-client": ["../workflows-client/src"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
|
||||
@@ -43,6 +43,10 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "."),
|
||||
"@llamaindex/workflows-client": path.resolve(
|
||||
__dirname,
|
||||
"../workflows-client/src"
|
||||
),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
@@ -82,6 +86,10 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "."),
|
||||
"@llamaindex/workflows-client": path.resolve(
|
||||
__dirname,
|
||||
"../workflows-client/src"
|
||||
),
|
||||
},
|
||||
},
|
||||
define: {
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
|
||||
// Re-export everything from generated code
|
||||
export * from './generated';
|
||||
export { createClient, createConfig, Client, Config } from './generated/client';
|
||||
export { createClient, createConfig } from './generated/client';
|
||||
export type { Client, Config } from './generated/client';
|
||||
export { client } from './generated/client.gen';
|
||||
|
||||
// Export version from package.json
|
||||
|
||||
Generated
+3
-9
@@ -72,9 +72,6 @@ importers:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.1.1
|
||||
version: 5.1.1(react-hook-form@7.60.0(react@18.3.1))
|
||||
'@llamaindex/workflows-client':
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
'@radix-ui/react-accordion':
|
||||
specifier: ^1.2.11
|
||||
version: 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -190,6 +187,9 @@ importers:
|
||||
'@chromatic-com/storybook':
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1(storybook@9.1.3(@testing-library/dom@10.4.0)(msw@2.10.4(@types/node@20.19.8)(typescript@5.8.3))(prettier@3.6.2)(vite@6.3.5(@types/node@20.19.8)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0)))
|
||||
'@llamaindex/workflows-client':
|
||||
specifier: workspace:*
|
||||
version: link:../workflows-client
|
||||
'@storybook/addon-a11y':
|
||||
specifier: ^9.1.3
|
||||
version: 9.1.3(storybook@9.1.3(@testing-library/dom@10.4.0)(msw@2.10.4(@types/node@20.19.8)(typescript@5.8.3))(prettier@3.6.2)(vite@6.3.5(@types/node@20.19.8)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0)))
|
||||
@@ -1038,10 +1038,6 @@ packages:
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
'@llamaindex/workflows-client@1.2.0':
|
||||
resolution: {integrity: sha512-TXre1LeVsIPMwWH6HOj1aa9Pch1y/n4RY8njfafJkYbXBD5Ty2obHVwmXU5vFlive5VCfnK2HbZWOeuifuUPkw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@manypkg/find-root@1.1.0':
|
||||
resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==}
|
||||
|
||||
@@ -5994,8 +5990,6 @@ snapshots:
|
||||
rxjs: 7.8.2
|
||||
zod: 3.25.76
|
||||
|
||||
'@llamaindex/workflows-client@1.2.0': {}
|
||||
|
||||
'@manypkg/find-root@1.1.0':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.27.6
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./packages/ui" }
|
||||
{ "path": "./packages/ui" },
|
||||
{ "path": "./packages/workflows-client" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user