feat: show context window data source (#1752)

This commit is contained in:
Carlos Florêncio
2026-07-17 18:48:08 +01:00
committed by GitHub
parent 9e484d7e89
commit 4dd1d333e7
13 changed files with 495 additions and 123 deletions
@@ -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();
+3 -9
View File
@@ -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). */