* 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:
Adrian Lyjak
2025-09-24 10:40:19 -04:00
committed by GitHub
parent e8b30a36fe
commit 3572aa1ad1
14 changed files with 323 additions and 310 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@llamaindex/ui": patch
---
Use SSE for streaming handler events
+4
View File
@@ -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"),
+97 -11
View File
@@ -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");
}
+1
View File
@@ -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,
+101 -138
View File
@@ -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>;
+69 -137
View File
@@ -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);
});
});
+2 -1
View File
@@ -18,7 +18,8 @@
"baseUrl": ".",
"outDir": "./dist",
"paths": {
"@/*": ["./*"]
"@/*": ["./*"],
"@llamaindex/workflows-client": ["../workflows-client/src"]
}
},
"include": [
+8
View File
@@ -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: {
+2 -1
View File
@@ -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
+3 -9
View File
@@ -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
View File
@@ -1,6 +1,7 @@
{
"files": [],
"references": [
{ "path": "./packages/ui" }
{ "path": "./packages/ui" },
{ "path": "./packages/workflows-client" }
]
}