feat: harden workflow gates and expose token usage

This commit is contained in:
2026-07-21 12:14:26 +02:00
parent 8aa1676811
commit 2ff63d371f
20 changed files with 604 additions and 53 deletions
+66 -1
View File
@@ -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);