mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
topology compact agent token cards
This commit is contained in:
1 parent
714932c491
commit
b53849c044
11 files changed
+213
-30
No files matched your search
@@ -33,6 +33,8 @@ interface RFNodeData {
|
||||
contextUsedPercentage: number | null;
|
||||
contextFresh: boolean;
|
||||
contextAvailability: string;
|
||||
contextTotalInputTokens: number | null;
|
||||
contextTotalOutputTokens: number | null;
|
||||
agentActivity?: AgentActivity | null;
|
||||
currentQitems?: CurrentQitemSummary[];
|
||||
}
|
||||
@@ -67,6 +69,8 @@ export interface InventoryOverlay {
|
||||
contextUsedPercentage?: number | null;
|
||||
contextFresh?: boolean;
|
||||
contextAvailability?: string;
|
||||
contextTotalInputTokens?: number | null;
|
||||
contextTotalOutputTokens?: number | null;
|
||||
agentActivity?: AgentActivity | null;
|
||||
currentQitems?: CurrentQitemSummary[];
|
||||
}
|
||||
@@ -125,6 +129,8 @@ export function projectRigToGraph(input: RigGraphInput, inventoryOverlay?: Inven
|
||||
contextUsedPercentage: overlay?.contextUsedPercentage ?? null,
|
||||
contextFresh: overlay?.contextFresh ?? false,
|
||||
contextAvailability: overlay?.contextAvailability ?? "unknown",
|
||||
contextTotalInputTokens: overlay?.contextTotalInputTokens ?? null,
|
||||
contextTotalOutputTokens: overlay?.contextTotalOutputTokens ?? null,
|
||||
agentActivity: overlay?.agentActivity ?? null,
|
||||
currentQitems: overlay?.currentQitems ?? [],
|
||||
},
|
||||
@@ -160,6 +166,8 @@ export function projectRigToGraph(input: RigGraphInput, inventoryOverlay?: Inven
|
||||
contextUsedPercentage: null,
|
||||
contextFresh: false,
|
||||
contextAvailability: "unknown",
|
||||
contextTotalInputTokens: null,
|
||||
contextTotalOutputTokens: null,
|
||||
agentActivity: null,
|
||||
currentQitems: [],
|
||||
},
|
||||
|
||||
@@ -231,6 +231,8 @@ rigsRoutes.get("/:id/graph", async (c) => {
|
||||
contextUsedPercentage: n.contextUsage?.usedPercentage ?? null,
|
||||
contextFresh: n.contextUsage?.fresh ?? false,
|
||||
contextAvailability: n.contextUsage?.availability ?? "unknown",
|
||||
contextTotalInputTokens: n.contextUsage?.totalInputTokens ?? null,
|
||||
contextTotalOutputTokens: n.contextUsage?.totalOutputTokens ?? null,
|
||||
agentActivity: n.agentActivity ?? null,
|
||||
currentQitems: n.canonicalSessionName
|
||||
? currentQitemsBySession.get(n.canonicalSessionName) ?? []
|
||||
|
||||
@@ -88,6 +88,8 @@ describe("Context Usage Projection", () => {
|
||||
const graphNode = body.nodes.find((n: any) => n.data?.logicalId === "dev.impl");
|
||||
expect(graphNode?.data?.contextAvailability).toBe("known");
|
||||
expect(graphNode?.data?.contextUsedPercentage).toBe(67);
|
||||
expect(graphNode?.data?.contextTotalInputTokens).toBe(120000);
|
||||
expect(graphNode?.data?.contextTotalOutputTokens).toBe(14000);
|
||||
});
|
||||
|
||||
// T5: WhoamiResult includes contextUsage (via route)
|
||||
|
||||
@@ -78,6 +78,27 @@ describe("PL-019 projectRigToGraph: agentActivity + currentQitems", () => {
|
||||
expect(result.nodes[0].data.agentActivity).toEqual(RUNNING_ACTIVITY);
|
||||
});
|
||||
|
||||
it("projects context token totals onto RFNodeData when overlay carries them", () => {
|
||||
const input = makeRig(
|
||||
[{ id: "n1", logicalId: "alpha", role: "worker" }],
|
||||
[],
|
||||
[makeRunningSession("n1")]
|
||||
);
|
||||
const overlay: InventoryOverlay[] = [
|
||||
{
|
||||
logicalId: "alpha",
|
||||
startupStatus: "ready",
|
||||
canonicalSessionName: "r01-alpha",
|
||||
restoreOutcome: "n-a",
|
||||
contextTotalInputTokens: 120_000,
|
||||
contextTotalOutputTokens: 14_000,
|
||||
},
|
||||
];
|
||||
const result = projectRigToGraph(input, overlay);
|
||||
expect(result.nodes[0].data.contextTotalInputTokens).toBe(120_000);
|
||||
expect(result.nodes[0].data.contextTotalOutputTokens).toBe(14_000);
|
||||
});
|
||||
|
||||
it("defaults agentActivity to null when overlay omits it", () => {
|
||||
const input = makeRig(
|
||||
[{ id: "n1", logicalId: "alpha", role: "worker" }],
|
||||
@@ -155,6 +176,8 @@ describe("PL-019 projectRigToGraph: agentActivity + currentQitems", () => {
|
||||
expect(podNode).toBeDefined();
|
||||
expect(podNode!.data.agentActivity).toBeNull();
|
||||
expect(podNode!.data.currentQitems).toEqual([]);
|
||||
expect(podNode!.data.contextTotalInputTokens).toBeNull();
|
||||
expect(podNode!.data.contextTotalOutputTokens).toBeNull();
|
||||
});
|
||||
|
||||
it("multiple nodes with mixed activity states project independently", () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ContextUsageRing } from "./ContextUsageRing.js";
|
||||
import { ActivityRing } from "./topology/ActivityRing.js";
|
||||
import { getActivityCardClasses, getActivityCardSignal } from "./topology/activity-card-visuals.js";
|
||||
import type { TopologyActivityVisual } from "../lib/topology-activity.js";
|
||||
import { formatCompactTokenCount, formatTokenTotalTitle, sumTokenCounts } from "../lib/token-format.js";
|
||||
|
||||
interface RigNodeData {
|
||||
logicalId: string;
|
||||
@@ -42,6 +43,8 @@ interface RigNodeData {
|
||||
contextUsedPercentage?: number | null;
|
||||
contextFresh?: boolean;
|
||||
contextAvailability?: string;
|
||||
contextTotalInputTokens?: number | null;
|
||||
contextTotalOutputTokens?: number | null;
|
||||
placementState?: "available" | "selected" | null;
|
||||
// PL-019: agent activity drives the node's primary "is this agent
|
||||
// working?" dot color (replacing the previous startup-status color).
|
||||
@@ -96,6 +99,9 @@ export function RigNode({ data }: { data: RigNodeData }) {
|
||||
const activityAnimClass = getActivityAnimationClass(activityState);
|
||||
const activityIsStale = isActivityStale(data.agentActivity);
|
||||
const activityCard = getActivityCardSignal({ activityRing: data.activityRing, activityState });
|
||||
const tokenTotal = sumTokenCounts(data.contextTotalInputTokens, data.contextTotalOutputTokens);
|
||||
const tokenLabel = formatCompactTokenCount(tokenTotal);
|
||||
const tokenTitle = formatTokenTotalTitle(data.contextTotalInputTokens, data.contextTotalOutputTokens);
|
||||
|
||||
const placementChipLabel = data.placementState === "selected" ? "target" : data.placementState === "available" ? "avail" : null;
|
||||
|
||||
@@ -114,6 +120,7 @@ export function RigNode({ data }: { data: RigNodeData }) {
|
||||
data.resolvedSpecName ? `Spec: ${data.resolvedSpecName}` : null,
|
||||
data.profile ? `Profile: ${data.profile}` : null,
|
||||
typeof data.edgeCount === "number" ? `Edges: ${data.edgeCount}` : null,
|
||||
tokenTitle,
|
||||
...qitemHoverLines,
|
||||
].filter((line): line is string => Boolean(line));
|
||||
const hoverHint = hoverHintLines.join("\n");
|
||||
@@ -232,13 +239,14 @@ export function RigNode({ data }: { data: RigNodeData }) {
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-3 space-y-2">
|
||||
{/* Runtime info */}
|
||||
{runtimeModel && (
|
||||
<div className="font-mono text-[8px] text-stone-500">
|
||||
RUNTIME: {runtimeModel}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1 px-2 py-1.5">
|
||||
<div className="truncate font-mono text-[8px] leading-tight text-stone-500">
|
||||
{data.canonicalSessionName ?? data.logicalId}
|
||||
</div>
|
||||
|
||||
<div className="truncate font-mono text-[8px] uppercase tracking-[0.12em] text-stone-400">
|
||||
{runtimeModel || data.profile || "runtime unknown"}
|
||||
</div>
|
||||
|
||||
{/* Spec hint */}
|
||||
{data.resolvedSpecName && (
|
||||
@@ -248,23 +256,32 @@ export function RigNode({ data }: { data: RigNodeData }) {
|
||||
)}
|
||||
|
||||
{/* Context usage — prominent big number per founder directive */}
|
||||
{data.contextAvailability === "known" && typeof data.contextUsedPercentage === "number" ? (
|
||||
<div className="flex items-end justify-between gap-3 pt-0.5">
|
||||
{data.contextAvailability === "known" && typeof data.contextUsedPercentage === "number" ? (
|
||||
<div
|
||||
className={`font-mono text-base font-bold leading-none ${
|
||||
data.contextUsedPercentage >= 80 ? "text-red-600" :
|
||||
data.contextUsedPercentage >= 60 ? "text-amber-600" :
|
||||
"text-green-700"
|
||||
}${!data.contextFresh ? " opacity-50" : ""}`}
|
||||
data-testid="context-badge"
|
||||
title={data.contextFresh ? "Context usage (fresh)" : "Context usage (stale sample)"}
|
||||
>
|
||||
{data.contextUsedPercentage}%
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-mono text-xs text-stone-400" data-testid="context-badge-unknown">
|
||||
?
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`font-mono text-base font-bold leading-tight ${
|
||||
data.contextUsedPercentage >= 80 ? "text-red-600" :
|
||||
data.contextUsedPercentage >= 60 ? "text-amber-600" :
|
||||
"text-green-700"
|
||||
}${!data.contextFresh ? " opacity-50" : ""}`}
|
||||
data-testid="context-badge"
|
||||
title={data.contextFresh ? "Context usage (fresh)" : "Context usage (stale sample)"}
|
||||
className={`font-mono text-base font-bold leading-none tracking-[0.02em] ${tokenLabel ? "text-stone-500" : "text-stone-300"}`}
|
||||
data-testid="token-total"
|
||||
title={tokenTitle ?? "Token sample unavailable"}
|
||||
>
|
||||
{data.contextUsedPercentage}%
|
||||
{tokenLabel ?? "--"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-mono text-xs text-stone-400" data-testid="context-badge-unknown">
|
||||
?
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Restore outcome */}
|
||||
{data.restoreOutcome && data.restoreOutcome !== "n-a" && (
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useCmuxLaunch } from "../../hooks/useCmuxLaunch.js";
|
||||
import { ActivityRing } from "./ActivityRing.js";
|
||||
import { getActivityCardClasses, getActivityCardSignal } from "./activity-card-visuals.js";
|
||||
import type { TopologyActivityVisual } from "../../lib/topology-activity.js";
|
||||
import { formatCompactTokenCount, formatTokenTotalTitle, sumTokenCounts } from "../../lib/token-format.js";
|
||||
|
||||
interface HybridPodGroupNodeData {
|
||||
podDisplayName?: string | null;
|
||||
@@ -37,6 +38,8 @@ interface HybridAgentNodeData {
|
||||
contextUsedPercentage?: number | null;
|
||||
contextFresh?: boolean;
|
||||
contextAvailability?: string | null;
|
||||
contextTotalInputTokens?: number | null;
|
||||
contextTotalOutputTokens?: number | null;
|
||||
agentActivity?: AgentActivitySummary | null;
|
||||
currentQitems?: unknown[];
|
||||
rigId?: string | null;
|
||||
@@ -93,6 +96,9 @@ export function HybridAgentNode({ data }: { data: HybridAgentNodeData }) {
|
||||
const activityCard = getActivityCardSignal({ activityRing: data.activityRing, activityState });
|
||||
const runtimeModel = [data.runtime, data.model].filter(Boolean).join(" / ");
|
||||
const contextKnown = data.contextAvailability === "known" && typeof data.contextUsedPercentage === "number";
|
||||
const tokenTotal = sumTokenCounts(data.contextTotalInputTokens, data.contextTotalOutputTokens);
|
||||
const tokenLabel = formatCompactTokenCount(tokenTotal);
|
||||
const tokenTitle = formatTokenTotalTitle(data.contextTotalInputTokens, data.contextTotalOutputTokens);
|
||||
|
||||
const card = (
|
||||
<div
|
||||
@@ -101,6 +107,7 @@ export function HybridAgentNode({ data }: { data: HybridAgentNodeData }) {
|
||||
data.canonicalSessionName,
|
||||
`activity: ${activityLabel}${activityStale ? " (stale)" : ""}`,
|
||||
runtimeModel || null,
|
||||
tokenTitle,
|
||||
].filter(Boolean).join("\n")}
|
||||
data-activity-card-state={activityCard.state}
|
||||
data-activity-card-flash={activityCard.flash ?? "none"}
|
||||
@@ -151,20 +158,35 @@ export function HybridAgentNode({ data }: { data: HybridAgentNodeData }) {
|
||||
event.stopPropagation();
|
||||
cmuxLaunch.mutate({ rigId: data.rigId!, logicalId: data.logicalId });
|
||||
}}
|
||||
className="absolute right-1.5 top-7 z-10 inline-flex h-6 w-6 items-center justify-center border border-outline-variant bg-white/90 text-stone-700 opacity-0 shadow-[1px_1px_0_rgba(46,52,46,0.14)] transition-opacity hover:bg-stone-100 hover:text-stone-950 focus:!opacity-100 focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-stone-900/20 group-hover:!opacity-100 group-hover:opacity-100 group-focus-within:!opacity-100 group-focus-within:opacity-100"
|
||||
className="absolute right-1.5 top-6 z-10 inline-flex h-6 w-6 items-center justify-center border border-outline-variant bg-white/90 text-stone-700 opacity-0 shadow-[1px_1px_0_rgba(46,52,46,0.14)] transition-opacity hover:bg-stone-100 hover:text-stone-950 focus:!opacity-100 focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-stone-900/20 group-hover:!opacity-100 group-hover:opacity-100 group-focus-within:!opacity-100 group-focus-within:opacity-100"
|
||||
>
|
||||
<PanelsTopLeft className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
<div className="space-y-1 px-2 py-1.5">
|
||||
<div className="truncate font-mono text-[7px] text-stone-500">
|
||||
<div className="truncate font-mono text-[8px] leading-tight text-stone-500">
|
||||
{data.canonicalSessionName ?? data.logicalId}
|
||||
</div>
|
||||
<div className="truncate font-mono text-[7px] uppercase tracking-[0.08em] text-stone-400">
|
||||
<div className="truncate font-mono text-[7px] uppercase tracking-[0.12em] text-stone-400">
|
||||
{runtimeModel || data.resolvedSpecName || data.profile || "runtime unknown"}
|
||||
</div>
|
||||
<div className={cn("font-mono text-[12px] font-bold leading-none", contextClass(data.contextUsedPercentage, data.contextFresh))}>
|
||||
{contextKnown ? `${data.contextUsedPercentage}%` : "--"}
|
||||
<div className="flex items-end justify-between gap-2 pt-0.5">
|
||||
<div
|
||||
className={cn("font-mono text-[14px] font-bold leading-none", contextClass(data.contextUsedPercentage, data.contextFresh))}
|
||||
data-testid="hybrid-context-badge"
|
||||
>
|
||||
{contextKnown ? `${data.contextUsedPercentage}%` : "--"}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"font-mono text-[13px] font-bold leading-none tracking-[0.02em]",
|
||||
tokenLabel ? "text-stone-500" : "text-stone-300",
|
||||
)}
|
||||
data-testid="hybrid-token-total"
|
||||
title={tokenTitle ?? "Token sample unavailable"}
|
||||
>
|
||||
{tokenLabel ?? "--"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Right} className="opacity-0" />
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Position, type Edge, type Node } from "@xyflow/react";
|
||||
const PREFIX_DELIMITER = "::";
|
||||
|
||||
export const HYBRID_AGENT_WIDTH = 166;
|
||||
export const HYBRID_AGENT_HEIGHT = 116;
|
||||
export const HYBRID_AGENT_HEIGHT = 94;
|
||||
export const HYBRID_POD_HEADER_HEIGHT = 38;
|
||||
export const HYBRID_POD_PADDING_X = 28;
|
||||
export const HYBRID_POD_PADDING_BOTTOM = 22;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
function cleanTokenCount(value: number | null | undefined): number | null {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return null;
|
||||
return Math.round(value);
|
||||
}
|
||||
|
||||
function formatScaled(value: number): string {
|
||||
const abs = Math.abs(value);
|
||||
const rounded = abs < 10 ? Math.round(value * 10) / 10 : Math.round(value);
|
||||
return String(rounded).replace(/\.0$/, "");
|
||||
}
|
||||
|
||||
export function sumTokenCounts(
|
||||
inputTokens: number | null | undefined,
|
||||
outputTokens: number | null | undefined,
|
||||
): number | null {
|
||||
const input = cleanTokenCount(inputTokens);
|
||||
const output = cleanTokenCount(outputTokens);
|
||||
if (input === null && output === null) return null;
|
||||
return (input ?? 0) + (output ?? 0);
|
||||
}
|
||||
|
||||
export function formatCompactTokenCount(value: number | null | undefined): string | null {
|
||||
const count = cleanTokenCount(value);
|
||||
if (count === null) return null;
|
||||
if (count >= 999_500) return `${formatScaled(count / 1_000_000)}m`;
|
||||
if (count >= 1_000) return `${formatScaled(count / 1_000)}k`;
|
||||
return count.toLocaleString("en-US");
|
||||
}
|
||||
|
||||
export function formatTokenTotalTitle(
|
||||
inputTokens: number | null | undefined,
|
||||
outputTokens: number | null | undefined,
|
||||
): string | null {
|
||||
const input = cleanTokenCount(inputTokens);
|
||||
const output = cleanTokenCount(outputTokens);
|
||||
const total = sumTokenCounts(input, output);
|
||||
if (total === null) return null;
|
||||
|
||||
const lines = [`Tokens: ${total.toLocaleString("en-US")}`];
|
||||
if (input !== null) lines.push(`Input: ${input.toLocaleString("en-US")}`);
|
||||
if (output !== null) lines.push(`Output: ${output.toLocaleString("en-US")}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactFlowProvider } from "@xyflow/react";
|
||||
import type React from "react";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { ActivityRing } from "../src/components/topology/ActivityRing.js";
|
||||
import { HotPotatoEdge } from "../src/components/topology/HotPotatoEdge.js";
|
||||
import { HybridAgentNode } from "../src/components/topology/HybridTopologyNodes.js";
|
||||
import { fallbackActivityCardState, getActivityCardClasses } from "../src/components/topology/activity-card-visuals.js";
|
||||
import {
|
||||
HOT_POTATO_WITHIN_RIG_DURATION_MS,
|
||||
@@ -52,6 +56,15 @@ function edgeProps(reducedMotion: boolean) {
|
||||
} as any;
|
||||
}
|
||||
|
||||
function renderHybridNode(children: React.ReactNode) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ReactFlowProvider>{children}</ReactFlowProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("P5.3 ActivityRing and HotPotatoEdge", () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
@@ -106,6 +119,29 @@ describe("P5.3 ActivityRing and HotPotatoEdge", () => {
|
||||
expect(getActivityCardClasses({ state: "active", flash: "source", reducedMotion: true })).toContain("activity-card-reduced-motion");
|
||||
});
|
||||
|
||||
it("hybrid agent card renders compact context and token totals", () => {
|
||||
renderHybridNode(
|
||||
<HybridAgentNode
|
||||
data={{
|
||||
logicalId: "driver",
|
||||
role: "driver",
|
||||
runtime: "claude-code",
|
||||
model: null,
|
||||
status: "running",
|
||||
contextAvailability: "known",
|
||||
contextUsedPercentage: 14,
|
||||
contextFresh: true,
|
||||
contextTotalInputTokens: 200_000,
|
||||
contextTotalOutputTokens: 19_000,
|
||||
canonicalSessionName: "velocity-driver@openrig-velocity",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("hybrid-context-badge").textContent).toBe("14%");
|
||||
expect(screen.getByTestId("hybrid-token-total").textContent).toBe("219k");
|
||||
});
|
||||
|
||||
it("source scan keeps activity and packet layers production-reachable", () => {
|
||||
const srcRoot = path.resolve(__dirname, "../src");
|
||||
const host = readFileSync(path.join(srcRoot, "components/topology/HostMultiRigGraph.tsx"), "utf8");
|
||||
|
||||
@@ -523,8 +523,8 @@ describe("RigGraph", () => {
|
||||
// React Flow uses our custom node type — nodes have class react-flow__node-rigNode
|
||||
const customNodes = container.querySelectorAll(".react-flow__node-rigNode");
|
||||
expect(customNodes.length).toBe(2);
|
||||
// RigNode renders runtime text in combined format
|
||||
expect(screen.getByText("RUNTIME: claude-code · opus")).toBeDefined();
|
||||
// RigNode renders runtime text in the compact card grammar.
|
||||
expect(screen.getByText("claude-code · opus")).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -547,7 +547,7 @@ describe("RigNode", () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText("impl")).toBeDefined();
|
||||
expect(screen.getByText("RUNTIME: claude-code · opus")).toBeDefined();
|
||||
expect(screen.getByText("claude-code · opus")).toBeDefined();
|
||||
expect(screen.queryByText("WORKER")).toBeNull();
|
||||
// PL-019: dot now reflects agentActivity (was startupStatus); no
|
||||
// agentActivity attached → "unknown" state, desaturated stone.
|
||||
@@ -1624,6 +1624,7 @@ describe("RigNode spec hint", () => {
|
||||
logicalId: "dev.impl", role: "worker", runtime: "claude-code",
|
||||
model: null, status: "running", binding: null,
|
||||
contextAvailability: "known", contextUsedPercentage: 85, contextFresh: true,
|
||||
contextTotalInputTokens: 120_000, contextTotalOutputTokens: 14_000,
|
||||
}} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
@@ -1633,6 +1634,9 @@ describe("RigNode spec hint", () => {
|
||||
expect(badge.className).toContain("text-red-600"); // >=80 = red
|
||||
expect(badge.className).toContain("font-bold");
|
||||
expect(badge.className).not.toContain("opacity-50");
|
||||
const tokenTotal = screen.getByTestId("token-total");
|
||||
expect(tokenTotal.textContent).toContain("134k");
|
||||
expect(tokenTotal.getAttribute("title")).toContain("Tokens: 134,000");
|
||||
});
|
||||
|
||||
it("renders stale context with reduced opacity", () => {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatCompactTokenCount, formatTokenTotalTitle, sumTokenCounts } from "../src/lib/token-format.js";
|
||||
|
||||
describe("token-format", () => {
|
||||
it("formats compact token totals for graph cards", () => {
|
||||
expect(formatCompactTokenCount(999)).toBe("999");
|
||||
expect(formatCompactTokenCount(1_234)).toBe("1.2k");
|
||||
expect(formatCompactTokenCount(123_000)).toBe("123k");
|
||||
expect(formatCompactTokenCount(1_000_000)).toBe("1m");
|
||||
expect(formatCompactTokenCount(123_000_000)).toBe("123m");
|
||||
});
|
||||
|
||||
it("sums known input and output token counts", () => {
|
||||
expect(sumTokenCounts(120_000, 14_000)).toBe(134_000);
|
||||
expect(sumTokenCounts(null, 14_000)).toBe(14_000);
|
||||
expect(sumTokenCounts(null, undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("builds an exact tooltip for available token counts", () => {
|
||||
expect(formatTokenTotalTitle(120_000, 14_000)).toBe([
|
||||
"Tokens: 134,000",
|
||||
"Input: 120,000",
|
||||
"Output: 14,000",
|
||||
].join("\n"));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user