feat: harden workflow gates and expose token usage
This commit is contained in:
@@ -75,6 +75,14 @@ export interface ActivityEntry {
|
||||
status?: "running" | "completed" | "failed";
|
||||
}
|
||||
|
||||
export interface TokenUsage {
|
||||
input: number;
|
||||
cacheRead: number;
|
||||
output: number;
|
||||
totalTokens: number;
|
||||
contextWindow: number;
|
||||
}
|
||||
|
||||
export type StreamEvent =
|
||||
| { type: "ui_request"; ui_request: WidgetDescriptor }
|
||||
| { type: "text_delta"; text: string }
|
||||
@@ -88,6 +96,7 @@ export type StreamEvent =
|
||||
status: "running" | "completed" | "failed";
|
||||
};
|
||||
}
|
||||
| { type: "usage"; usage: TokenUsage }
|
||||
| { type: "info"; level?: "info" | "warning" | "error"; text: string }
|
||||
| { type: "system_event"; event: string };
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { server } from "../test/msw";
|
||||
import { SteerInput } from "./SteerInput";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
import { ComposerFooter, ContextGauge, SteerInput } from "./SteerInput";
|
||||
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
@@ -83,6 +85,52 @@ test("marks the composer as awaiting input only when requested", () => {
|
||||
expect(input).toHaveClass("thot-awaiting-input");
|
||||
});
|
||||
|
||||
test("the context gauge uses green, yellow, and red at the requested thresholds", () => {
|
||||
const { rerender } = render(<ContextGauge pct={0.699} />);
|
||||
expect(screen.getByLabelText("Context usage 70%")).toHaveAttribute("data-level", "green");
|
||||
|
||||
rerender(<ContextGauge pct={0.7} />);
|
||||
expect(screen.getByLabelText("Context usage 70%")).toHaveAttribute("data-level", "yellow");
|
||||
|
||||
rerender(<ContextGauge pct={0.85} />);
|
||||
expect(screen.getByLabelText("Context usage 85%")).toHaveAttribute("data-level", "yellow");
|
||||
|
||||
rerender(<ContextGauge pct={0.851} />);
|
||||
expect(screen.getByLabelText("Context usage 85%")).toHaveAttribute("data-level", "red");
|
||||
});
|
||||
|
||||
test("footer shows cumulative k-token counters after workspace and context gauge after thinking", async () => {
|
||||
server.use(
|
||||
http.get("http://localhost:8787/settings", () => HttpResponse.json({
|
||||
workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium",
|
||||
})),
|
||||
http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{ name: "psd" }])),
|
||||
http.get("http://localhost:8787/models", () => HttpResponse.json({
|
||||
models: [{ provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }],
|
||||
})),
|
||||
);
|
||||
useSessionStore.getState().resetSession();
|
||||
useSessionStore.getState().applyEvent({
|
||||
type: "usage",
|
||||
usage: {
|
||||
input: 110_348,
|
||||
cacheRead: 633_344,
|
||||
output: 33_652,
|
||||
totalTokens: 55_935,
|
||||
contextWindow: 200_000,
|
||||
},
|
||||
});
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><ComposerFooter /></QueryClientProvider>);
|
||||
|
||||
const counters = screen.getByText("110k/633k/34k");
|
||||
const workspace = await screen.findByRole("combobox", { name: "Workspace" });
|
||||
const gauge = screen.getByLabelText("Context usage 28%");
|
||||
const thinking = await screen.findByRole("combobox", { name: "Thinking level" });
|
||||
expect(workspace.compareDocumentPosition(counters) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(thinking.compareDocumentPosition(gauge) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
test("pulses the stop dot only while the harness is working", () => {
|
||||
const { rerender } = render(<SteerInput sessionId="s1" working />);
|
||||
const dot = () =>
|
||||
|
||||
@@ -151,9 +151,8 @@ export function SteerInput({
|
||||
|
||||
/**
|
||||
* Status strip beneath the composer. Left: the active workspace selector.
|
||||
* Right: live model + thinking-level selectors (these replace the old Settings
|
||||
* dialog and persist through PUT /settings) plus a context-usage gauge
|
||||
* (placeholder until the backend reports token usage).
|
||||
* Right: live context usage plus model + thinking-level selectors (these replace
|
||||
* the old Settings dialog and persist through PUT /settings).
|
||||
*/
|
||||
export function ComposerFooter() {
|
||||
const qc = useQueryClient();
|
||||
@@ -161,6 +160,7 @@ export function ComposerFooter() {
|
||||
const { data: workspaces = [] } = useQuery({ queryKey: ["workspaces"], queryFn: listWorkspaces });
|
||||
const { data: modelsData } = useQuery({ queryKey: ["models"], queryFn: listModels });
|
||||
const models = modelsData?.models ?? [];
|
||||
const tokenUsage = useSessionStore((state) => state.tokenUsage);
|
||||
|
||||
const workspace = settings?.workspace ?? "";
|
||||
const model = settings?.model ?? "";
|
||||
@@ -178,20 +178,31 @@ export function ComposerFooter() {
|
||||
}
|
||||
|
||||
const knownModel = models.some((m) => m.id === model);
|
||||
const contextPct = tokenUsage && tokenUsage.contextWindow > 0
|
||||
? tokenUsage.totalTokens / tokenUsage.contextWindow
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 border-t border-border/60 px-3 py-1.5">
|
||||
<FooterSelect aria-label="Workspace" value={workspace} onChange={(v) => update({ workspace: v })}>
|
||||
{workspaces.length === 0 ? (
|
||||
<option value="">— workspace —</option>
|
||||
) : (
|
||||
workspaces.map((w) => (
|
||||
<option key={w.name} value={w.name}>
|
||||
{w.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</FooterSelect>
|
||||
<div className="flex items-center gap-2">
|
||||
<FooterSelect aria-label="Workspace" value={workspace} onChange={(v) => update({ workspace: v })}>
|
||||
{workspaces.length === 0 ? (
|
||||
<option value="">— workspace —</option>
|
||||
) : (
|
||||
workspaces.map((w) => (
|
||||
<option key={w.name} value={w.name}>
|
||||
{w.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</FooterSelect>
|
||||
<span
|
||||
className="whitespace-nowrap font-mono text-[0.7rem] tabular-nums text-muted-foreground"
|
||||
aria-label="Input non-cached, cached, and output tokens"
|
||||
>
|
||||
{formatTokenUsage(tokenUsage)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<FooterSelect aria-label="Model" value={model} onChange={onModelChange}>
|
||||
@@ -215,7 +226,7 @@ export function ComposerFooter() {
|
||||
</option>
|
||||
))}
|
||||
</FooterSelect>
|
||||
<ContextGauge />
|
||||
<ContextGauge pct={contextPct} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -249,14 +260,36 @@ function FooterSelect({
|
||||
);
|
||||
}
|
||||
|
||||
function formatThousands(tokens: number): string {
|
||||
return `${Math.round(Math.max(tokens, 0) / 1_000)}k`;
|
||||
}
|
||||
|
||||
export function formatTokenUsage(usage: {
|
||||
input: number;
|
||||
cacheRead: number;
|
||||
output: number;
|
||||
} | null): string {
|
||||
return [usage?.input ?? 0, usage?.cacheRead ?? 0, usage?.output ?? 0]
|
||||
.map(formatThousands)
|
||||
.join("/");
|
||||
}
|
||||
|
||||
/** Circular context-usage gauge. `pct` in [0,1]; 0 renders an empty track. */
|
||||
function ContextGauge({ pct = 0 }: { pct?: number }) {
|
||||
export function ContextGauge({ pct = 0 }: { pct?: number }) {
|
||||
const r = 6;
|
||||
const circ = 2 * Math.PI * r;
|
||||
const clamped = Math.min(Math.max(pct, 0), 1);
|
||||
const level = clamped < 0.7 ? "green" : clamped <= 0.85 ? "yellow" : "red";
|
||||
const progressClass = level === "green"
|
||||
? "stroke-emerald-500"
|
||||
: level === "yellow"
|
||||
? "stroke-amber-400"
|
||||
: "stroke-red-500";
|
||||
return (
|
||||
<span
|
||||
title="Context usage"
|
||||
aria-label="Context usage"
|
||||
aria-label={`Context usage ${Math.round(clamped * 100)}%`}
|
||||
data-level={level}
|
||||
className="inline-flex items-center"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" className="opacity-80">
|
||||
@@ -266,11 +299,11 @@ function ContextGauge({ pct = 0 }: { pct?: number }) {
|
||||
cy="8"
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="oklch(var(--primary))"
|
||||
className={progressClass}
|
||||
strokeWidth="2.25"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circ}
|
||||
strokeDashoffset={circ * (1 - Math.min(Math.max(pct, 0), 1))}
|
||||
strokeDashoffset={circ * (1 - clamped)}
|
||||
transform="rotate(-90 8 8)"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
@@ -38,6 +38,26 @@ test("text_delta accumulates into transcript", () => {
|
||||
expect(useSessionStore.getState().transcript.at(-1)?.text).toBe("Analisi");
|
||||
});
|
||||
|
||||
test("usage events accumulate billed token categories but keep the latest context occupancy", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.applyEvent({
|
||||
type: "usage",
|
||||
usage: { input: 60_000, cacheRead: 400_000, output: 20_000, totalTokens: 80_000, contextWindow: 200_000 },
|
||||
});
|
||||
store.applyEvent({
|
||||
type: "usage",
|
||||
usage: { input: 50_348, cacheRead: 233_344, output: 13_652, totalTokens: 55_935, contextWindow: 200_000 },
|
||||
});
|
||||
|
||||
expect(useSessionStore.getState().tokenUsage).toEqual({
|
||||
input: 110_348,
|
||||
cacheRead: 633_344,
|
||||
output: 33_652,
|
||||
totalTokens: 55_935,
|
||||
contextWindow: 200_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("folds prompt, lifecycle, streams, tool updates, and gates in chronological order", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.setPhase("F1");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import type { ActivityEntry, StreamEvent, WidgetDescriptor } from "../api/types";
|
||||
import type { ActivityEntry, StreamEvent, TokenUsage, WidgetDescriptor } from "../api/types";
|
||||
|
||||
interface Entry {
|
||||
role: "assistant";
|
||||
@@ -21,6 +21,7 @@ interface SessionState {
|
||||
// system event — the only end-of-turn signal on the final workflow step, where no
|
||||
// follow-up gate arrives to release the spinner.
|
||||
agentActive: boolean;
|
||||
tokenUsage: TokenUsage | null;
|
||||
applyEvent: (e: StreamEvent) => void;
|
||||
clearPending: () => void;
|
||||
resetSession: () => void;
|
||||
@@ -65,6 +66,7 @@ const empty = {
|
||||
phaseError: null as string | null,
|
||||
seenGateIds: new Set<string>(),
|
||||
agentActive: false,
|
||||
tokenUsage: null as TokenUsage | null,
|
||||
};
|
||||
|
||||
export const useSessionStore = create<SessionState>((set) => ({
|
||||
@@ -145,6 +147,17 @@ export const useSessionStore = create<SessionState>((set) => ({
|
||||
}
|
||||
return { activityLog, agentActive: true };
|
||||
}
|
||||
if (e.type === "usage") {
|
||||
return {
|
||||
tokenUsage: {
|
||||
input: (st.tokenUsage?.input ?? 0) + e.usage.input,
|
||||
cacheRead: (st.tokenUsage?.cacheRead ?? 0) + e.usage.cacheRead,
|
||||
output: (st.tokenUsage?.output ?? 0) + e.usage.output,
|
||||
totalTokens: e.usage.totalTokens,
|
||||
contextWindow: e.usage.contextWindow,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (e.type === "info") {
|
||||
const level = e.level ?? "info";
|
||||
const stepMessages = [...st.stepMessages, { level, text: e.text }];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { act, useLayoutEffect } from "react";
|
||||
import { FakeEventSource } from "../test/fakeEventSource";
|
||||
import { useSessionStream } from "./useSessionStream";
|
||||
import { createStreamEventCoalescer, useSessionStream } from "./useSessionStream";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -10,6 +10,49 @@ beforeEach(() => {
|
||||
useSessionStore.getState().resetSession();
|
||||
});
|
||||
|
||||
test("coalesces a burst of text deltas into bounded store updates", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const applied: Array<{ type: string; text?: string }> = [];
|
||||
const coalescer = createStreamEventCoalescer((event) => applied.push(event), 100);
|
||||
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
coalescer.push({ type: "text_delta", text: "x" });
|
||||
}
|
||||
|
||||
expect(applied).toEqual([{ type: "text_delta", text: "x" }]);
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(applied).toEqual([
|
||||
{ type: "text_delta", text: "x" },
|
||||
{ type: "text_delta", text: "x".repeat(99) },
|
||||
]);
|
||||
coalescer.dispose();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("flushes pending stream text before a structural event", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const applied: Array<{ type: string; text?: string }> = [];
|
||||
const coalescer = createStreamEventCoalescer((event) => applied.push(event), 100);
|
||||
|
||||
coalescer.push({ type: "text_delta", text: "a" });
|
||||
coalescer.push({ type: "text_delta", text: "b" });
|
||||
coalescer.push({ type: "system_event", event: "turn_end" });
|
||||
|
||||
expect(applied).toEqual([
|
||||
{ type: "text_delta", text: "a" },
|
||||
{ type: "text_delta", text: "b" },
|
||||
{ type: "system_event", event: "turn_end" },
|
||||
]);
|
||||
coalescer.dispose();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("opens an EventSource and feeds NAMED events to the store", () => {
|
||||
renderHook(() => useSessionStream("s1"));
|
||||
const es = FakeEventSource.instances[0];
|
||||
@@ -56,6 +99,26 @@ test("feeds named activity_event events to the tool activity timeline", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("feeds named usage events to the session token counters", () => {
|
||||
renderHook(() => useSessionStream("s1"));
|
||||
const es = FakeEventSource.instances[0];
|
||||
|
||||
act(() =>
|
||||
es.emitNamed("usage", {
|
||||
type: "usage",
|
||||
usage: { input: 1_000, cacheRead: 2_000, output: 300, totalTokens: 3_300, contextWindow: 200_000 },
|
||||
})
|
||||
);
|
||||
|
||||
expect(useSessionStore.getState().tokenUsage).toEqual({
|
||||
input: 1_000,
|
||||
cacheRead: 2_000,
|
||||
output: 300,
|
||||
totalTokens: 3_300,
|
||||
contextWindow: 200_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("also feeds UNNAMED (default message) events to the store", () => {
|
||||
renderHook(() => useSessionStream("s1"));
|
||||
const es = FakeEventSource.instances[0];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -21,6 +21,11 @@ test("sql artifact accepts a { content } wrapper", async () => {
|
||||
await screen.findByTestId("hl");
|
||||
});
|
||||
|
||||
test("an empty artifact shows an explicit fallback instead of a blank panel", () => {
|
||||
render(<ArtifactView artifact={{ kind: "sql", data: {} }} />);
|
||||
expect(screen.getByText("No artifact content available.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("cte_plan renders an ordered list of names", () => {
|
||||
render(<ArtifactView artifact={{ kind: "cte_plan", data: { names: ["a_cte", "b_cte"] } }} />);
|
||||
expect(screen.getByText("a_cte")).toBeInTheDocument();
|
||||
|
||||
@@ -135,6 +135,9 @@ function StructuredValue({ value, depth = 0 }: { value: unknown; depth?: number
|
||||
);
|
||||
}
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
if (entries.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No artifact content available.</p>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{entries.map(([k, v], i) => (
|
||||
|
||||
Reference in New Issue
Block a user