From 16e5368c99b65797345b4497fa74c95eec8b5881 Mon Sep 17 00:00:00 2001 From: Diyor Khaydarov Date: Wed, 15 Apr 2026 12:36:09 +0500 Subject: [PATCH 1/5] =?UTF-8?q?feat(bichat):=20resilient=20run=20client=20?= =?UTF-8?q?=E2=80=94=20request=5Fid,=20text=20blocks,=20active-run=20SSE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complements iota-uz/iota-sdk#747 with the client pieces of the resilient generation pipeline. Pure additions — no wire break. All features degrade gracefully when the backend doesn't implement the matching endpoints (older SDK commits). ## Idempotent send via request_id Every POST /bi-chat/stream now carries a client-generated requestId. The backend's SetNX dedupe collapses duplicate sends (double-click / cross-tab) onto a single run within the ~30 min dedupe window. Callers can override via SendMessageOptions.requestId for retry flows; otherwise one is auto-minted per call via crypto.randomUUID (with a Math.random fallback for older WebViews). ## Cursor-based reconnect via native EventSource New MessageTransport.subscribeRunEvents + HttpDataSource.subscribeRunEvents open a native EventSource against GET /bi-chat/stream/events. The browser handles reconnect + Last-Event-ID automatically on wifi drops / tab sleep — we listen for every documented server event type and settle the promise on a terminal (done / error). Callers can abort via a signal to close the EventSource on unmount. ## Intermediate text blocks - Added text_block_end to StreamChunk / StreamEvent / sseParser. - New utils/textBlocks.ts exports splitIntoTextBlocks + readTextBlockOffsets so components can split the accumulated assistant content at server- emitted byte offsets and render text → tool → text → tool → final_text as distinct blocks instead of one merged paragraph. ## Sidebar status fan-out - New ActiveRunDelivery type + subscribeActiveRuns on MessageTransport / HttpDataSource / ChatDataSource. - useActiveRuns hook subscribes on mount, aggregates the initial snapshot batch with a 16ms coalescer, then applies live update deltas (removing entries on terminal status). Components read one per-session status dictionary to render a status dot without polling /stream/status per session. ## Tests - textBlocks.test.ts (8 cases): empty / no-offsets / split / clamp / dedup-offsets / unicode boundary / metadata parser. - MessageTransport.test.ts: auto-generated requestId in POST body, caller-supplied requestId preserved verbatim. - pnpm run typecheck clean, pnpm run build green, eslint zero errors. --- ui/src/bichat/data/HttpDataSource.ts | 41 ++++ ui/src/bichat/data/MessageTransport.test.ts | 44 ++++ ui/src/bichat/data/MessageTransport.ts | 229 ++++++++++++++++++++ ui/src/bichat/hooks/useActiveRuns.ts | 129 +++++++++++ ui/src/bichat/index.ts | 12 + ui/src/bichat/types/index.ts | 63 +++++- ui/src/bichat/utils/sseParser.ts | 4 + ui/src/bichat/utils/textBlocks.test.ts | 71 ++++++ ui/src/bichat/utils/textBlocks.ts | 95 ++++++++ 9 files changed, 687 insertions(+), 1 deletion(-) create mode 100644 ui/src/bichat/hooks/useActiveRuns.ts create mode 100644 ui/src/bichat/utils/textBlocks.test.ts create mode 100644 ui/src/bichat/utils/textBlocks.ts diff --git a/ui/src/bichat/data/HttpDataSource.ts b/ui/src/bichat/data/HttpDataSource.ts index c735302..ea5607f 100644 --- a/ui/src/bichat/data/HttpDataSource.ts +++ b/ui/src/bichat/data/HttpDataSource.ts @@ -268,6 +268,47 @@ export class HttpDataSource implements ChatDataSource { ); } + /** + * Open a native EventSource against GET /stream/events for the given + * run. Used by components that want browser-native auto-reconnect + * with Last-Event-ID (tab close, wifi drop, device switch). Prefer + * over resumeStream when tailing an already-running generation that + * another tab started. + */ + subscribeRunEvents( + sessionId: string, + runId: string, + options: Messages.SubscribeRunEventsOptions + ): Promise { + return Messages.subscribeRunEvents( + { + baseUrl: this.config.baseUrl, + streamEndpoint: this.config.streamEndpoint!, + }, + sessionId, + runId, + options + ); + } + + /** + * Subscribe to the per-tenant active-run fan-out + * (GET /stream/active-runs). Never resolves until the caller aborts + * via the signal; use from a top-level component (sidebar container) + * that mounts for the lifetime of the chat app. + */ + subscribeActiveRuns( + options: Messages.SubscribeActiveRunsOptions + ): Promise { + return Messages.subscribeActiveRuns( + { + baseUrl: this.config.baseUrl, + streamEndpoint: this.config.streamEndpoint!, + }, + options + ); + } + async *sendMessage( sessionId: string, content: string, diff --git a/ui/src/bichat/data/MessageTransport.test.ts b/ui/src/bichat/data/MessageTransport.test.ts index d845bc2..ed5a311 100644 --- a/ui/src/bichat/data/MessageTransport.test.ts +++ b/ui/src/bichat/data/MessageTransport.test.ts @@ -92,6 +92,50 @@ describe('MessageTransport stream timeout defaults', () => { }); }); +describe('sendMessage request_id idempotency', () => { + it('includes an auto-generated requestId in the POST body', async () => { + const fetchMock = vi.fn(async () => new Response( + createSSEStream([{ type: 'done' }]), + { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, + )); + vi.stubGlobal('fetch', fetchMock); + + for await (const _ of sendMessage(createDeps(), 'session-1', 'hello', [])) { + // drain stream + } + + expect(fetchMock).toHaveBeenCalledTimes(1); + const firstCall = fetchMock.mock.calls[0] as unknown as [unknown, RequestInit]; + const body = JSON.parse(firstCall[1].body as string); + expect(typeof body.requestId).toBe('string'); + expect(body.requestId.length).toBeGreaterThan(0); + }); + + it('preserves a caller-supplied requestId verbatim', async () => { + const fetchMock = vi.fn(async () => new Response( + createSSEStream([{ type: 'done' }]), + { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, + )); + vi.stubGlobal('fetch', fetchMock); + + const explicit = '11111111-2222-4333-8444-555566667777'; + for await (const _ of sendMessage( + createDeps(), + 'session-1', + 'hello', + [], + undefined, + { requestId: explicit }, + )) { + // drain + } + + const firstCall = fetchMock.mock.calls[0] as unknown as [unknown, RequestInit]; + const body = JSON.parse(firstCall[1].body as string); + expect(body.requestId).toBe(explicit); + }); +}); + describe('submitQuestionAnswers', () => { it('flattens custom text answers for RPC submission', async () => { const callRPC = vi.fn(async () => ({ diff --git a/ui/src/bichat/data/MessageTransport.ts b/ui/src/bichat/data/MessageTransport.ts index b3e60b8..4684698 100644 --- a/ui/src/bichat/data/MessageTransport.ts +++ b/ui/src/bichat/data/MessageTransport.ts @@ -51,6 +51,35 @@ export interface MessageTransportDeps { logAttachmentLifecycle: AttachmentLifecycleLogger } +// --------------------------------------------------------------------------- +// Request-id generation +// --------------------------------------------------------------------------- + +/** + * Generate a UUID-ish idempotency key for a single send. Prefers + * crypto.randomUUID when available (every evergreen browser + Node + * 16.7+); falls back to a Math.random-based v4 string so the feature + * still works in constrained WebViews. + * + * The resulting id is returned inline in the POST /stream body as + * `requestId` so the backend's SetNX dedupe can collapse duplicates. + */ +function generateRequestId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + // RFC 4122 v4 fallback. Not cryptographically strong, but sufficient + // for idempotency keys that live < 30 min. + const bytes = new Uint8Array(16); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Math.floor(Math.random() * 256); + } + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + // --------------------------------------------------------------------------- // Stream sending // --------------------------------------------------------------------------- @@ -92,12 +121,19 @@ export async function* sendMessage( sessionId, attachmentCount: streamAttachments.length, }); + // Idempotency: one request_id per send, client-generated unless + // the caller provided a deterministic one (e.g. retry flow). The + // backend dedupes duplicates within a ~30 min window so a double + // click / tab-level retry converges on the same run. Falls back to + // a stable pseudo-id on environments that lack crypto.randomUUID. + const requestId = options?.requestId ?? generateRequestId(); const payload: Record = { sessionId, content, debugMode: options?.debugMode ?? false, replaceFromMessageId: options?.replaceFromMessageID, attachments: streamAttachments, + requestId, }; if (options?.reasoningEffort) { payload.reasoningEffort = options.reasoningEffort; @@ -309,6 +345,199 @@ export async function resumeStream( } } +// --------------------------------------------------------------------------- +// Cursor-based event tail (Last-Event-ID reconnect) +// --------------------------------------------------------------------------- + +type StreamEventsDeps = Pick< + MessageTransportDeps, + 'baseUrl' | 'streamEndpoint' +> + +export interface SubscribeRunEventsOptions { + /** When set, start from the last seen event id instead of a full replay. */ + lastEventId?: string; + /** Fires for every chunk (content / tool / snapshot / done / error / …). */ + onChunk: (chunk: StreamChunk) => void; + /** Optional hook for raw SSE errors (connection blips, parse failures). */ + onError?: (event: Event) => void; + /** AbortSignal closes the underlying EventSource. */ + signal?: AbortSignal; +} + +/** + * Open a native EventSource against GET /stream/events for a run. The + * browser handles reconnect + Last-Event-ID automatically on transient + * network drops; we forward each SSE event onto onChunk and resolve the + * returned promise when a terminal event (done / error) arrives or the + * caller aborts. + * + * Kept separate from sendMessage so the applet can connect to a run + * that was started by another tab (shared request_id) or that the + * current session already had in flight (tab reopen, device switch). + */ +export function subscribeRunEvents( + deps: StreamEventsDeps, + sessionId: string, + runId: string, + options: SubscribeRunEventsOptions +): Promise { + const base = buildStreamUrl(deps, '/events'); + const qs = new URLSearchParams({ sessionId, runId }); + const url = `${base}?${qs.toString()}`; + + // EventSource ignores custom headers in most browsers, so we leave + // auth to the cookie that backs the session. Last-Event-ID is only + // honoured by native reconnects — when the caller explicitly + // supplies one (first connect after a known cursor) we append it as + // a query parameter the server reads as a fallback. Native + // reconnect adds the real `Last-Event-ID` header for subsequent + // drops. + const withCursor = options.lastEventId + ? `${url}&${new URLSearchParams({ lastEventId: options.lastEventId }).toString()}` + : url; + + return new Promise((resolve, reject) => { + const es = new EventSource(withCursor, { withCredentials: true }); + let settled = false; + + const cleanup = () => { + es.close(); + }; + const settle = (err?: Error) => { + if (settled) {return;} + settled = true; + cleanup(); + if (err) { + reject(err); + } else { + resolve(); + } + }; + + if (options.signal) { + if (options.signal.aborted) { + settle(); + return; + } + options.signal.addEventListener('abort', () => settle(), { once: true }); + } + + // Listen on every event type we emit server-side. Unknown types + // fall through to the default handler. + const forward = (evt: MessageEvent) => { + try { + const parsed = JSON.parse(evt.data) as StreamChunk; + options.onChunk(parsed); + if (parsed.type === 'done' || parsed.type === 'error') { + settle(); + } + } catch (parseErr) { + // Malformed payload — surface as an error chunk but don't + // tear the stream down; native EventSource will reconnect. + options.onChunk({ + type: 'error', + error: `Failed to parse event: ${String(parseErr)}`, + }); + } + }; + + const eventNames = [ + 'content', + 'chunk', + 'thinking', + 'tool_start', + 'tool_end', + 'text_block_end', + 'snapshot', + 'interrupt', + 'usage', + 'done', + 'error', + 'stream_started', + ]; + for (const name of eventNames) { + es.addEventListener(name, forward as EventListener); + } + es.onmessage = forward; + + es.onerror = (evt) => { + options.onError?.(evt); + // Don't settle here — EventSource auto-reconnects on transient + // errors. The server closes the connection with a terminal + // `done` / `error` event when the run ends, at which point the + // forward() path settles the promise. + }; + }); +} + +// --------------------------------------------------------------------------- +// Active-run sidebar fan-out (per-tenant status SSE) +// --------------------------------------------------------------------------- + +export type ActiveRunSidebarEventType = 'snapshot' | 'update'; + +export interface ActiveRunSidebarEvent { + event: ActiveRunSidebarEventType; + sessionId: string; + runId: string; + status: 'queued' | 'streaming' | 'completed' | 'cancelled' | 'failed'; + updatedAt: number; +} + +export interface SubscribeActiveRunsOptions { + onEvent: (event: ActiveRunSidebarEvent) => void; + onError?: (event: Event) => void; + signal?: AbortSignal; +} + +/** + * Open an EventSource to the per-tenant active-run feed. Emits one + * "snapshot" row per currently-running session on connect then live + * "update" deltas as runs transition. Used by the chat list to render + * a status dot without polling each session. + * + * Never resolves on its own — the caller should abort via the signal + * when the component unmounts; the returned promise only settles on + * signal abort. + */ +export function subscribeActiveRuns( + deps: StreamEventsDeps, + options: SubscribeActiveRunsOptions +): Promise { + const url = buildStreamUrl(deps, '/active-runs'); + return new Promise((resolve) => { + const es = new EventSource(url, { withCredentials: true }); + + const close = () => { + es.close(); + resolve(); + }; + + if (options.signal) { + if (options.signal.aborted) { + close(); + return; + } + options.signal.addEventListener('abort', close, { once: true }); + } + + const forward = (event: ActiveRunSidebarEventType) => + (msg: MessageEvent) => { + try { + const body = JSON.parse(msg.data) as Omit; + options.onEvent({ event, ...body }); + } catch { + // ignore malformed payloads — server contract guarantees JSON + } + }; + + es.addEventListener('snapshot', forward('snapshot') as EventListener); + es.addEventListener('update', forward('update') as EventListener); + es.onerror = (evt) => options.onError?.(evt); + }); +} + // --------------------------------------------------------------------------- // Question submission / rejection // --------------------------------------------------------------------------- diff --git a/ui/src/bichat/hooks/useActiveRuns.ts b/ui/src/bichat/hooks/useActiveRuns.ts new file mode 100644 index 0000000..c849301 --- /dev/null +++ b/ui/src/bichat/hooks/useActiveRuns.ts @@ -0,0 +1,129 @@ +/** + * useActiveRuns — live map of per-session generation status. + * + * Subscribes to the data source's `subscribeActiveRuns` channel + * (`GET /bi-chat/stream/active-runs`) and maintains a + * sessionId → status dictionary so the sidebar can render a status + * dot next to each session card without polling /stream/status per + * session. + * + * The hook is a no-op when: + * - the data source does not implement subscribeActiveRuns (older + * backends without the active-run index); + * - `enabled` is false (e.g. the user is offline). + */ + +import { useEffect, useRef, useState, useCallback } from 'react'; +import type { ActiveRunDelivery, ChatDataSource } from '../types'; + +export interface ActiveRunSnapshot { + runId: string; + status: ActiveRunDelivery['status']; + updatedAt: number; +} + +export interface UseActiveRunsOptions { + enabled?: boolean; + /** Optional hook for raw SSE errors. */ + onError?: (event: Event) => void; +} + +export interface UseActiveRunsResult { + /** sessionId → current live status. Terminal statuses are emitted then the entry is removed. */ + runs: Record; + /** True once the initial HGETALL snapshot is delivered. */ + ready: boolean; + /** Convenience: undefined when not active. */ + status: (sessionId: string) => ActiveRunDelivery['status'] | undefined; +} + +const TERMINAL = new Set([ + 'completed', + 'cancelled', + 'failed', +]); + +export function useActiveRuns( + dataSource: Pick, + options: UseActiveRunsOptions = {} +): UseActiveRunsResult { + const [runs, setRuns] = useState>({}); + const [ready, setReady] = useState(false); + const onErrorRef = useRef(options.onError); + onErrorRef.current = options.onError; + + const enabled = options.enabled ?? true; + + useEffect(() => { + if (!enabled) {return;} + if (!dataSource.subscribeActiveRuns) {return;} + + const controller = new AbortController(); + // Collect snapshot rows into a staging object so we only flip + // `ready` once at the end of the initial batch. Each SSE + // "snapshot" event is a single row; the server never emits a + // delimiter, so we use a microtask coalescer instead. + let stagingTimer: ReturnType | undefined; + const staging: Record = {}; + let sawSnapshotRow = false; + + const flushSnapshot = () => { + setRuns((prev) => ({ ...prev, ...staging })); + setReady(true); + stagingTimer = undefined; + }; + + dataSource.subscribeActiveRuns({ + signal: controller.signal, + onError: (evt) => onErrorRef.current?.(evt), + onEvent: (evt) => { + if (evt.event === 'snapshot') { + sawSnapshotRow = true; + staging[evt.sessionId] = { + runId: evt.runId, + status: evt.status, + updatedAt: evt.updatedAt, + }; + if (stagingTimer === undefined) { + stagingTimer = setTimeout(flushSnapshot, 16); + } + return; + } + // update: apply immediately, prune on terminal status. + setRuns((prev) => { + const next = { ...prev }; + if (TERMINAL.has(evt.status)) { + delete next[evt.sessionId]; + } else { + next[evt.sessionId] = { + runId: evt.runId, + status: evt.status, + updatedAt: evt.updatedAt, + }; + } + return next; + }); + }, + }); + + // If the server has zero active runs on connect it never emits a + // snapshot row; mark ready after a brief idle so consumers don't + // spin forever. + const readyTimeout = setTimeout(() => { + if (!sawSnapshotRow) {setReady(true);} + }, 250); + + return () => { + controller.abort(); + if (stagingTimer !== undefined) {clearTimeout(stagingTimer);} + clearTimeout(readyTimeout); + }; + }, [dataSource, enabled]); + + const status = useCallback( + (sessionId: string) => runs[sessionId]?.status, + [runs] + ); + + return { runs, ready, status }; +} diff --git a/ui/src/bichat/index.ts b/ui/src/bichat/index.ts index 26e1d81..0e8a48d 100644 --- a/ui/src/bichat/index.ts +++ b/ui/src/bichat/index.ts @@ -156,6 +156,12 @@ export * from './primitives'; // Existing hooks export { useStreaming } from './hooks/useStreaming'; +export { + useActiveRuns, + type UseActiveRunsOptions, + type UseActiveRunsResult, + type ActiveRunSnapshot, +} from './hooks/useActiveRuns'; export { useTranslation } from './hooks/useTranslation'; export { useModalLock } from './hooks/useModalLock'; export { useFocusTrap } from './hooks/useFocusTrap'; @@ -265,6 +271,11 @@ export { groupSteps } from './utils/activitySteps'; export * from './utils/fileUtils'; export { groupSessionsByDate } from './utils/sessionGrouping'; export { toErrorDisplay, isPermissionDeniedError, type RPCErrorDisplay } from './utils/errorDisplay'; +export { + splitIntoTextBlocks, + readTextBlockOffsets, + type AssistantTextBlock, +} from './utils/textBlocks'; // ============================================================================= // Types @@ -294,6 +305,7 @@ export type { // Streaming types StreamEvent, StreamChunk, + ActiveRunDelivery, // Split data source interfaces SessionStore, MessageTransport, diff --git a/ui/src/bichat/types/index.ts b/ui/src/bichat/types/index.ts index 789426a..d3bd7a8 100644 --- a/ui/src/bichat/types/index.ts +++ b/ui/src/bichat/types/index.ts @@ -347,6 +347,7 @@ export type StreamEvent = | { type: "usage"; usage: DebugUsage } | { type: "user_message"; sessionId: string } | { type: "interrupt"; interrupt: StreamInterruptPayload; sessionId?: string } + | { type: "text_block_end"; seq: number } | { type: "done"; sessionId?: string; generationMs?: number } | { type: "error"; error: string }; @@ -389,7 +390,8 @@ export interface StreamChunk { | "user_message" | "interrupt" | "snapshot" - | "stream_started"; + | "stream_started" + | "text_block_end"; content?: string; error?: string; sessionId?: string; @@ -401,6 +403,28 @@ export interface StreamChunk { snapshot?: StreamSnapshotPayload; /** Set when type is 'stream_started'; client should store for refresh-safe resume */ runId?: string; + /** + * Zero-based ordinal of the assistant text segment that just ended. + * Populated only when type === "text_block_end". Used to split the + * accumulated assistant content into distinct blocks interleaved with + * tool_call UI (text → tool → text → tool → final_text). + */ + textBlockSeq?: number; +} + +/** + * Per-tenant active-run status event. Delivered via the + * GET /bi-chat/stream/active-runs SSE endpoint. The first batch after + * connect carries `event === "snapshot"` for each currently-running + * session; subsequent rows carry `event === "update"` as runs + * transition (queued → streaming → completed / cancelled / failed). + */ +export interface ActiveRunDelivery { + event: "snapshot" | "update"; + sessionId: string; + runId: string; + status: "queued" | "streaming" | "completed" | "cancelled" | "failed"; + updatedAt: number; } export interface StreamInterruptPayload { @@ -526,6 +550,13 @@ export interface SendMessageOptions { replaceFromMessageID?: string; reasoningEffort?: string; model?: string; + /** + * Client-generated idempotency key. Duplicate sends sharing the same + * requestId within the backend's dedupe window (~30 min) converge on + * a single server-side run. Omit to disable dedupe for a particular + * send; the data source auto-generates one per call otherwise. + */ + requestId?: string; } // ============================================================================ @@ -647,6 +678,36 @@ export interface ChatDataSource { onChunk: (chunk: StreamChunk) => void, signal?: AbortSignal, ): Promise; + /** + * Tail a run via native EventSource against GET /stream/events. Honours + * Last-Event-ID for auto-reconnect on wifi drops / tab sleep. Prefer + * over resumeStream when connecting to a run that was started by + * another tab (same request_id) or that needs to survive a reload + * via cursor-based replay. Optional; data sources that don't + * implement it fall back to resumeStream. + */ + subscribeRunEvents?( + sessionId: string, + runId: string, + options: { + lastEventId?: string; + onChunk: (chunk: StreamChunk) => void; + onError?: (event: Event) => void; + signal?: AbortSignal; + }, + ): Promise; + /** + * Subscribe to the per-tenant active-run fan-out + * (GET /stream/active-runs). Emits one "snapshot" event per + * currently-running session on connect, then live "update" deltas. + * Optional; data sources that don't implement it fall back to + * per-session polling via getStreamStatus. + */ + subscribeActiveRuns?(options: { + onEvent: (event: ActiveRunDelivery) => void; + onError?: (event: Event) => void; + signal?: AbortSignal; + }): Promise; // Session management listSessions(options?: { limit?: number; diff --git a/ui/src/bichat/utils/sseParser.ts b/ui/src/bichat/utils/sseParser.ts index 0f8da31..9e1af89 100644 --- a/ui/src/bichat/utils/sseParser.ts +++ b/ui/src/bichat/utils/sseParser.ts @@ -157,6 +157,10 @@ function toStreamEvent(chunk: StreamChunk): StreamEvent | null { return chunk.interrupt ? { type: 'interrupt', interrupt: chunk.interrupt, sessionId: chunk.sessionId } : null; + case 'text_block_end': + // seq defaults to 0 so consumers can always key on it even when + // the server mints the first block implicitly. + return { type: 'text_block_end', seq: chunk.textBlockSeq ?? 0 }; case 'done': return { type: 'done', sessionId: chunk.sessionId, generationMs: chunk.generationMs }; case 'error': diff --git a/ui/src/bichat/utils/textBlocks.test.ts b/ui/src/bichat/utils/textBlocks.test.ts new file mode 100644 index 0000000..ad32d5d --- /dev/null +++ b/ui/src/bichat/utils/textBlocks.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { + splitIntoTextBlocks, + readTextBlockOffsets, + type AssistantTextBlock, +} from "./textBlocks"; + +describe("splitIntoTextBlocks", () => { + it("returns an empty list for empty content", () => { + expect(splitIntoTextBlocks("")).toEqual([]); + }); + + it("returns a single block when no offsets are supplied", () => { + const got = splitIntoTextBlocks("Hello world"); + expect(got).toEqual([{ seq: 0, content: "Hello world" }]); + }); + + it("splits content at each offset and includes the trailing segment", () => { + const content = "Checking weather. Result: sunny. All done."; + // Offset 17 = end of "Checking weather.", 32 = end of "… Result: sunny.". + const got = splitIntoTextBlocks(content, [17, 32]); + expect(got).toEqual([ + { seq: 0, content: "Checking weather." }, + { seq: 1, content: " Result: sunny." }, + { seq: 2, content: " All done." }, + ]); + }); + + it("clamps offsets beyond the current content length (stale snapshot case)", () => { + const got = splitIntoTextBlocks("short", [10]); + // Clamps to 5 then no trailing remainder — single block covering the full content. + expect(got).toEqual([{ seq: 0, content: "short" }]); + }); + + it("sorts offsets and ignores duplicates without crashing", () => { + const got = splitIntoTextBlocks("abcdef", [4, 4, 2]); + expect(got).toEqual([ + { seq: 0, content: "ab" }, + { seq: 1, content: "cd" }, + { seq: 2, content: "ef" }, + ]); + }); + + it("preserves unicode boundaries that fall between surrogate pairs safely", () => { + // NOTE: this intentionally matches Go's byte-offset semantics on the + // server — text_block_offsets are byte offsets into the UTF-8 + // accumulated Content. The client uses the same indices into the + // JS string (UTF-16). Consumers who need grapheme-accurate rendering + // should defer to markdown rendering rather than expecting this + // function to fix the boundary. + const got = splitIntoTextBlocks("hi\u{1F600}bye", [2]); + expect(got[0].content).toBe("hi"); + expect(got[1].content).toBe("\u{1F600}bye"); + }); +}); + +describe("readTextBlockOffsets", () => { + it("returns empty array for missing or non-array metadata", () => { + expect(readTextBlockOffsets()).toEqual([]); + expect(readTextBlockOffsets(null)).toEqual([]); + expect(readTextBlockOffsets({})).toEqual([]); + expect(readTextBlockOffsets({ text_block_offsets: "nope" })).toEqual([]); + }); + + it("extracts numeric entries and drops non-numeric ones", () => { + const got = readTextBlockOffsets({ + text_block_offsets: [0, 10, "20", NaN, -5, 30, null], + }); + expect(got).toEqual([0, 10, 30]); + }); +}); diff --git a/ui/src/bichat/utils/textBlocks.ts b/ui/src/bichat/utils/textBlocks.ts new file mode 100644 index 0000000..3514c79 --- /dev/null +++ b/ui/src/bichat/utils/textBlocks.ts @@ -0,0 +1,95 @@ +/** + * Utilities for splitting accumulated assistant content into the distinct + * text blocks the server emitted. + * + * Background: the executor interleaves text with tool calls within one + * turn — `text → tool_call → text → tool_call → final_text`. Before the + * backend emitted `text_block_end` markers (iota-uz/iota-sdk#732 M1), + * all text segments collapsed into one paragraph on the client. Now the + * server emits a `text_block_end` event before each tool_start and at + * snapshot time carries byte offsets in `partialMetadata.text_block_offsets`. + * + * `splitIntoTextBlocks` is the client-side inverse: given the + * accumulated content string and the ordered list of byte offsets that + * mark segment ends, return one entry per segment. The trailing un-closed + * segment (if any) is included as the last entry. + */ + +export interface AssistantTextBlock { + /** Zero-based index matching the seq carried on text_block_end events. */ + seq: number; + /** Markdown source for this segment. */ + content: string; +} + +/** + * Split an accumulated assistant content string into blocks using byte + * offsets emitted by the server. Offsets are EXCLUSIVE end markers — + * i.e. `offsets[0]` is the length of block 0. + * + * - When `offsets` is empty or undefined, returns a single block with + * the full content. + * - When offsets are provided but content is shorter than the last + * offset (e.g. stale metadata), offsets beyond the content length are + * clamped so no crash propagates up. + * - Trailing content after the last offset is included as an extra + * block (the un-closed segment during streaming / the final segment + * after the last tool call). + */ +export function splitIntoTextBlocks( + content: string, + offsets?: ReadonlyArray | null +): AssistantTextBlock[] { + if (!content) { + return []; + } + if (!offsets || offsets.length === 0) { + return [{ seq: 0, content }]; + } + + const sanitized = [...offsets] + .map((n) => Math.max(0, Math.min(Math.floor(n), content.length))) + .sort((a, b) => a - b); + + const blocks: AssistantTextBlock[] = []; + let cursor = 0; + for (let i = 0; i < sanitized.length; i++) { + const end = sanitized[i]; + if (end <= cursor) { + continue; + } + const slice = content.slice(cursor, end); + if (slice) { + blocks.push({ seq: blocks.length, content: slice }); + } + cursor = end; + } + if (cursor < content.length) { + blocks.push({ seq: blocks.length, content: content.slice(cursor) }); + } + return blocks; +} + +/** + * Normalise partialMetadata.text_block_offsets from a StreamSnapshotPayload + * into a number[]. Guards against malformed server data (non-array, + * non-numeric entries) so UI code never has to think about shapes. + */ +export function readTextBlockOffsets( + partialMetadata?: Record | null +): number[] { + if (!partialMetadata) { + return []; + } + const raw = partialMetadata["text_block_offsets"]; + if (!Array.isArray(raw)) { + return []; + } + const out: number[] = []; + for (const entry of raw) { + if (typeof entry === "number" && Number.isFinite(entry) && entry >= 0) { + out.push(Math.floor(entry)); + } + } + return out; +} From e922c135b998b5fd02931b9ac1bf8bc92a78a89e Mon Sep 17 00:00:00 2001 From: Diyor Khaydarov Date: Wed, 15 Apr 2026 13:07:29 +0500 Subject: [PATCH 2/5] fix(bichat): settle cancelled/failed; escape initial-connect errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native EventSource routes events with an `event:` line ONLY via addEventListener — there is no onmessage fallback. subscribeRunEvents registered listeners for {done, error, content, …} but not for the `cancelled` / `failed` terminal names emitted by the backend on cross-tab Stop or RunReaper stale-run sweeps. Effect: the client promise never settles, EventSource auto-reconnects forever, users see a stuck "thinking" indicator. - Register listeners for cancelled / failed / citation. - Extend the settle predicate to the full terminal set. - Add a 500ms initial-connect grace: when onerror fires before the first event arrives, settle with a typed RunEventsConnectError instead of spinning the auto-reconnect. Distinguishes 401/404/503 at the boundary from transient mid-run flaps. - Document the request_id / UUID v4 contract in the generator jsdoc. --- ui/src/bichat/data/MessageTransport.test.ts | 162 ++++++++++++++++++++ ui/src/bichat/data/MessageTransport.ts | 65 +++++++- ui/src/bichat/index.ts | 1 + 3 files changed, 225 insertions(+), 3 deletions(-) diff --git a/ui/src/bichat/data/MessageTransport.test.ts b/ui/src/bichat/data/MessageTransport.test.ts index ed5a311..114cc67 100644 --- a/ui/src/bichat/data/MessageTransport.test.ts +++ b/ui/src/bichat/data/MessageTransport.test.ts @@ -1,10 +1,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { resumeStream, + RunEventsConnectError, sendMessage, + subscribeRunEvents, submitQuestionAnswers, type MessageTransportDeps, } from './MessageTransport'; +import type { StreamChunk } from '../types'; const encoder = new TextEncoder(); @@ -136,6 +139,165 @@ describe('sendMessage request_id idempotency', () => { }); }); +// --------------------------------------------------------------------------- +// subscribeRunEvents — EventSource listener regression coverage +// --------------------------------------------------------------------------- + +class FakeEventSource { + static instances: FakeEventSource[] = []; + listeners = new Map void>>(); + onerror: ((e: Event) => void) | null = null; + onmessage: ((e: MessageEvent) => void) | null = null; + readyState = 0; + closed = false; + constructor(public url: string, public init?: EventSourceInit) { + FakeEventSource.instances.push(this); + } + addEventListener(name: string, cb: (e: MessageEvent) => void) { + if (!this.listeners.has(name)) {this.listeners.set(name, new Set());} + this.listeners.get(name)!.add(cb); + } + removeEventListener(name: string, cb: (e: MessageEvent) => void) { + this.listeners.get(name)?.delete(cb); + } + close() { + this.readyState = 2; + this.closed = true; + } + emit(name: string, data: unknown) { + const payload = typeof data === 'string' ? data : JSON.stringify(data); + const evt = new MessageEvent(name, { data: payload }); + for (const cb of this.listeners.get(name) ?? []) {cb(evt);} + } + emitError() { + this.onerror?.(new Event('error')); + } +} + +function installFakeEventSource(): typeof FakeEventSource { + FakeEventSource.instances = []; + vi.stubGlobal('EventSource', FakeEventSource as unknown as typeof EventSource); + return FakeEventSource; +} + +describe('subscribeRunEvents — terminal event handling', () => { + it('settles when backend emits a `cancelled` event (regression)', async () => { + installFakeEventSource(); + const chunks: StreamChunk[] = []; + const promise = subscribeRunEvents( + { baseUrl: '', streamEndpoint: '/stream' }, + 'session-1', + 'run-1', + { onChunk: (c) => chunks.push(c) }, + ); + // Flush microtasks so the constructor + addEventListener calls + // complete before we emit. + await Promise.resolve(); + const es = FakeEventSource.instances[0]; + expect(es).toBeDefined(); + es.emit('cancelled', { type: 'cancelled', reason: 'user_stop' }); + await expect(promise).resolves.toBeUndefined(); + expect(es.closed).toBe(true); + expect(chunks).toEqual([{ type: 'cancelled', reason: 'user_stop' }]); + }); + + it('settles when backend emits a `failed` event (regression)', async () => { + installFakeEventSource(); + const chunks: StreamChunk[] = []; + const promise = subscribeRunEvents( + { baseUrl: '', streamEndpoint: '/stream' }, + 'session-1', + 'run-1', + { onChunk: (c) => chunks.push(c) }, + ); + await Promise.resolve(); + const es = FakeEventSource.instances[0]; + es.emit('failed', { type: 'failed', error: 'reaper_stale' }); + await expect(promise).resolves.toBeUndefined(); + expect(es.closed).toBe(true); + expect(chunks).toHaveLength(1); + expect(chunks[0].type).toBe('failed'); + }); + + it('surfaces malformed JSON as an error chunk without tearing down the subscription', async () => { + installFakeEventSource(); + const chunks: StreamChunk[] = []; + const promise = subscribeRunEvents( + { baseUrl: '', streamEndpoint: '/stream' }, + 'session-1', + 'run-1', + { onChunk: (c) => chunks.push(c) }, + ); + await Promise.resolve(); + const es = FakeEventSource.instances[0]; + es.emit('content', '{bad'); + // Subscription remains open — the first chunk was an injected error + // chunk. A subsequent valid `done` settles the promise. + expect(es.closed).toBe(false); + es.emit('done', { type: 'done' }); + await expect(promise).resolves.toBeUndefined(); + expect(es.closed).toBe(true); + expect(chunks).toHaveLength(2); + expect(chunks[0].type).toBe('error'); + expect(chunks[1]).toEqual({ type: 'done' }); + }); + + it('resolves and closes EventSource when the AbortSignal fires', async () => { + installFakeEventSource(); + const ctrl = new AbortController(); + const promise = subscribeRunEvents( + { baseUrl: '', streamEndpoint: '/stream' }, + 'session-1', + 'run-1', + { onChunk: () => {}, signal: ctrl.signal }, + ); + await Promise.resolve(); + const es = FakeEventSource.instances[0]; + expect(es.closed).toBe(false); + ctrl.abort(); + await expect(promise).resolves.toBeUndefined(); + expect(es.closed).toBe(true); + }); + + it('rejects with RunEventsConnectError when onerror fires within the initial-connect grace', async () => { + installFakeEventSource(); + const promise = subscribeRunEvents( + { baseUrl: '', streamEndpoint: '/stream' }, + 'session-1', + 'run-1', + { onChunk: () => {} }, + ); + await Promise.resolve(); + const es = FakeEventSource.instances[0]; + es.emitError(); + await expect(promise).rejects.toBeInstanceOf(RunEventsConnectError); + expect(es.closed).toBe(true); + }); +}); + +describe('sendMessage request_id fallback', () => { + it('produces a valid UUID v4 when crypto.randomUUID is unavailable', async () => { + const fetchMock = vi.fn(async () => new Response( + createSSEStream([{ type: 'done' }]), + { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, + )); + vi.stubGlobal('fetch', fetchMock); + // Stub crypto with a shape that is defined but lacks randomUUID — + // forces the Math.random fallback path in generateRequestId. + vi.stubGlobal('crypto', { randomUUID: undefined } as unknown as Crypto); + + for await (const _ of sendMessage(createDeps(), 'session-1', 'hello', [])) { + // drain + } + + const firstCall = fetchMock.mock.calls[0] as unknown as [unknown, RequestInit]; + const body = JSON.parse(firstCall[1].body as string); + expect(body.requestId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); +}); + describe('submitQuestionAnswers', () => { it('flattens custom text answers for RPC submission', async () => { const callRPC = vi.fn(async () => ({ diff --git a/ui/src/bichat/data/MessageTransport.ts b/ui/src/bichat/data/MessageTransport.ts index 4684698..ff052b4 100644 --- a/ui/src/bichat/data/MessageTransport.ts +++ b/ui/src/bichat/data/MessageTransport.ts @@ -20,6 +20,26 @@ import { type CoreUploadResponse, } from './AttachmentUploader'; +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/** + * Thrown by {@link subscribeRunEvents} when the underlying EventSource + * emits `onerror` before the first event arrives within the + * initial-connect grace window. Distinguishes boundary failures + * (401 / 404 / 503) from transient mid-run flaps, which the browser's + * native auto-reconnect continues to handle silently. + */ +export class RunEventsConnectError extends Error { + readonly cause?: Event; + constructor(message: string, cause?: Event) { + super(message); + this.name = 'RunEventsConnectError'; + this.cause = cause; + } +} + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -30,6 +50,13 @@ interface Result { error?: string } +/** + * Terminal SSE event names that settle the subscribeRunEvents promise. + * Mirrors the backend's pkg/httpdto.StreamEventType terminal set. + * Commit D moves this to utils/eventNames.ts alongside a drift-guard. + */ +const TERMINAL_STREAM_EVENT_TYPES = ['done', 'cancelled', 'error', 'failed'] as const; + type RPCCaller = ( method: TMethod, params: BichatRPC[TMethod]['params'] @@ -63,6 +90,15 @@ export interface MessageTransportDeps { * * The resulting id is returned inline in the POST /stream body as * `requestId` so the backend's SetNX dedupe can collapse duplicates. + * + * Backend contract: + * - Dedupe window is 30 minutes on SetNX (see SDK stream handler). + * - UUID v4 is expected — the backend parses the value as + * `uuid.UUID` via the Go stdlib, so malformed ids are rejected + * at the boundary. + * - A double-click or cross-tab retry that sends the same + * `requestId` converges on the same run; the second call returns + * the existing run's `runId` instead of spawning a duplicate. */ function generateRequestId(): string { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { @@ -398,8 +434,10 @@ export function subscribeRunEvents( : url; return new Promise((resolve, reject) => { + const startedAt = Date.now(); const es = new EventSource(withCursor, { withCredentials: true }); let settled = false; + let sawEvent = false; const cleanup = () => { es.close(); @@ -426,10 +464,15 @@ export function subscribeRunEvents( // Listen on every event type we emit server-side. Unknown types // fall through to the default handler. const forward = (evt: MessageEvent) => { + sawEvent = true; try { const parsed = JSON.parse(evt.data) as StreamChunk; options.onChunk(parsed); - if (parsed.type === 'done' || parsed.type === 'error') { + if ( + TERMINAL_STREAM_EVENT_TYPES.includes( + parsed.type as typeof TERMINAL_STREAM_EVENT_TYPES[number], + ) + ) { settle(); } } catch (parseErr) { @@ -451,9 +494,12 @@ export function subscribeRunEvents( 'text_block_end', 'snapshot', 'interrupt', + 'citation', 'usage', 'done', + 'cancelled', 'error', + 'failed', 'stream_started', ]; for (const name of eventNames) { @@ -463,10 +509,23 @@ export function subscribeRunEvents( es.onerror = (evt) => { options.onError?.(evt); + // Initial-connect grace: if onerror fires before any event has + // arrived AND within 500ms of construction, treat as a boundary + // failure (401 / 404 / 503) and settle with a typed error + // instead of relying on EventSource's silent auto-reconnect + // loop. After the grace expires the reconnect path handles + // transient mid-run flaps as before. + if (!sawEvent && Date.now() - startedAt < 500) { + settle(new RunEventsConnectError( + 'EventSource failed to connect before first event', + evt, + )); + return; + } // Don't settle here — EventSource auto-reconnects on transient // errors. The server closes the connection with a terminal - // `done` / `error` event when the run ends, at which point the - // forward() path settles the promise. + // `done` / `error` / `cancelled` / `failed` event when the run + // ends, at which point the forward() path settles the promise. }; }); } diff --git a/ui/src/bichat/index.ts b/ui/src/bichat/index.ts index 0e8a48d..360d1d8 100644 --- a/ui/src/bichat/index.ts +++ b/ui/src/bichat/index.ts @@ -248,6 +248,7 @@ export { getCSRFToken, addCSRFHeader, createHeadersWithCSRF } from './api/csrf'; export { HttpDataSource, createHttpDataSource } from './data/HttpDataSource'; export type { BichatRPC } from './data/rpc.generated'; +export { RunEventsConnectError } from './data/MessageTransport'; // ============================================================================= // Machine (framework-agnostic state management) From f7338f7cfd89662811493a59c6d49ef14a00fbc5 Mon Sep 17 00:00:00 2001 From: Diyor Khaydarov Date: Wed, 15 Apr 2026 13:14:45 +0500 Subject: [PATCH 3/5] refactor(bichat): shared EventSource helper + useActiveRuns polish - openManagedEventSource: shared primitive for the two subscribeX functions; centralises listener wiring, abort handling, and the 500ms initial-connect probe so future fan-out helpers only need to map event names to business callbacks. - utils/eventNames.ts: hand-mirrored version of the backend's pkg/httpdto.StreamEventType constants. A drift-guard test reads the Go file at test time (skipped when the sibling tree is absent). - useActiveRuns: retainTerminalMs + emptyStateTimeoutMs options so consumers can render a "completed" pulse before the entry is pruned. - Remove duplicate ActiveRunSidebarEvent type; reuse public ActiveRunDelivery. - Add @testing-library/react + jsdom to devDependencies; fill in the missing hook + subscribeActiveRuns test coverage. --- package.json | 3 + pnpm-lock.yaml | 357 +++++++++++++++++- ui/src/bichat/data/MessageTransport.ts | 203 ++++------ ui/src/bichat/data/openManagedEventSource.ts | 118 ++++++ .../bichat/data/subscribeActiveRuns.test.ts | 139 +++++++ ui/src/bichat/hooks/useActiveRuns.test.tsx | 137 +++++++ ui/src/bichat/hooks/useActiveRuns.ts | 88 ++++- ui/src/bichat/index.ts | 6 + ui/src/bichat/utils/eventNames.test.ts | 49 +++ ui/src/bichat/utils/eventNames.ts | 52 +++ vitest.config.ts | 2 +- 11 files changed, 1005 insertions(+), 149 deletions(-) create mode 100644 ui/src/bichat/data/openManagedEventSource.ts create mode 100644 ui/src/bichat/data/subscribeActiveRuns.test.ts create mode 100644 ui/src/bichat/hooks/useActiveRuns.test.tsx create mode 100644 ui/src/bichat/utils/eventNames.test.ts create mode 100644 ui/src/bichat/utils/eventNames.ts diff --git a/package.json b/package.json index b39e2b9..abb4070 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,8 @@ "@storybook/test": "8.6.15", "@tailwindcss/cli": "4.1.18", "@tailwindcss/typography": "^0.5.19", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", "@types/node": "^25.2.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -126,6 +128,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", "globals": "^15.15.0", + "jsdom": "^29.0.2", "storybook": "8.6.15", "tailwindcss": "4.1.18", "tsup": "^8.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de5a37c..c1663ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,12 @@ importers: '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@4.1.18) + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/node': specifier: ^25.2.1 version: 25.2.1 @@ -108,6 +114,9 @@ importers: globals: specifier: ^15.15.0 version: 15.15.0 + jsdom: + specifier: ^29.0.2 + version: 29.0.2 storybook: specifier: 8.6.15 version: 8.6.15 @@ -128,13 +137,24 @@ importers: version: 6.4.1(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.30.2) vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.30.2) + version: 4.0.18(@types/node@25.2.1)(jiti@2.6.1)(jsdom@29.0.2)(lightningcss@1.30.2) packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@asamuzakjp/css-color@5.1.10': + resolution: {integrity: sha512-02OhhkKtgNRuicQ/nF3TRnGsxL9wp0r3Y7VlKWyOHHGmGyvXv03y+PnymU8FKFJMTjIr1Bk8U2g1HWSLrpAHww==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.0.9': + resolution: {integrity: sha512-r3ElRr7y8ucyN2KdICwGsmj19RoN13CLCa/pvGydghWK6ZzeKQ+TcDjVdtEZz2ElpndM5jXw//B9CEee0mWnVg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -206,6 +226,46 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.0': + resolution: {integrity: sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.0': + resolution: {integrity: sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3': + resolution: {integrity: sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -556,6 +616,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.7.4': resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} @@ -1173,10 +1242,29 @@ packages: resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} engines: {node: '>=18'} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + '@testing-library/jest-dom@6.5.0': resolution: {integrity: sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@testing-library/user-event@14.5.2': resolution: {integrity: sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==} engines: {node: '>=12', npm: '>=6'} @@ -1489,6 +1577,9 @@ packages: resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} engines: {node: '>=12.0.0'} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -1622,6 +1713,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -1633,6 +1728,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -1657,6 +1756,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -2065,6 +2167,10 @@ packages: highlightjs-vue@1.0.0: resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -2199,6 +2305,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -2271,6 +2380,15 @@ packages: resolution: {integrity: sha512-iZ8Bdb84lWRuGHamRXFyML07r21pcwBrLkHEuHgEY5UbCouBwv7ECknDRKzsQIXMiqpPymqtIf8TC/shYKB5rw==} engines: {node: '>=12.0.0'} + jsdom@29.0.2: + resolution: {integrity: sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -2412,6 +2530,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.3.5: + resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -2484,6 +2606,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + memoizerific@1.11.3: resolution: {integrity: sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==} @@ -2692,6 +2817,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2880,6 +3008,10 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2914,6 +3046,10 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -3110,6 +3246,9 @@ packages: resolution: {integrity: sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw==} engines: {node: '>= 0.8.0'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} @@ -3156,6 +3295,21 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + tldts-core@7.0.28: + resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==} + + tldts@7.0.28: + resolution: {integrity: sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==} + hasBin: true + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -3247,6 +3401,10 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -3382,12 +3540,28 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -3438,6 +3612,13 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -3459,6 +3640,22 @@ snapshots: '@adobe/css-tools@4.4.4': {} + '@asamuzakjp/css-color@5.1.10': + dependencies: + '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.0.9': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -3561,6 +3758,34 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -3763,6 +3988,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.15.0': {} + '@floating-ui/core@1.7.4': dependencies: '@floating-ui/utils': 0.2.10 @@ -4363,6 +4590,17 @@ snapshots: lz-string: 1.5.0 pretty-format: 27.5.1 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.28.6 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + '@testing-library/jest-dom@6.5.0': dependencies: '@adobe/css-tools': 4.4.4 @@ -4373,6 +4611,16 @@ snapshots: lodash: 4.17.23 redent: 3.0.0 + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + '@testing-library/user-event@14.5.2(@testing-library/dom@10.4.0)': dependencies: '@testing-library/dom': 10.4.0 @@ -4758,6 +5006,10 @@ snapshots: dependencies: open: 8.4.2 + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -4877,12 +5129,24 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + css.escape@1.5.1: {} cssesc@3.0.0: {} csstype@3.2.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -4907,6 +5171,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -5500,6 +5766,12 @@ snapshots: highlightjs-vue@1.0.0: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + html-url-attributes@3.0.1: {} ignore@5.3.2: {} @@ -5625,6 +5897,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -5699,6 +5973,32 @@ snapshots: jsdoc-type-pratt-parser@4.8.0: {} + jsdom@29.0.2: + dependencies: + '@asamuzakjp/css-color': 5.1.10 + '@asamuzakjp/dom-selector': 7.0.9 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.2.1) + '@exodus/bytes': 1.15.0 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.3.5 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.25.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -5807,6 +6107,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.3.5: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -5992,6 +6294,8 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdn-data@2.27.1: {} + memoizerific@1.11.3: dependencies: map-or-similar: 1.5.0 @@ -6338,6 +6642,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.0: + dependencies: + entities: 6.0.1 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -6580,6 +6888,8 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -6646,6 +6956,10 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -6873,6 +7187,8 @@ snapshots: dependencies: svg.js: 2.7.1 + symbol-tree@3.2.4: {} + tabbable@6.4.0: {} tailwindcss@4.1.18: {} @@ -6906,6 +7222,20 @@ snapshots: tinyspy@3.0.2: {} + tldts-core@7.0.28: {} + + tldts@7.0.28: + dependencies: + tldts-core: 7.0.28 + + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.28 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} trim-lines@3.0.1: {} @@ -7017,6 +7347,8 @@ snapshots: undici-types@7.16.0: {} + undici@7.25.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -7120,7 +7452,7 @@ snapshots: jiti: 2.6.1 lightningcss: 1.30.2 - vitest@4.0.18(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.30.2): + vitest@4.0.18(@types/node@25.2.1)(jiti@2.6.1)(jsdom@29.0.2)(lightningcss@1.30.2): dependencies: '@vitest/expect': 4.0.18 '@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.30.2)) @@ -7144,6 +7476,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.2.1 + jsdom: 29.0.2 transitivePeerDependencies: - jiti - less @@ -7157,10 +7490,26 @@ snapshots: - tsx - yaml + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + web-namespaces@2.0.1: {} + webidl-conversions@8.0.1: {} + webpack-virtual-modules@0.6.2: {} + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -7227,6 +7576,10 @@ snapshots: ws@8.19.0: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + xtend@4.0.2: {} yallist@3.1.1: {} diff --git a/ui/src/bichat/data/MessageTransport.ts b/ui/src/bichat/data/MessageTransport.ts index ff052b4..cf1fb7d 100644 --- a/ui/src/bichat/data/MessageTransport.ts +++ b/ui/src/bichat/data/MessageTransport.ts @@ -6,6 +6,7 @@ import type { BichatRPC } from './rpc.generated'; import type { + ActiveRunDelivery, Attachment, StreamChunk, StreamStatus, @@ -14,6 +15,11 @@ import type { AsyncRunAccepted, } from '../types'; import { parseBichatStream } from '../utils/sseParser'; +import { + STREAM_EVENT_TYPES, + isTerminalEvent, +} from '../utils/eventNames'; +import { openManagedEventSource } from './openManagedEventSource'; import { ensureAttachmentUpload, assertUploadReferences, @@ -50,13 +56,6 @@ interface Result { error?: string } -/** - * Terminal SSE event names that settle the subscribeRunEvents promise. - * Mirrors the backend's pkg/httpdto.StreamEventType terminal set. - * Commit D moves this to utils/eventNames.ts alongside a drift-guard. - */ -const TERMINAL_STREAM_EVENT_TYPES = ['done', 'cancelled', 'error', 'failed'] as const; - type RPCCaller = ( method: TMethod, params: BichatRPC[TMethod]['params'] @@ -433,100 +432,58 @@ export function subscribeRunEvents( ? `${url}&${new URLSearchParams({ lastEventId: options.lastEventId }).toString()}` : url; - return new Promise((resolve, reject) => { - const startedAt = Date.now(); - const es = new EventSource(withCursor, { withCredentials: true }); - let settled = false; - let sawEvent = false; - - const cleanup = () => { - es.close(); - }; - const settle = (err?: Error) => { - if (settled) {return;} - settled = true; - cleanup(); - if (err) { - reject(err); - } else { - resolve(); - } - }; - - if (options.signal) { - if (options.signal.aborted) { - settle(); - return; - } - options.signal.addEventListener('abort', () => settle(), { once: true }); + // Track whether the caller has already received a terminal chunk — + // openManagedEventSource only closes on abort or initial-connect + // error, so we drive the settle handshake through AbortController. + const settleController = new AbortController(); + if (options.signal) { + if (options.signal.aborted) { + settleController.abort(); + } else { + options.signal.addEventListener('abort', () => settleController.abort(), { + once: true, + }); } + } - // Listen on every event type we emit server-side. Unknown types - // fall through to the default handler. - const forward = (evt: MessageEvent) => { - sawEvent = true; - try { - const parsed = JSON.parse(evt.data) as StreamChunk; - options.onChunk(parsed); - if ( - TERMINAL_STREAM_EVENT_TYPES.includes( - parsed.type as typeof TERMINAL_STREAM_EVENT_TYPES[number], - ) - ) { - settle(); - } - } catch (parseErr) { - // Malformed payload — surface as an error chunk but don't - // tear the stream down; native EventSource will reconnect. + return openManagedEventSource({ + url: withCursor, + events: STREAM_EVENT_TYPES, + withCredentials: true, + signal: settleController.signal, + onError: options.onError, + onConnectError: (evt) => + new RunEventsConnectError( + 'EventSource failed to connect before first event', + evt, + ), + onMessage: (name, data) => { + if ( + typeof data === 'object' && + data !== null && + (data as { __unparseable?: boolean }).__unparseable + ) { + // Surface parse failures as synthetic error chunks but keep + // the stream open — native EventSource reconnects on transient + // flaps. options.onChunk({ type: 'error', - error: `Failed to parse event: ${String(parseErr)}`, + error: `Failed to parse event: ${(data as { raw: string }).raw}`, }); - } - }; - - const eventNames = [ - 'content', - 'chunk', - 'thinking', - 'tool_start', - 'tool_end', - 'text_block_end', - 'snapshot', - 'interrupt', - 'citation', - 'usage', - 'done', - 'cancelled', - 'error', - 'failed', - 'stream_started', - ]; - for (const name of eventNames) { - es.addEventListener(name, forward as EventListener); - } - es.onmessage = forward; - - es.onerror = (evt) => { - options.onError?.(evt); - // Initial-connect grace: if onerror fires before any event has - // arrived AND within 500ms of construction, treat as a boundary - // failure (401 / 404 / 503) and settle with a typed error - // instead of relying on EventSource's silent auto-reconnect - // loop. After the grace expires the reconnect path handles - // transient mid-run flaps as before. - if (!sawEvent && Date.now() - startedAt < 500) { - settle(new RunEventsConnectError( - 'EventSource failed to connect before first event', - evt, - )); return; } - // Don't settle here — EventSource auto-reconnects on transient - // errors. The server closes the connection with a terminal - // `done` / `error` / `cancelled` / `failed` event when the run - // ends, at which point the forward() path settles the promise. - }; + // Some server payloads omit `type` because it duplicates the SSE + // event name; back-fill it so downstream consumers can rely on + // StreamChunk.type being populated. + const parsed = data as Partial & Record; + if (!parsed.type) { + parsed.type = name as StreamChunk['type']; + } + options.onChunk(parsed as StreamChunk); + if (isTerminalEvent(name) || isTerminalEvent(String(parsed.type))) { + settleController.abort(); + } + }, }); } @@ -534,18 +491,8 @@ export function subscribeRunEvents( // Active-run sidebar fan-out (per-tenant status SSE) // --------------------------------------------------------------------------- -export type ActiveRunSidebarEventType = 'snapshot' | 'update'; - -export interface ActiveRunSidebarEvent { - event: ActiveRunSidebarEventType; - sessionId: string; - runId: string; - status: 'queued' | 'streaming' | 'completed' | 'cancelled' | 'failed'; - updatedAt: number; -} - export interface SubscribeActiveRunsOptions { - onEvent: (event: ActiveRunSidebarEvent) => void; + onEvent: (event: ActiveRunDelivery) => void; onError?: (event: Event) => void; signal?: AbortSignal; } @@ -558,42 +505,32 @@ export interface SubscribeActiveRunsOptions { * * Never resolves on its own — the caller should abort via the signal * when the component unmounts; the returned promise only settles on - * signal abort. + * signal abort or initial-connect failure. */ export function subscribeActiveRuns( deps: StreamEventsDeps, options: SubscribeActiveRunsOptions ): Promise { const url = buildStreamUrl(deps, '/active-runs'); - return new Promise((resolve) => { - const es = new EventSource(url, { withCredentials: true }); - - const close = () => { - es.close(); - resolve(); - }; - - if (options.signal) { - if (options.signal.aborted) { - close(); + return openManagedEventSource({ + url, + events: ['snapshot', 'update'], + withCredentials: true, + signal: options.signal, + onError: options.onError, + onMessage: (name, data) => { + if ( + typeof data !== 'object' || + data === null || + (data as { __unparseable?: boolean }).__unparseable + ) { + // Server contract guarantees JSON; silently skip malformed + // frames rather than surfacing them as fake entries. return; } - options.signal.addEventListener('abort', close, { once: true }); - } - - const forward = (event: ActiveRunSidebarEventType) => - (msg: MessageEvent) => { - try { - const body = JSON.parse(msg.data) as Omit; - options.onEvent({ event, ...body }); - } catch { - // ignore malformed payloads — server contract guarantees JSON - } - }; - - es.addEventListener('snapshot', forward('snapshot') as EventListener); - es.addEventListener('update', forward('update') as EventListener); - es.onerror = (evt) => options.onError?.(evt); + const body = data as Omit; + options.onEvent({ event: name as ActiveRunDelivery['event'], ...body }); + }, }); } diff --git a/ui/src/bichat/data/openManagedEventSource.ts b/ui/src/bichat/data/openManagedEventSource.ts new file mode 100644 index 0000000..3f183b2 --- /dev/null +++ b/ui/src/bichat/data/openManagedEventSource.ts @@ -0,0 +1,118 @@ +/** + * openManagedEventSource — shared EventSource primitive. + * + * Centralises the listener wiring, AbortSignal handling, and the + * 500ms initial-connect grace used by subscribeRunEvents and + * subscribeActiveRuns. Future fan-out helpers can adopt this instead + * of duplicating the EventSource boilerplate. + * + * Behaviour: + * - Registers an addEventListener for every name in `events`. The + * callback receives the event name and the JSON-parsed payload. + * - On JSON parse failure, the callback is invoked with + * `{ __unparseable: true, raw: }` so the caller can log or + * surface a synthetic error. The subscription stays open; native + * EventSource reconnect handles transient flaps. + * - Initial-connect grace: when `onerror` fires before any event is + * received AND within `connectGraceMs` of construction, the + * returned promise rejects with either the caller-provided error + * (via `onConnectError`) or a generic Error. After the grace + * window expires, `onerror` is non-fatal — the browser reconnects + * silently. + * - AbortSignal close: closing the source resolves the promise. + * Already-aborted signals short-circuit before construction. + */ + +export interface UnparseableEventPayload { + __unparseable: true; + raw: string; +} + +export interface OpenManagedEventSourceOptions { + url: string; + /** Every named event to subscribe to via addEventListener. */ + events: readonly string[]; + /** + * Called for each event received. `data` is either the JSON-parsed + * payload or a sentinel `{ __unparseable: true, raw }` when the + * payload could not be parsed. + */ + onMessage: (name: string, data: unknown) => void; + /** Optional hook for raw EventSource errors (all flaps, not just initial). */ + onError?: (e: Event) => void; + /** Close the source and resolve the promise when the signal aborts. */ + signal?: AbortSignal; + /** Forwarded to the EventSource constructor. Defaults to true. */ + withCredentials?: boolean; + /** Initial-connect grace window in ms. Default 500. Set 0 to disable the probe. */ + connectGraceMs?: number; + /** + * Maps the raw onerror Event into a rejection Error. Called only + * when the error fires within the grace window and before any event + * has been received. Defaults to a plain Error. + */ + onConnectError?: (e: Event) => Error; +} + +export function openManagedEventSource( + opts: OpenManagedEventSourceOptions, +): Promise { + const graceMs = opts.connectGraceMs ?? 500; + return new Promise((resolve, reject) => { + const startedAt = Date.now(); + const es = new EventSource(opts.url, { + withCredentials: opts.withCredentials ?? true, + }); + let settled = false; + let sawEvent = false; + + const settle = (err?: Error) => { + if (settled) {return;} + settled = true; + es.close(); + if (err) { + reject(err); + } else { + resolve(); + } + }; + + if (opts.signal) { + if (opts.signal.aborted) { + settle(); + return; + } + opts.signal.addEventListener('abort', () => settle(), { once: true }); + } + + const forward = (name: string) => (evt: MessageEvent) => { + sawEvent = true; + let parsed: unknown; + try { + parsed = JSON.parse(evt.data); + } catch { + parsed = { __unparseable: true, raw: String(evt.data) } as UnparseableEventPayload; + } + try { + opts.onMessage(name, parsed); + } catch { + // Caller errors are non-fatal — EventSource stays open. + } + }; + + for (const name of opts.events) { + es.addEventListener(name, forward(name) as EventListener); + } + + es.onerror = (evt) => { + opts.onError?.(evt); + if (graceMs > 0 && !sawEvent && Date.now() - startedAt < graceMs) { + const err = opts.onConnectError + ? opts.onConnectError(evt) + : new Error('EventSource failed to connect before first event'); + settle(err); + } + // Otherwise let native reconnect handle the flap. + }; + }); +} diff --git a/ui/src/bichat/data/subscribeActiveRuns.test.ts b/ui/src/bichat/data/subscribeActiveRuns.test.ts new file mode 100644 index 0000000..cbd94c4 --- /dev/null +++ b/ui/src/bichat/data/subscribeActiveRuns.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { subscribeActiveRuns } from './MessageTransport'; +import type { ActiveRunDelivery } from '../types'; + +// Standalone fake EventSource duplicated from MessageTransport.test.ts +// so these two files can be moved independently; both exercise the +// native listener contract and both are purely local. +class FakeEventSource { + static instances: FakeEventSource[] = []; + listeners = new Map void>>(); + onerror: ((e: Event) => void) | null = null; + readyState = 0; + closed = false; + constructor(public url: string, public init?: EventSourceInit) { + FakeEventSource.instances.push(this); + } + addEventListener(name: string, cb: (e: MessageEvent) => void) { + if (!this.listeners.has(name)) {this.listeners.set(name, new Set());} + this.listeners.get(name)!.add(cb); + } + removeEventListener() {} + close() { + this.readyState = 2; + this.closed = true; + } + emit(name: string, data: unknown) { + const payload = typeof data === 'string' ? data : JSON.stringify(data); + const evt = new MessageEvent(name, { data: payload }); + for (const cb of this.listeners.get(name) ?? []) {cb(evt);} + } +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function install(): typeof FakeEventSource { + FakeEventSource.instances = []; + vi.stubGlobal('EventSource', FakeEventSource as unknown as typeof EventSource); + return FakeEventSource; +} + +describe('subscribeActiveRuns', () => { + it('surfaces snapshot then update events through onEvent', async () => { + install(); + const events: ActiveRunDelivery[] = []; + const ctrl = new AbortController(); + const promise = subscribeActiveRuns( + { baseUrl: '', streamEndpoint: '/stream' }, + { + onEvent: (e) => events.push(e), + signal: ctrl.signal, + }, + ); + await Promise.resolve(); + const es = FakeEventSource.instances[0]; + es.emit('snapshot', { + sessionId: 'sess-1', runId: 'run-1', status: 'streaming', updatedAt: 1, + }); + es.emit('snapshot', { + sessionId: 'sess-2', runId: 'run-2', status: 'queued', updatedAt: 2, + }); + es.emit('update', { + sessionId: 'sess-1', runId: 'run-1', status: 'completed', updatedAt: 3, + }); + ctrl.abort(); + await promise; + expect(events).toEqual([ + { event: 'snapshot', sessionId: 'sess-1', runId: 'run-1', status: 'streaming', updatedAt: 1 }, + { event: 'snapshot', sessionId: 'sess-2', runId: 'run-2', status: 'queued', updatedAt: 2 }, + { event: 'update', sessionId: 'sess-1', runId: 'run-1', status: 'completed', updatedAt: 3 }, + ]); + }); + + it('swallows malformed payloads silently', async () => { + install(); + const events: ActiveRunDelivery[] = []; + const ctrl = new AbortController(); + const promise = subscribeActiveRuns( + { baseUrl: '', streamEndpoint: '/stream' }, + { + onEvent: (e) => events.push(e), + signal: ctrl.signal, + }, + ); + await Promise.resolve(); + const es = FakeEventSource.instances[0]; + es.emit('snapshot', '{not-json'); + es.emit('update', { + sessionId: 'sess-1', runId: 'run-1', status: 'streaming', updatedAt: 42, + }); + ctrl.abort(); + await promise; + expect(events).toEqual([ + { event: 'update', sessionId: 'sess-1', runId: 'run-1', status: 'streaming', updatedAt: 42 }, + ]); + }); + + it('resolves and closes EventSource when the AbortSignal fires', async () => { + install(); + const ctrl = new AbortController(); + const promise = subscribeActiveRuns( + { baseUrl: '', streamEndpoint: '/stream' }, + { onEvent: () => {}, signal: ctrl.signal }, + ); + await Promise.resolve(); + const es = FakeEventSource.instances[0]; + expect(es.closed).toBe(false); + ctrl.abort(); + await expect(promise).resolves.toBeUndefined(); + expect(es.closed).toBe(true); + }); + + it('forwards errors through options.onError', async () => { + install(); + const errors: Event[] = []; + const ctrl = new AbortController(); + const promise = subscribeActiveRuns( + { baseUrl: '', streamEndpoint: '/stream' }, + { + onEvent: () => {}, + onError: (evt) => errors.push(evt), + signal: ctrl.signal, + }, + ); + await Promise.resolve(); + const es = FakeEventSource.instances[0]; + // Emit one event first so the onerror we trigger below is classed + // as a transient flap (post-grace) rather than an initial-connect + // boundary failure. + es.emit('snapshot', { + sessionId: 'sess-1', runId: 'run-1', status: 'streaming', updatedAt: 1, + }); + es.onerror?.(new Event('error')); + expect(errors).toHaveLength(1); + ctrl.abort(); + await promise; + }); +}); diff --git a/ui/src/bichat/hooks/useActiveRuns.test.tsx b/ui/src/bichat/hooks/useActiveRuns.test.tsx new file mode 100644 index 0000000..3682de7 --- /dev/null +++ b/ui/src/bichat/hooks/useActiveRuns.test.tsx @@ -0,0 +1,137 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { useActiveRuns } from './useActiveRuns'; +import type { ActiveRunDelivery, ChatDataSource } from '../types'; + +type Subscribe = NonNullable; +type SubscribeOptions = Parameters[0]; + +interface FakeDataSource { + subscribeActiveRuns?: Subscribe; + aborted: boolean; + emit(evt: ActiveRunDelivery): void; +} + +function createDataSource(): FakeDataSource { + let capturedOnEvent: SubscribeOptions['onEvent'] | null = null; + const ds: FakeDataSource = { + aborted: false, + emit(evt) { + capturedOnEvent?.(evt); + }, + subscribeActiveRuns: (options: SubscribeOptions) => { + capturedOnEvent = options.onEvent; + options.signal?.addEventListener('abort', () => { + ds.aborted = true; + }); + return new Promise(() => { /* never resolves — caller aborts */ }); + }, + }; + return ds; +} + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('useActiveRuns', () => { + it('coalesces snapshot rows into a single ready flip', () => { + const ds = createDataSource(); + const { result } = renderHook(() => useActiveRuns(ds)); + + expect(result.current.ready).toBe(false); + + act(() => { + ds.emit({ event: 'snapshot', sessionId: 's1', runId: 'r1', status: 'streaming', updatedAt: 1 }); + ds.emit({ event: 'snapshot', sessionId: 's2', runId: 'r2', status: 'queued', updatedAt: 2 }); + }); + + // Ready not flipped until the 16ms flush tick fires. + expect(result.current.ready).toBe(false); + + act(() => { + vi.advanceTimersByTime(16); + }); + + expect(result.current.ready).toBe(true); + expect(Object.keys(result.current.runs)).toEqual(['s1', 's2']); + expect(result.current.status('s1')).toBe('streaming'); + expect(result.current.status('s2')).toBe('queued'); + }); + + it('removes terminal-status entries immediately when retainTerminalMs is 0', () => { + const ds = createDataSource(); + const { result } = renderHook(() => useActiveRuns(ds)); + + act(() => { + ds.emit({ event: 'update', sessionId: 's1', runId: 'r1', status: 'streaming', updatedAt: 1 }); + }); + expect(result.current.runs.s1?.status).toBe('streaming'); + + act(() => { + ds.emit({ event: 'update', sessionId: 's1', runId: 'r1', status: 'completed', updatedAt: 2 }); + }); + expect(result.current.runs.s1).toBeUndefined(); + }); + + it('retains terminal entries for retainTerminalMs then prunes', () => { + const ds = createDataSource(); + const { result } = renderHook(() => + useActiveRuns(ds, { retainTerminalMs: 1000 }) + ); + + act(() => { + ds.emit({ event: 'update', sessionId: 's1', runId: 'r1', status: 'completed', updatedAt: 10 }); + }); + // Entry still visible with terminal status. + expect(result.current.runs.s1?.status).toBe('completed'); + + act(() => { + vi.advanceTimersByTime(500); + }); + expect(result.current.runs.s1?.status).toBe('completed'); + + act(() => { + vi.advanceTimersByTime(600); + }); + expect(result.current.runs.s1).toBeUndefined(); + }); + + it('is a no-op when enabled is false', () => { + const ds = createDataSource(); + const subscribeSpy = vi.spyOn(ds, 'subscribeActiveRuns' as const); + const { result } = renderHook(() => useActiveRuns(ds, { enabled: false })); + expect(subscribeSpy).not.toHaveBeenCalled(); + expect(result.current.ready).toBe(false); + expect(result.current.runs).toEqual({}); + }); + + it('aborts the previous subscription when the dataSource reference swaps', () => { + const dsA = createDataSource(); + const dsB = createDataSource(); + const { rerender } = renderHook( + ({ ds }: { ds: FakeDataSource }) => useActiveRuns(ds), + { initialProps: { ds: dsA } }, + ); + expect(dsA.aborted).toBe(false); + rerender({ ds: dsB }); + expect(dsA.aborted).toBe(true); + expect(dsB.aborted).toBe(false); + }); + + it('flips ready after emptyStateTimeoutMs with no snapshot rows', () => { + const ds = createDataSource(); + const { result } = renderHook(() => + useActiveRuns(ds, { emptyStateTimeoutMs: 100 }) + ); + expect(result.current.ready).toBe(false); + act(() => { vi.advanceTimersByTime(100); }); + expect(result.current.ready).toBe(true); + }); +}); diff --git a/ui/src/bichat/hooks/useActiveRuns.ts b/ui/src/bichat/hooks/useActiveRuns.ts index c849301..f9a92c7 100644 --- a/ui/src/bichat/hooks/useActiveRuns.ts +++ b/ui/src/bichat/hooks/useActiveRuns.ts @@ -26,6 +26,19 @@ export interface UseActiveRunsOptions { enabled?: boolean; /** Optional hook for raw SSE errors. */ onError?: (event: Event) => void; + /** + * Keep terminal-status entries in the map for this many milliseconds + * before pruning them. Default 0 preserves the previous behaviour + * (synchronous delete). Useful when consumers want to render a + * "completed" pulse animation before the dot disappears. + */ + retainTerminalMs?: number; + /** + * How long to wait for an initial snapshot batch before declaring + * the hook `ready` when the server has zero active runs. + * Default 250ms. + */ + emptyStateTimeoutMs?: number; } export interface UseActiveRunsResult { @@ -53,6 +66,8 @@ export function useActiveRuns( onErrorRef.current = options.onError; const enabled = options.enabled ?? true; + const retainTerminalMs = options.retainTerminalMs ?? 0; + const emptyStateTimeoutMs = options.emptyStateTimeoutMs ?? 250; useEffect(() => { if (!enabled) {return;} @@ -66,6 +81,10 @@ export function useActiveRuns( let stagingTimer: ReturnType | undefined; const staging: Record = {}; let sawSnapshotRow = false; + // Pending terminal-removal timers keyed by sessionId so unmount + // can clear them and repeat terminals for the same session don't + // leak timers. + const pendingTerminalTimers = new Map>(); const flushSnapshot = () => { setRuns((prev) => ({ ...prev, ...staging })); @@ -89,20 +108,59 @@ export function useActiveRuns( } return; } - // update: apply immediately, prune on terminal status. - setRuns((prev) => { - const next = { ...prev }; - if (TERMINAL.has(evt.status)) { - delete next[evt.sessionId]; - } else { - next[evt.sessionId] = { + // update: apply immediately. + if (TERMINAL.has(evt.status)) { + if (retainTerminalMs <= 0) { + // Legacy synchronous delete path. + setRuns((prev) => { + if (!(evt.sessionId in prev)) {return prev;} + const next = { ...prev }; + delete next[evt.sessionId]; + return next; + }); + return; + } + // Retain the entry with its terminal status so the consumer + // can render a completion pulse, then prune after the + // retention window. + setRuns((prev) => ({ + ...prev, + [evt.sessionId]: { runId: evt.runId, status: evt.status, updatedAt: evt.updatedAt, - }; - } - return next; - }); + }, + })); + const existing = pendingTerminalTimers.get(evt.sessionId); + if (existing !== undefined) {clearTimeout(existing);} + const handle = setTimeout(() => { + pendingTerminalTimers.delete(evt.sessionId); + setRuns((prev) => { + if (!(evt.sessionId in prev)) {return prev;} + const next = { ...prev }; + delete next[evt.sessionId]; + return next; + }); + }, retainTerminalMs); + pendingTerminalTimers.set(evt.sessionId, handle); + return; + } + // Non-terminal update: write through and cancel any pending + // prune for this session (run restarted before the retention + // timer fired). + const pending = pendingTerminalTimers.get(evt.sessionId); + if (pending !== undefined) { + clearTimeout(pending); + pendingTerminalTimers.delete(evt.sessionId); + } + setRuns((prev) => ({ + ...prev, + [evt.sessionId]: { + runId: evt.runId, + status: evt.status, + updatedAt: evt.updatedAt, + }, + })); }, }); @@ -111,14 +169,18 @@ export function useActiveRuns( // spin forever. const readyTimeout = setTimeout(() => { if (!sawSnapshotRow) {setReady(true);} - }, 250); + }, emptyStateTimeoutMs); return () => { controller.abort(); if (stagingTimer !== undefined) {clearTimeout(stagingTimer);} clearTimeout(readyTimeout); + for (const handle of pendingTerminalTimers.values()) { + clearTimeout(handle); + } + pendingTerminalTimers.clear(); }; - }, [dataSource, enabled]); + }, [dataSource, enabled, retainTerminalMs, emptyStateTimeoutMs]); const status = useCallback( (sessionId: string) => runs[sessionId]?.status, diff --git a/ui/src/bichat/index.ts b/ui/src/bichat/index.ts index 360d1d8..3cbd25e 100644 --- a/ui/src/bichat/index.ts +++ b/ui/src/bichat/index.ts @@ -277,6 +277,12 @@ export { readTextBlockOffsets, type AssistantTextBlock, } from './utils/textBlocks'; +export { + STREAM_EVENT_TYPES, + TERMINAL_STREAM_EVENT_TYPES, + isTerminalEvent, + type StreamEventType, +} from './utils/eventNames'; // ============================================================================= // Types diff --git a/ui/src/bichat/utils/eventNames.test.ts b/ui/src/bichat/utils/eventNames.test.ts new file mode 100644 index 0000000..c2a1622 --- /dev/null +++ b/ui/src/bichat/utils/eventNames.test.ts @@ -0,0 +1,49 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { STREAM_EVENT_TYPES, TERMINAL_STREAM_EVENT_TYPES, isTerminalEvent } from './eventNames'; + +const SDK_EVENTS_FILE = '/Users/diyorkhaydarov/Projects/sdk/iota-sdk/pkg/httpdto/stream_events.go'; + +describe('STREAM_EVENT_TYPES', () => { + it('contains a unique, non-empty set of lowercase tokens', () => { + expect(STREAM_EVENT_TYPES.length).toBeGreaterThan(0); + const set = new Set(STREAM_EVENT_TYPES); + expect(set.size).toBe(STREAM_EVENT_TYPES.length); + for (const name of STREAM_EVENT_TYPES) { + expect(name).toMatch(/^[a-z_]+$/); + } + }); + + it('exposes TERMINAL_STREAM_EVENT_TYPES as a strict subset', () => { + const all = new Set(STREAM_EVENT_TYPES); + for (const t of TERMINAL_STREAM_EVENT_TYPES) { + expect(all.has(t)).toBe(true); + } + }); + + it('isTerminalEvent narrows only the terminal set', () => { + for (const t of TERMINAL_STREAM_EVENT_TYPES) { + expect(isTerminalEvent(t)).toBe(true); + } + expect(isTerminalEvent('chunk')).toBe(false); + expect(isTerminalEvent('content')).toBe(false); + expect(isTerminalEvent('nope')).toBe(false); + }); +}); + +// Drift guard. Reads the Go source of truth at test time and diffs +// the constant set against STREAM_EVENT_TYPES. Silently skipped when +// the SDK sibling tree is not checked out next to this repo. +describe.skipIf(!existsSync(SDK_EVENTS_FILE))('STREAM_EVENT_TYPES drift guard', () => { + it('matches the Go StreamEventType constant set', () => { + const src = readFileSync(SDK_EVENTS_FILE, 'utf8'); + const re = /StreamEvent[A-Za-z0-9_]+\s+StreamEventType\s*=\s*"([^"]+)"/g; + const goValues = new Set(); + for (const m of src.matchAll(re)) { + goValues.add(m[1]); + } + const tsValues = new Set(STREAM_EVENT_TYPES); + expect(goValues.size).toBeGreaterThan(0); + expect(Array.from(tsValues).sort()).toEqual(Array.from(goValues).sort()); + }); +}); diff --git a/ui/src/bichat/utils/eventNames.ts b/ui/src/bichat/utils/eventNames.ts new file mode 100644 index 0000000..49984bf --- /dev/null +++ b/ui/src/bichat/utils/eventNames.ts @@ -0,0 +1,52 @@ +/** + * Hand-mirrored copy of the backend's pkg/httpdto.StreamEventType + * constant set. Used by subscribeRunEvents / subscribeActiveRuns / + * openManagedEventSource to register addEventListener handlers that + * match every `event:` label the server emits. + * + * Drift between this file and the Go definition is caught by + * eventNames.test.ts — the test reads the sibling Go source at test + * time and diffs the constant set. When the Go file is absent (CI + * without the SDK sidecar checkout) the drift guard self-skips. + */ + +export const STREAM_EVENT_TYPES = [ + 'chunk', + 'content', + 'thinking', + 'tool_start', + 'tool_end', + 'text_block_end', + 'snapshot', + 'interrupt', + 'citation', + 'usage', + 'ping', + 'stream_started', + 'done', + 'cancelled', + 'error', + 'failed', +] as const; + +export type StreamEventType = typeof STREAM_EVENT_TYPES[number]; + +/** + * Subset of {@link STREAM_EVENT_TYPES} that terminates a run from the + * client's perspective. Callers should close the EventSource and + * settle their promise on any of these. + */ +export const TERMINAL_STREAM_EVENT_TYPES: readonly StreamEventType[] = [ + 'done', + 'cancelled', + 'error', + 'failed', +]; + +/** + * Narrow + type-guard helper. Returns true when `name` is one of the + * terminal stream event types. + */ +export function isTerminalEvent(name: string): name is StreamEventType { + return TERMINAL_STREAM_EVENT_TYPES.includes(name as StreamEventType); +} diff --git a/vitest.config.ts b/vitest.config.ts index 2e88f2e..62b2bda 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { - include: ['ui/src/**/*.test.ts'], + include: ['ui/src/**/*.test.ts', 'ui/src/**/*.test.tsx'], exclude: ['ui/src/applet-runtime/**', 'node_modules/**'], }, }) From b6cebc2401e2aeeeff818abd57edffba28b806d7 Mon Sep 17 00:00:00 2001 From: Diyor Khaydarov Date: Wed, 15 Apr 2026 13:42:25 +0500 Subject: [PATCH 4/5] fix(bichat): address review feedback on resilient run client - Snapshot rows now clear any pending terminal-removal timer for the same sessionId before merging into staging. Previously, when retainTerminalMs > 0, an in-flight deletion could wipe a newly reconnected streaming snapshot. - useActiveRuns attaches .catch() on the subscribeActiveRuns promise so initial-connect failures (401 / 503) route through the onError callback instead of surfacing as unhandled rejections. - eventNames drift-guard test resolves the sibling iota-sdk path relative to __dirname so it runs anywhere the repos are checked out side-by-side; only skips (with a console.warn) when the sibling file is truly absent. --- ui/src/bichat/hooks/useActiveRuns.test.tsx | 65 ++++++++++++++++++++++ ui/src/bichat/hooks/useActiveRuns.ts | 23 +++++++- ui/src/bichat/utils/eventNames.test.ts | 22 +++++++- 3 files changed, 107 insertions(+), 3 deletions(-) diff --git a/ui/src/bichat/hooks/useActiveRuns.test.tsx b/ui/src/bichat/hooks/useActiveRuns.test.tsx index 3682de7..b3dbf45 100644 --- a/ui/src/bichat/hooks/useActiveRuns.test.tsx +++ b/ui/src/bichat/hooks/useActiveRuns.test.tsx @@ -134,4 +134,69 @@ describe('useActiveRuns', () => { act(() => { vi.advanceTimersByTime(100); }); expect(result.current.ready).toBe(true); }); + + // APL-1 regression: a stale terminal-removal timer must not wipe a + // newly arrived streaming snapshot for the same sessionId (the + // native EventSource auto-reconnect path redelivers snapshot rows). + it('cancels pending terminal prune when a new snapshot arrives for the same session', () => { + const ds = createDataSource(); + const { result } = renderHook(() => + useActiveRuns(ds, { retainTerminalMs: 1000 }) + ); + + // Arm a terminal prune for s1. + act(() => { + ds.emit({ event: 'update', sessionId: 's1', runId: 'r1', status: 'cancelled', updatedAt: 1 }); + }); + expect(result.current.runs.s1?.status).toBe('cancelled'); + + // Before the 1000ms retention window elapses, a fresh snapshot + // arrives (reconnect or a new run starting on the same session). + act(() => { + vi.advanceTimersByTime(200); + ds.emit({ event: 'snapshot', sessionId: 's1', runId: 'r2', status: 'streaming', updatedAt: 2 }); + }); + + // Flush the 16ms snapshot coalescer. + act(() => { vi.advanceTimersByTime(16); }); + expect(result.current.runs.s1?.status).toBe('streaming'); + expect(result.current.runs.s1?.runId).toBe('r2'); + + // Advance well past the original retention window. The stale + // prune must have been cancelled — s1 should still be present. + act(() => { vi.advanceTimersByTime(1500); }); + expect(result.current.runs.s1?.status).toBe('streaming'); + expect(result.current.runs.s1?.runId).toBe('r2'); + }); + + // APL-2 regression: a rejection from subscribeActiveRuns (initial + // connect failure, 401/503) must surface via onError rather than + // leaking as an unhandled promise rejection. + it('routes subscribeActiveRuns rejections through onError', async () => { + const onError = vi.fn(); + const subscribeActiveRuns: Subscribe = () => + Promise.reject(new Error('mock connect failure')); + const ds = { subscribeActiveRuns } as unknown as FakeDataSource; + + // Capture unhandled rejections so the test fails if we leak. + const unhandled: unknown[] = []; + const onUnhandled = (err: unknown) => unhandled.push(err); + process.on('unhandledRejection', onUnhandled); + + try { + renderHook(() => useActiveRuns(ds, { onError })); + + // Flush the rejected microtask queue. + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][0]).toBeInstanceOf(Event); + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', onUnhandled); + } + }); }); diff --git a/ui/src/bichat/hooks/useActiveRuns.ts b/ui/src/bichat/hooks/useActiveRuns.ts index f9a92c7..b6d4226 100644 --- a/ui/src/bichat/hooks/useActiveRuns.ts +++ b/ui/src/bichat/hooks/useActiveRuns.ts @@ -92,12 +92,22 @@ export function useActiveRuns( stagingTimer = undefined; }; - dataSource.subscribeActiveRuns({ + const subscription = dataSource.subscribeActiveRuns({ signal: controller.signal, onError: (evt) => onErrorRef.current?.(evt), onEvent: (evt) => { if (evt.event === 'snapshot') { sawSnapshotRow = true; + // If a terminal-removal timer is pending for this sessionId + // (e.g. the previous run completed and we're inside the + // retention window), cancel it. A fresh snapshot — typically + // delivered after native EventSource reconnect or when the + // user restarts a run — must NOT be wiped by a stale prune. + const pending = pendingTerminalTimers.get(evt.sessionId); + if (pending !== undefined) { + clearTimeout(pending); + pendingTerminalTimers.delete(evt.sessionId); + } staging[evt.sessionId] = { runId: evt.runId, status: evt.status, @@ -164,6 +174,17 @@ export function useActiveRuns( }, }); + // The subscription promise rejects on initial-connect failures + // (401/503 before the grace window elapses). Route that through + // onError instead of leaking an unhandled rejection to the global + // scope — callers expect a single failure surface. + subscription?.catch((err: unknown) => { + if (controller.signal.aborted) {return;} + const surface = + err instanceof Event ? err : new Event('error'); + onErrorRef.current?.(surface); + }); + // If the server has zero active runs on connect it never emits a // snapshot row; mark ready after a brief idle so consumers don't // spin forever. diff --git a/ui/src/bichat/utils/eventNames.test.ts b/ui/src/bichat/utils/eventNames.test.ts index c2a1622..f38ce09 100644 --- a/ui/src/bichat/utils/eventNames.test.ts +++ b/ui/src/bichat/utils/eventNames.test.ts @@ -1,8 +1,26 @@ import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { STREAM_EVENT_TYPES, TERMINAL_STREAM_EVENT_TYPES, isTerminalEvent } from './eventNames'; -const SDK_EVENTS_FILE = '/Users/diyorkhaydarov/Projects/sdk/iota-sdk/pkg/httpdto/stream_events.go'; +// Resolve the Go source of truth relative to this test file so the +// drift guard runs on any checkout where `applets` and `iota-sdk` are +// siblings. Five ".." hops: utils -> bichat -> src -> ui -> applets -> +// sdk, then down into iota-sdk/pkg/httpdto/. +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SDK_EVENTS_FILE = resolve( + __dirname, + '../../../../../iota-sdk/pkg/httpdto/stream_events.go', +); +const SDK_EVENTS_FILE_EXISTS = existsSync(SDK_EVENTS_FILE); +if (!SDK_EVENTS_FILE_EXISTS) { + // Not silent: surface a notice so CI runs without the sibling + // checkout make the skipped guard visible. + console.warn( + `[eventNames.test] skipping drift guard — SDK sibling not found at ${SDK_EVENTS_FILE}`, + ); +} describe('STREAM_EVENT_TYPES', () => { it('contains a unique, non-empty set of lowercase tokens', () => { @@ -34,7 +52,7 @@ describe('STREAM_EVENT_TYPES', () => { // Drift guard. Reads the Go source of truth at test time and diffs // the constant set against STREAM_EVENT_TYPES. Silently skipped when // the SDK sibling tree is not checked out next to this repo. -describe.skipIf(!existsSync(SDK_EVENTS_FILE))('STREAM_EVENT_TYPES drift guard', () => { +describe.skipIf(!SDK_EVENTS_FILE_EXISTS)('STREAM_EVENT_TYPES drift guard', () => { it('matches the Go StreamEventType constant set', () => { const src = readFileSync(SDK_EVENTS_FILE, 'utf8'); const re = /StreamEvent[A-Za-z0-9_]+\s+StreamEventType\s*=\s*"([^"]+)"/g; From 68e0032769ef9f47cf216c4e2bf11e1da714a116 Mon Sep 17 00:00:00 2001 From: Diyor Khaydarov Date: Wed, 15 Apr 2026 14:14:58 +0500 Subject: [PATCH 5/5] fix(bichat): narrow isTerminalEvent guard to TerminalStreamEventType CodeRabbit flagged that the type guard claimed to narrow to the full StreamEventType union while only checking membership in the 4-element terminal subset. Callers receiving `true` now get TerminalStreamEventType, which is the actual constrained set. TERMINAL_STREAM_EVENT_TYPES is retyped via `as const satisfies readonly StreamEventType[]` so the tuple's literal types power the narrower derived type without losing the containment invariant. --- ui/src/bichat/index.ts | 1 + ui/src/bichat/utils/eventNames.ts | 14 +++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/ui/src/bichat/index.ts b/ui/src/bichat/index.ts index 3cbd25e..d973951 100644 --- a/ui/src/bichat/index.ts +++ b/ui/src/bichat/index.ts @@ -282,6 +282,7 @@ export { TERMINAL_STREAM_EVENT_TYPES, isTerminalEvent, type StreamEventType, + type TerminalStreamEventType, } from './utils/eventNames'; // ============================================================================= diff --git a/ui/src/bichat/utils/eventNames.ts b/ui/src/bichat/utils/eventNames.ts index 49984bf..e32edfd 100644 --- a/ui/src/bichat/utils/eventNames.ts +++ b/ui/src/bichat/utils/eventNames.ts @@ -36,17 +36,21 @@ export type StreamEventType = typeof STREAM_EVENT_TYPES[number]; * client's perspective. Callers should close the EventSource and * settle their promise on any of these. */ -export const TERMINAL_STREAM_EVENT_TYPES: readonly StreamEventType[] = [ +export const TERMINAL_STREAM_EVENT_TYPES = [ 'done', 'cancelled', 'error', 'failed', -]; +] as const satisfies readonly StreamEventType[]; + +export type TerminalStreamEventType = typeof TERMINAL_STREAM_EVENT_TYPES[number]; /** * Narrow + type-guard helper. Returns true when `name` is one of the - * terminal stream event types. + * terminal stream event types. The guard narrows precisely to the + * terminal subset — callers receiving `true` get {@link TerminalStreamEventType}, + * not the full {@link StreamEventType} union. */ -export function isTerminalEvent(name: string): name is StreamEventType { - return TERMINAL_STREAM_EVENT_TYPES.includes(name as StreamEventType); +export function isTerminalEvent(name: string): name is TerminalStreamEventType { + return (TERMINAL_STREAM_EVENT_TYPES as readonly string[]).includes(name); }