feat: show context window data source (#1752)
This commit is contained in:
@@ -338,7 +338,7 @@ func (s *Service) handleContextWindowUpdated(ctx context.Context, data watcher.C
|
||||
return
|
||||
}
|
||||
|
||||
size, remaining, efficiency, ok := s.resolveContextWindowValues(ctx, data)
|
||||
size, remaining, efficiency, source, ok := s.resolveContextWindowValues(ctx, data)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -348,6 +348,7 @@ func (s *Service) handleContextWindowUpdated(ctx context.Context, data watcher.C
|
||||
"used": data.ContextWindowUsed,
|
||||
"remaining": remaining,
|
||||
"efficiency": efficiency,
|
||||
"source": source,
|
||||
}
|
||||
|
||||
// Persist to database asynchronously using json_set to atomically set one
|
||||
@@ -382,28 +383,28 @@ func (s *Service) handleContextWindowUpdated(ctx context.Context, data watcher.C
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) resolveContextWindowValues(ctx context.Context, data watcher.ContextWindowData) (int64, int64, float64, bool) {
|
||||
func (s *Service) resolveContextWindowValues(ctx context.Context, data watcher.ContextWindowData) (int64, int64, float64, string, bool) {
|
||||
if data.ContextWindowSize > 0 {
|
||||
return data.ContextWindowSize, data.ContextWindowRemaining, data.ContextEfficiency, true
|
||||
return data.ContextWindowSize, data.ContextWindowRemaining, data.ContextEfficiency, "acp", true
|
||||
}
|
||||
lookup := s.currentModelInfoLookup()
|
||||
if lookup == nil {
|
||||
return 0, 0, 0, false
|
||||
return 0, 0, 0, "", false
|
||||
}
|
||||
modelID := s.currentRuntimeModel(ctx, data.TaskSessionID)
|
||||
if modelID == "" {
|
||||
return 0, 0, 0, false
|
||||
return 0, 0, 0, "", false
|
||||
}
|
||||
info, ok := lookup.LookupModelInfo(ctx, modelID)
|
||||
if !ok || info.ContextWindow <= 0 {
|
||||
return 0, 0, 0, false
|
||||
return 0, 0, 0, "", false
|
||||
}
|
||||
remaining := info.ContextWindow - data.ContextWindowUsed
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
efficiency := float64(data.ContextWindowUsed) / float64(info.ContextWindow) * 100
|
||||
return info.ContextWindow, remaining, efficiency, true
|
||||
return info.ContextWindow, remaining, efficiency, "api", true
|
||||
}
|
||||
|
||||
func (s *Service) currentRuntimeModel(ctx context.Context, sessionID string) string {
|
||||
|
||||
@@ -257,7 +257,7 @@ func TestResolveContextWindowValues(t *testing.T) {
|
||||
repo: repo,
|
||||
modelInfoLookup: fakeModelInfoLookup{info: modelsdev.ModelInfo{ContextWindow: 128000}, ok: true},
|
||||
}
|
||||
size, remaining, efficiency, ok := svc.resolveContextWindowValues(ctx, watcher.ContextWindowData{
|
||||
size, remaining, efficiency, source, ok := svc.resolveContextWindowValues(ctx, watcher.ContextWindowData{
|
||||
TaskID: base.TaskID,
|
||||
TaskSessionID: base.TaskSessionID,
|
||||
ContextWindowSize: 258400,
|
||||
@@ -270,6 +270,7 @@ func TestResolveContextWindowValues(t *testing.T) {
|
||||
require.Equal(t, int64(258400), size)
|
||||
require.Equal(t, int64(158400), remaining)
|
||||
require.Equal(t, 38.7, efficiency)
|
||||
require.Equal(t, "acp", source)
|
||||
})
|
||||
|
||||
t.Run("models.dev fallback supplies missing ACP size", func(t *testing.T) {
|
||||
@@ -277,7 +278,7 @@ func TestResolveContextWindowValues(t *testing.T) {
|
||||
repo: repo,
|
||||
modelInfoLookup: fakeModelInfoLookup{info: modelsdev.ModelInfo{ContextWindow: 128000}, ok: true},
|
||||
}
|
||||
size, remaining, efficiency, ok := svc.resolveContextWindowValues(ctx, watcher.ContextWindowData{
|
||||
size, remaining, efficiency, source, ok := svc.resolveContextWindowValues(ctx, watcher.ContextWindowData{
|
||||
TaskID: base.TaskID,
|
||||
TaskSessionID: base.TaskSessionID,
|
||||
ContextWindowUsed: 64000,
|
||||
@@ -287,6 +288,7 @@ func TestResolveContextWindowValues(t *testing.T) {
|
||||
require.Equal(t, int64(128000), size)
|
||||
require.Equal(t, int64(64000), remaining)
|
||||
require.Equal(t, 50.0, efficiency)
|
||||
require.Equal(t, "api", source)
|
||||
})
|
||||
|
||||
t.Run("models.dev fallback uses cached runtime model before session load", func(t *testing.T) {
|
||||
@@ -294,7 +296,7 @@ func TestResolveContextWindowValues(t *testing.T) {
|
||||
modelInfoLookup: fakeModelInfoLookup{info: modelsdev.ModelInfo{ContextWindow: 96000}, ok: true},
|
||||
}
|
||||
svc.runtimeModelBySession.Store("s1", "gpt-5.3-codex-spark")
|
||||
size, remaining, efficiency, ok := svc.resolveContextWindowValues(ctx, watcher.ContextWindowData{
|
||||
size, remaining, efficiency, source, ok := svc.resolveContextWindowValues(ctx, watcher.ContextWindowData{
|
||||
TaskID: base.TaskID,
|
||||
TaskSessionID: base.TaskSessionID,
|
||||
ContextWindowUsed: 24000,
|
||||
@@ -304,11 +306,12 @@ func TestResolveContextWindowValues(t *testing.T) {
|
||||
require.Equal(t, int64(96000), size)
|
||||
require.Equal(t, int64(72000), remaining)
|
||||
require.Equal(t, 25.0, efficiency)
|
||||
require.Equal(t, "api", source)
|
||||
})
|
||||
|
||||
t.Run("lookup miss hides context window", func(t *testing.T) {
|
||||
svc := &Service{repo: repo, modelInfoLookup: fakeModelInfoLookup{}}
|
||||
_, _, _, ok := svc.resolveContextWindowValues(ctx, watcher.ContextWindowData{
|
||||
_, _, _, _, ok := svc.resolveContextWindowValues(ctx, watcher.ContextWindowData{
|
||||
TaskID: base.TaskID,
|
||||
TaskSessionID: base.TaskSessionID,
|
||||
ContextWindowUsed: 64000,
|
||||
|
||||
@@ -13,6 +13,7 @@ vi.mock("@/hooks/domains/session/use-session-agent-usage", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@kandev/ui/tooltip", () => ({
|
||||
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
Tooltip: ({ children, open }: { children: React.ReactNode; open?: boolean }) => (
|
||||
<div data-testid="tooltip-root" data-open={open}>
|
||||
{children}
|
||||
@@ -63,20 +64,6 @@ describe("TokenUsageDisplay", () => {
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the indicator for valid usage under the window", () => {
|
||||
vi.mocked(useSessionContextWindow).mockReturnValue({
|
||||
size: 200_000,
|
||||
used: 56_047,
|
||||
remaining: 143_953,
|
||||
efficiency: 28,
|
||||
});
|
||||
|
||||
const { container } = render(<TokenUsageDisplay sessionId="sess-1" />);
|
||||
|
||||
expect(container.querySelector(".cursor-help")).not.toBeNull();
|
||||
expect(container.querySelector("svg")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("opens the tooltip when the context ring is tapped", () => {
|
||||
vi.mocked(useSessionContextWindow).mockReturnValue({
|
||||
size: 200_000,
|
||||
@@ -92,6 +79,22 @@ describe("TokenUsageDisplay", () => {
|
||||
expect(getByTestId("tooltip-root").getAttribute("data-open")).toBe("true");
|
||||
});
|
||||
|
||||
it("closes a tapped tooltip when Escape is pressed", () => {
|
||||
vi.mocked(useSessionContextWindow).mockReturnValue({
|
||||
size: 200_000,
|
||||
used: 56_047,
|
||||
remaining: 143_953,
|
||||
efficiency: 28,
|
||||
});
|
||||
|
||||
const { getByRole, getByTestId } = render(<TokenUsageDisplay sessionId="sess-1" />);
|
||||
|
||||
fireEvent.click(getByRole("button", { name: "Context window: 28% used" }));
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
expect(getByTestId("tooltip-root").getAttribute("data-open")).toBe("false");
|
||||
});
|
||||
|
||||
it("shows subscription usage rows in the tooltip when the agent has them", () => {
|
||||
vi.mocked(useSessionContextWindow).mockReturnValue({
|
||||
size: 200_000,
|
||||
@@ -160,3 +163,47 @@ describe("TokenUsageDisplay", () => {
|
||||
expect(container.querySelector('[data-testid="doughnut-subscription-usage"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TokenUsageDisplay context source", () => {
|
||||
it("labels agent-reported ACP data", () => {
|
||||
vi.mocked(useSessionContextWindow).mockReturnValue({
|
||||
size: 200_000,
|
||||
used: 56_047,
|
||||
remaining: 143_953,
|
||||
efficiency: 28,
|
||||
source: "acp",
|
||||
});
|
||||
|
||||
const { container, getAllByTestId, getByText, getByLabelText } = render(
|
||||
<TokenUsageDisplay sessionId="sess-1" />,
|
||||
);
|
||||
|
||||
const tokenCount = getByText("56.0K of 200.0K tokens");
|
||||
const source = getByText("ACP");
|
||||
|
||||
expect(container.querySelector(".cursor-help")).not.toBeNull();
|
||||
expect(container.querySelector("svg")).not.toBeNull();
|
||||
expect(tokenCount.closest('[data-testid="context-window-token-row"]')).toBe(
|
||||
source.closest('[data-testid="context-window-token-row"]'),
|
||||
);
|
||||
expect(getAllByTestId("tooltip-root")).toHaveLength(1);
|
||||
expect(getByLabelText("About context window source")).toBeDefined();
|
||||
expect(getByText(/ACP is the active session's effective window/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it("labels model API fallback data", () => {
|
||||
vi.mocked(useSessionContextWindow).mockReturnValue({
|
||||
size: 128_000,
|
||||
used: 64_000,
|
||||
remaining: 64_000,
|
||||
efficiency: 50,
|
||||
source: "api",
|
||||
});
|
||||
|
||||
const { getByText, getByLabelText } = render(<TokenUsageDisplay sessionId="sess-1" />);
|
||||
|
||||
expect(getByText("API")).toBeDefined();
|
||||
expect(getByLabelText("About context window source")).toBeDefined();
|
||||
expect(getByText(/model's advertised maximum from the catalogue/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useState } from "react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { memo, useEffect, useId, useRef, useState } from "react";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { UsageWindowRows, usageStatus } from "@/components/usage/usage-window-rows";
|
||||
import { useSessionContextWindow } from "@/hooks/domains/session/use-session-context-window";
|
||||
@@ -16,7 +17,7 @@ type TokenUsageDisplayProps = {
|
||||
* A context-window report is only trustworthy when we have a positive window
|
||||
* size and usage that does not exceed it. `used > size` is impossible for a
|
||||
* real window, so it means the agent (via the ACP bridge) reported a stale or
|
||||
* wrong `size` (e.g. 200K for a model actually running on the 1M beta window).
|
||||
* wrong `size` (for example, usage and window metadata from different turns).
|
||||
* In that case we hide the indicator instead of showing a confusing >100%.
|
||||
* `used === size` (exactly full) is valid and still renders.
|
||||
*/
|
||||
@@ -41,6 +42,117 @@ function getCircleColor(efficiency: number): string {
|
||||
return "text-blue-300";
|
||||
}
|
||||
|
||||
function usePinnableTooltip() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const pinnedRef = useRef(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const closePinnedTooltip = () => {
|
||||
pinnedRef.current = false;
|
||||
setOpen(false);
|
||||
};
|
||||
const closeOnOutsidePointer = (event: PointerEvent) => {
|
||||
if (!pinnedRef.current || !(event.target instanceof Node)) return;
|
||||
if (
|
||||
triggerRef.current?.contains(event.target) ||
|
||||
(event.target instanceof Element && event.target.closest('[data-slot="tooltip-content"]'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
closePinnedTooltip();
|
||||
};
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && pinnedRef.current) closePinnedTooltip();
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", closeOnOutsidePointer);
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", closeOnOutsidePointer);
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
open,
|
||||
triggerRef,
|
||||
onOpenChange: (nextOpen: boolean) => {
|
||||
if (!nextOpen && pinnedRef.current) return;
|
||||
setOpen(nextOpen);
|
||||
},
|
||||
onTriggerClick: () => {
|
||||
pinnedRef.current = !pinnedRef.current;
|
||||
setOpen(pinnedRef.current);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function ContextWindowRing({ usagePercent }: { usagePercent: number }) {
|
||||
const radius = 10;
|
||||
const strokeWidth = 2.5;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const strokeDashoffset = circumference * (1 - usagePercent / 100);
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className="size-5 -rotate-90" aria-hidden="true">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
className="text-muted"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
className={cn(getCircleColor(usagePercent), "transition-all duration-300 ease-out")}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextWindowSource({ source }: { source: "acp" | "api" | undefined }) {
|
||||
const helpId = useId();
|
||||
|
||||
if (!source) return null;
|
||||
const description =
|
||||
source === "acp"
|
||||
? "ACP is the active session's effective window, reported by the agent."
|
||||
: "API is the model's advertised maximum from the catalogue and is used when ACP omits the window.";
|
||||
|
||||
return (
|
||||
<div className="group relative flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<span>Source</span>
|
||||
<span className="font-medium text-foreground">{source.toUpperCase()}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="About context window source"
|
||||
aria-describedby={helpId}
|
||||
className="inline-flex size-6 cursor-help items-center justify-center text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring sm:size-4"
|
||||
>
|
||||
<IconInfoCircle className="h-3 w-3" />
|
||||
</button>
|
||||
<span
|
||||
id={helpId}
|
||||
role="tooltip"
|
||||
className="pointer-events-none absolute right-0 bottom-[calc(100%+0.375rem)] z-10 w-60 rounded-md border border-border bg-popover px-3 py-1.5 text-xs text-popover-foreground opacity-0 shadow-sm transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription utilization rows for the session's agent, rendered inside the
|
||||
* doughnut tooltip. Mounted only while the tooltip is open, so each hover
|
||||
@@ -72,94 +184,74 @@ export const TokenUsageDisplay = memo(function TokenUsageDisplay({
|
||||
sessionId,
|
||||
className,
|
||||
}: TokenUsageDisplayProps) {
|
||||
const [tooltipOpen, setTooltipOpen] = useState(false);
|
||||
const tooltip = usePinnableTooltip();
|
||||
const contextWindow = useSessionContextWindow(sessionId);
|
||||
|
||||
if (!contextWindow) return null;
|
||||
|
||||
const { size, used } = contextWindow;
|
||||
const { size, used, source } = contextWindow;
|
||||
|
||||
// Hide when there's no data yet (size 0) or the report is impossible
|
||||
// (used > size) — see isContextWindowReliable.
|
||||
if (!isContextWindowReliable(size, used)) return null;
|
||||
|
||||
const usagePercent = (used / size) * 100;
|
||||
const progress = usagePercent / 100;
|
||||
|
||||
// SVG circle parameters
|
||||
const radius = 10;
|
||||
const strokeWidth = 2.5;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const strokeDashoffset = circumference * (1 - progress);
|
||||
|
||||
return (
|
||||
<Tooltip open={tooltipOpen} onOpenChange={setTooltipOpen}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Context window: ${usagePercent.toFixed(0)}% used`}
|
||||
onClick={() => setTooltipOpen(true)}
|
||||
className={cn(
|
||||
"flex size-7 cursor-help items-center justify-center rounded-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring sm:size-5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="size-5 -rotate-90" aria-hidden="true">
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
className="text-muted"
|
||||
/>
|
||||
{/* Progress circle */}
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
className={cn(getCircleColor(usagePercent), "transition-all duration-300 ease-out")}
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<div className="min-w-64 text-xs">
|
||||
<div className="space-y-2" data-testid="context-window-usage">
|
||||
<div className="flex items-baseline justify-between gap-6">
|
||||
<span className="text-[10px] font-medium uppercase text-muted-foreground">
|
||||
Context window
|
||||
</span>
|
||||
<span className="text-base font-semibold tabular-nums text-foreground">
|
||||
{usagePercent.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"h-1.5 overflow-hidden rounded-full bg-muted",
|
||||
getCircleColor(usagePercent),
|
||||
)}
|
||||
>
|
||||
// The UI wrapper defaults this to true; the source control must remain reachable inside.
|
||||
<TooltipProvider disableHoverableContent={false}>
|
||||
<Tooltip open={tooltip.open} onOpenChange={tooltip.onOpenChange}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
ref={tooltip.triggerRef}
|
||||
type="button"
|
||||
aria-label={`Context window: ${usagePercent.toFixed(0)}% used`}
|
||||
aria-expanded={tooltip.open}
|
||||
onClick={tooltip.onTriggerClick}
|
||||
className={cn(
|
||||
"flex size-7 cursor-help items-center justify-center rounded-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring sm:size-5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ContextWindowRing usagePercent={usagePercent} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="pointer-events-auto">
|
||||
<div className="min-w-64 text-xs">
|
||||
<div className="space-y-2" data-testid="context-window-usage">
|
||||
<div className="flex items-baseline justify-between gap-6">
|
||||
<span className="text-[10px] font-medium uppercase text-muted-foreground">
|
||||
Context window
|
||||
</span>
|
||||
<span className="text-base font-semibold tabular-nums text-foreground">
|
||||
{usagePercent.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-full rounded-full bg-current transition-all duration-300 ease-out"
|
||||
style={{ width: `${usagePercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[11px] tabular-nums text-muted-foreground">
|
||||
{formatNumber(used)} of {formatNumber(size)} tokens
|
||||
className={cn(
|
||||
"h-1.5 overflow-hidden rounded-full bg-muted",
|
||||
getCircleColor(usagePercent),
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-current transition-all duration-300 ease-out"
|
||||
style={{ width: `${usagePercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex min-h-6 items-center justify-between gap-3"
|
||||
data-testid="context-window-token-row"
|
||||
>
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground">
|
||||
{formatNumber(used)} of {formatNumber(size)} tokens
|
||||
</span>
|
||||
<ContextWindowSource source={source} />
|
||||
</div>
|
||||
</div>
|
||||
<SessionUsageRows sessionId={sessionId} />
|
||||
</div>
|
||||
<SessionUsageRows sessionId={sessionId} />
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
type ContextWindowStore = Window & {
|
||||
__KANDEV_E2E_STORE__?: {
|
||||
getState: () => {
|
||||
setContextWindow: (
|
||||
sessionId: string,
|
||||
contextWindow: {
|
||||
size: number;
|
||||
used: number;
|
||||
remaining: number;
|
||||
efficiency: number;
|
||||
source: "acp" | "api";
|
||||
},
|
||||
) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export async function seedContextWindowTask(
|
||||
testPage: Page,
|
||||
apiClient: ApiClient,
|
||||
seedData: SeedData,
|
||||
): Promise<void> {
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Context Window Source Test",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
if (!task.session_id) throw new Error("Expected the seeded task to start a session");
|
||||
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await session.waitForChatIdle({ timeout: 30_000 });
|
||||
|
||||
await testPage.evaluate((sessionId) => {
|
||||
const store = (window as ContextWindowStore).__KANDEV_E2E_STORE__;
|
||||
if (!store) throw new Error("E2E store bridge is unavailable");
|
||||
store.getState().setContextWindow(sessionId, {
|
||||
size: 258_400,
|
||||
used: 54_100,
|
||||
remaining: 204_300,
|
||||
efficiency: 21,
|
||||
source: "acp",
|
||||
});
|
||||
}, task.session_id);
|
||||
}
|
||||
|
||||
export async function expectSourceRightOfTokenCount(contextTooltip: Locator): Promise<void> {
|
||||
const row = contextTooltip.getByTestId("context-window-token-row").first();
|
||||
const tokenCount = row.getByText("54.1K of 258.4K tokens");
|
||||
const source = row.getByText("ACP", { exact: true });
|
||||
const [tokenBox, sourceBox] = await Promise.all([tokenCount.boundingBox(), source.boundingBox()]);
|
||||
|
||||
if (!tokenBox || !sourceBox) throw new Error("Expected token count and source to be rendered");
|
||||
if (sourceBox.x <= tokenBox.x + tokenBox.width) {
|
||||
throw new Error("Expected context source to render to the right of the token count");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import {
|
||||
expectSourceRightOfTokenCount,
|
||||
seedContextWindowTask,
|
||||
} from "./context-window-source-helpers";
|
||||
|
||||
test("context source help stays open when hovered", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
prCapture,
|
||||
}) => {
|
||||
await seedContextWindowTask(testPage, apiClient, seedData);
|
||||
|
||||
const contextTrigger = testPage.getByRole("button", { name: "Context window: 21% used" });
|
||||
await contextTrigger.hover();
|
||||
const contextTooltip = testPage
|
||||
.locator('[data-slot="tooltip-content"][data-state]')
|
||||
.filter({ has: testPage.getByTestId("context-window-usage") });
|
||||
const contextUsage = contextTooltip.getByTestId("context-window-usage").first();
|
||||
await expect(contextUsage).toBeVisible();
|
||||
await expectSourceRightOfTokenCount(contextTooltip);
|
||||
|
||||
const sourceHelpButton = contextUsage.locator('button[aria-label="About context window source"]');
|
||||
const sourceHelpId = await sourceHelpButton.getAttribute("aria-describedby");
|
||||
if (!sourceHelpId) throw new Error("Expected source help to be described");
|
||||
await sourceHelpButton.hover();
|
||||
|
||||
await expect(contextUsage).toBeVisible();
|
||||
await expect(testPage.locator(`[id="${sourceHelpId}"]`)).toHaveCSS("opacity", "1");
|
||||
await prCapture.screenshot("context-source-help", {
|
||||
caption: "Context source shown inline with the token count and its help visible",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import {
|
||||
expectSourceRightOfTokenCount,
|
||||
seedContextWindowTask,
|
||||
} from "./context-window-source-helpers";
|
||||
|
||||
test("context source help is reachable by touch without overflow", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
await seedContextWindowTask(testPage, apiClient, seedData);
|
||||
|
||||
const contextTrigger = testPage.getByRole("button", { name: "Context window: 21% used" });
|
||||
await contextTrigger.tap();
|
||||
const contextTooltip = testPage
|
||||
.locator('[data-slot="tooltip-content"][data-state]')
|
||||
.filter({ has: testPage.getByTestId("context-window-usage") });
|
||||
const contextUsage = contextTooltip.getByTestId("context-window-usage").first();
|
||||
await expect(contextUsage).toBeVisible();
|
||||
await expectSourceRightOfTokenCount(contextTooltip);
|
||||
|
||||
const sourceHelpButton = contextUsage.locator('button[aria-label="About context window source"]');
|
||||
const sourceHelpId = await sourceHelpButton.getAttribute("aria-describedby");
|
||||
if (!sourceHelpId) throw new Error("Expected source help to be described");
|
||||
await sourceHelpButton.tap();
|
||||
|
||||
await expect(contextUsage).toBeVisible();
|
||||
await expect(testPage.locator(`[id="${sourceHelpId}"]`)).toHaveCSS("opacity", "1");
|
||||
const hasHorizontalOverflow = await testPage.evaluate(() => {
|
||||
const root = document.scrollingElement ?? document.documentElement;
|
||||
return root.scrollWidth > root.clientWidth + 1;
|
||||
});
|
||||
expect(hasHorizontalOverflow).toBe(false);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo } from "react";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
import type { ContextWindowEntry } from "@/lib/state/store";
|
||||
import { parseContextWindowEntry } from "@/lib/state/slices/session-runtime/context-window";
|
||||
|
||||
export function useSessionContextWindow(sessionId: string | null): ContextWindowEntry | undefined {
|
||||
// Subscribe to individual primitive values to ensure reactivity
|
||||
@@ -17,6 +18,9 @@ export function useSessionContextWindow(sessionId: string | null): ContextWindow
|
||||
const efficiency = useAppStore((state) =>
|
||||
sessionId ? state.contextWindow.bySessionId[sessionId]?.efficiency : undefined,
|
||||
);
|
||||
const source = useAppStore((state) =>
|
||||
sessionId ? state.contextWindow.bySessionId[sessionId]?.source : undefined,
|
||||
);
|
||||
const timestamp = useAppStore((state) =>
|
||||
sessionId ? state.contextWindow.bySessionId[sessionId]?.timestamp : undefined,
|
||||
);
|
||||
@@ -29,9 +33,10 @@ export function useSessionContextWindow(sessionId: string | null): ContextWindow
|
||||
used: used ?? 0,
|
||||
remaining: remaining ?? 0,
|
||||
efficiency: efficiency ?? 0,
|
||||
source,
|
||||
timestamp,
|
||||
};
|
||||
}, [size, used, remaining, efficiency, timestamp]);
|
||||
}, [size, used, remaining, efficiency, source, timestamp]);
|
||||
|
||||
const session = useAppStore((state) =>
|
||||
sessionId ? state.taskSessions.items[sessionId] : undefined,
|
||||
@@ -48,17 +53,8 @@ export function useSessionContextWindow(sessionId: string | null): ContextWindow
|
||||
if (!metadata || typeof metadata !== "object") return;
|
||||
|
||||
const storedContextWindow = (metadata as Record<string, unknown>).context_window;
|
||||
if (!storedContextWindow || typeof storedContextWindow !== "object") return;
|
||||
|
||||
// Map stored context window to ContextWindowEntry
|
||||
const cw = storedContextWindow as Record<string, unknown>;
|
||||
const entry: ContextWindowEntry = {
|
||||
size: (cw.size as number) ?? 0,
|
||||
used: (cw.used as number) ?? 0,
|
||||
remaining: (cw.remaining as number) ?? 0,
|
||||
efficiency: (cw.efficiency as number) ?? 0,
|
||||
timestamp: (cw.timestamp as string) ?? undefined,
|
||||
};
|
||||
const entry = parseContextWindowEntry(storedContextWindow);
|
||||
if (!entry) return;
|
||||
|
||||
setContextWindow(sessionId, entry);
|
||||
}, [sessionId, contextWindow, session?.metadata, setContextWindow]);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseContextWindowEntry } from "./context-window";
|
||||
|
||||
describe("parseContextWindowEntry", () => {
|
||||
it.each(["acp", "api"] as const)("retains the %s source", (source) => {
|
||||
expect(
|
||||
parseContextWindowEntry({
|
||||
size: 258_400,
|
||||
used: 95_100,
|
||||
remaining: 163_300,
|
||||
efficiency: 36.8,
|
||||
source,
|
||||
}),
|
||||
).toEqual({
|
||||
size: 258_400,
|
||||
used: 95_100,
|
||||
remaining: 163_300,
|
||||
efficiency: 36.8,
|
||||
source,
|
||||
timestamp: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops an unknown source from older or malformed metadata", () => {
|
||||
expect(parseContextWindowEntry({ size: 128_000, source: "cache" })).toEqual(
|
||||
expect.objectContaining({ source: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers a caller-supplied timestamp over embedded metadata", () => {
|
||||
expect(
|
||||
parseContextWindowEntry(
|
||||
{
|
||||
size: 128_000,
|
||||
used: 64_000,
|
||||
remaining: 64_000,
|
||||
efficiency: 50,
|
||||
timestamp: "stored",
|
||||
},
|
||||
"live-2026-07-17T11:00:00.000Z",
|
||||
),
|
||||
).toEqual(expect.objectContaining({ timestamp: "live-2026-07-17T11:00:00.000Z" }));
|
||||
});
|
||||
|
||||
it("rejects non-object metadata", () => {
|
||||
expect(parseContextWindowEntry(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ContextWindowEntry } from "./types";
|
||||
|
||||
export function parseContextWindowEntry(
|
||||
value: unknown,
|
||||
timestamp?: string,
|
||||
): ContextWindowEntry | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
|
||||
const contextWindow = value as Record<string, unknown>;
|
||||
const source =
|
||||
contextWindow.source === "acp" || contextWindow.source === "api"
|
||||
? contextWindow.source
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
size: (contextWindow.size as number) ?? 0,
|
||||
used: (contextWindow.used as number) ?? 0,
|
||||
remaining: (contextWindow.remaining as number) ?? 0,
|
||||
efficiency: (contextWindow.efficiency as number) ?? 0,
|
||||
source,
|
||||
timestamp: timestamp ?? (contextWindow.timestamp as string | undefined),
|
||||
};
|
||||
}
|
||||
@@ -153,6 +153,7 @@ export type ContextWindowEntry = {
|
||||
used: number;
|
||||
remaining: number;
|
||||
efficiency: number;
|
||||
source?: "acp" | "api";
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -190,6 +190,35 @@ describe("session.state_changed name propagation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.state_changed context window provenance", () => {
|
||||
it("retains the backend context-window source", () => {
|
||||
const setContextWindow = vi.fn();
|
||||
const store = makeStore({ setContextWindow });
|
||||
const handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!;
|
||||
|
||||
handler(
|
||||
makeMessage({
|
||||
task_id: "t-1",
|
||||
session_id: "s-1",
|
||||
metadata: {
|
||||
context_window: {
|
||||
size: 258_400,
|
||||
used: 95_100,
|
||||
remaining: 163_300,
|
||||
efficiency: 36.8,
|
||||
source: "acp",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(setContextWindow).toHaveBeenCalledWith(
|
||||
"s-1",
|
||||
expect.objectContaining({ source: "acp" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.state_changed recoverable errors", () => {
|
||||
it("upserts recoverable error metadata for non-failed session states", () => {
|
||||
const upsertTaskSessionFromEvent = vi.fn();
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/lib/types/http";
|
||||
import type { QueuedMessage } from "@/lib/state/slices/session/types";
|
||||
import { syncKanbanPrimarySessionState } from "@/lib/ws/handlers/agent-session-kanban-sync";
|
||||
import { parseContextWindowEntry } from "@/lib/state/slices/session-runtime/context-window";
|
||||
|
||||
const debug = createDebugLogger("session:state");
|
||||
|
||||
@@ -237,15 +238,8 @@ function extractContextWindow(store: StoreApi<AppState>, sessionId: string, payl
|
||||
const metadata = payload.metadata;
|
||||
if (!metadata || typeof metadata !== "object") return;
|
||||
const contextWindow = (metadata as Record<string, unknown>).context_window;
|
||||
if (!contextWindow || typeof contextWindow !== "object") return;
|
||||
const cw = contextWindow as Record<string, unknown>;
|
||||
store.getState().setContextWindow(sessionId, {
|
||||
size: (cw.size as number) ?? 0,
|
||||
used: (cw.used as number) ?? 0,
|
||||
remaining: (cw.remaining as number) ?? 0,
|
||||
efficiency: (cw.efficiency as number) ?? 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
const entry = parseContextWindowEntry(contextWindow, new Date().toISOString());
|
||||
if (entry) store.getState().setContextWindow(sessionId, entry);
|
||||
}
|
||||
|
||||
/** Copy agentctl "ready" status from one session to another (same-task switch). */
|
||||
|
||||
Reference in New Issue
Block a user