feat: harden workflow gates and expose token usage
This commit is contained in:
@@ -4,6 +4,68 @@ import { joinBackendPath } from "../api/runtime-config";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
import type { StreamEvent } from "../api/types";
|
||||
|
||||
const STREAM_UPDATE_INTERVAL_MS = 100;
|
||||
|
||||
function isStreamingDelta(
|
||||
event: StreamEvent,
|
||||
): event is Extract<StreamEvent, { type: "text_delta" | "activity_delta" }> {
|
||||
return event.type === "text_delta" || event.type === "activity_delta";
|
||||
}
|
||||
|
||||
export function createStreamEventCoalescer(
|
||||
applyEvent: (event: StreamEvent) => void,
|
||||
intervalMs = STREAM_UPDATE_INTERVAL_MS,
|
||||
) {
|
||||
let pending: Extract<StreamEvent, { type: "text_delta" | "activity_delta" }> | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let disposed = false;
|
||||
|
||||
const flushPending = () => {
|
||||
if (!pending) return;
|
||||
const event = pending;
|
||||
pending = null;
|
||||
applyEvent(event);
|
||||
};
|
||||
|
||||
const schedule = () => {
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
if (!pending || disposed) return;
|
||||
flushPending();
|
||||
schedule();
|
||||
}, intervalMs);
|
||||
};
|
||||
|
||||
return {
|
||||
push(event: StreamEvent) {
|
||||
if (disposed) return;
|
||||
if (!isStreamingDelta(event)) {
|
||||
flushPending();
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
applyEvent(event);
|
||||
return;
|
||||
}
|
||||
if (!timer) {
|
||||
applyEvent(event);
|
||||
schedule();
|
||||
return;
|
||||
}
|
||||
if (pending && pending.type !== event.type) flushPending();
|
||||
pending = pending
|
||||
? { ...event, text: pending.text + event.text }
|
||||
: event;
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
flushPending();
|
||||
disposed = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useSessionStream(
|
||||
sessionId: string | null,
|
||||
generation = 0,
|
||||
@@ -36,6 +98,7 @@ export function useSessionStream(
|
||||
: "";
|
||||
const es = new EventSource(joinBackendPath(BASE, `/sessions/${sessionId}/events${query}`));
|
||||
const identity = { source: es, sessionId, cursorResetEpoch };
|
||||
const coalescer = createStreamEventCoalescer(applyEvent);
|
||||
activeSource.current = identity;
|
||||
es.onopen = () => {
|
||||
if (activeSource.current === identity) setConnected(true);
|
||||
@@ -48,7 +111,7 @@ export function useSessionStream(
|
||||
if (activeSource.current !== identity) return;
|
||||
if (ev.lastEventId) cursor.current.lastEventId = ev.lastEventId;
|
||||
try {
|
||||
applyEvent(JSON.parse(ev.data) as StreamEvent);
|
||||
coalescer.push(JSON.parse(ev.data) as StreamEvent);
|
||||
} catch {
|
||||
/* ignore malformed */
|
||||
}
|
||||
@@ -62,6 +125,7 @@ export function useSessionStream(
|
||||
"text_delta",
|
||||
"activity_delta",
|
||||
"activity_event",
|
||||
"usage",
|
||||
"info",
|
||||
"system_event",
|
||||
] as const;
|
||||
@@ -71,6 +135,7 @@ export function useSessionStream(
|
||||
for (const name of namedEvents) es.removeEventListener(name, handle);
|
||||
es.onmessage = null;
|
||||
es.close();
|
||||
coalescer.dispose();
|
||||
if (activeSource.current === identity) {
|
||||
activeSource.current = null;
|
||||
setConnected(false);
|
||||
|
||||
Reference in New Issue
Block a user