diff --git a/apps/backend/internal/orchestrator/event_handlers_git.go b/apps/backend/internal/orchestrator/event_handlers_git.go
index c86e67200b..0b396b13dc 100644
--- a/apps/backend/internal/orchestrator/event_handlers_git.go
+++ b/apps/backend/internal/orchestrator/event_handlers_git.go
@@ -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 {
diff --git a/apps/backend/internal/orchestrator/event_handlers_git_test.go b/apps/backend/internal/orchestrator/event_handlers_git_test.go
index fc1d88ae79..92fd04f732 100644
--- a/apps/backend/internal/orchestrator/event_handlers_git_test.go
+++ b/apps/backend/internal/orchestrator/event_handlers_git_test.go
@@ -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,
diff --git a/apps/web/components/task/chat/token-usage-display.test.tsx b/apps/web/components/task/chat/token-usage-display.test.tsx
index 37aa017316..1a46376bfb 100644
--- a/apps/web/components/task/chat/token-usage-display.test.tsx
+++ b/apps/web/components/task/chat/token-usage-display.test.tsx
@@ -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 }) => (
{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(
);
-
- 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(
);
+
+ 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(
+
,
+ );
+
+ 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(
);
+
+ expect(getByText("API")).toBeDefined();
+ expect(getByLabelText("About context window source")).toBeDefined();
+ expect(getByText(/model's advertised maximum from the catalogue/i)).toBeDefined();
+ });
+});
diff --git a/apps/web/components/task/chat/token-usage-display.tsx b/apps/web/components/task/chat/token-usage-display.tsx
index a8365d0184..68af7a2b33 100644
--- a/apps/web/components/task/chat/token-usage-display.tsx
+++ b/apps/web/components/task/chat/token-usage-display.tsx
@@ -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
(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 (
+
+ );
+}
+
+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 (
+
+ Source
+ {source.toUpperCase()}
+
+
+ {description}
+
+
+ );
+}
+
/**
* 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 (
-
-
-
-
-
-
-
-
-
- Context window
-
-
- {usagePercent.toFixed(0)}%
-
-
-
+ // The UI wrapper defaults this to true; the source control must remain reachable inside.
+
+
+
+
+
+
+
+
+
+
+ Context window
+
+
+ {usagePercent.toFixed(0)}%
+
+
-
-
- {formatNumber(used)} of {formatNumber(size)} tokens
+ className={cn(
+ "h-1.5 overflow-hidden rounded-full bg-muted",
+ getCircleColor(usagePercent),
+ )}
+ >
+
+
+
+
+ {formatNumber(used)} of {formatNumber(size)} tokens
+
+
+
+
-
-
-
-
+
+
+
);
});
diff --git a/apps/web/e2e/tests/chat/context-window-source-helpers.ts b/apps/web/e2e/tests/chat/context-window-source-helpers.ts
new file mode 100644
index 0000000000..b056f5e09b
--- /dev/null
+++ b/apps/web/e2e/tests/chat/context-window-source-helpers.ts
@@ -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
{
+ 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 {
+ 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");
+ }
+}
diff --git a/apps/web/e2e/tests/chat/context-window-source.spec.ts b/apps/web/e2e/tests/chat/context-window-source.spec.ts
new file mode 100644
index 0000000000..13f8903644
--- /dev/null
+++ b/apps/web/e2e/tests/chat/context-window-source.spec.ts
@@ -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",
+ });
+});
diff --git a/apps/web/e2e/tests/chat/mobile-context-window-source.spec.ts b/apps/web/e2e/tests/chat/mobile-context-window-source.spec.ts
new file mode 100644
index 0000000000..f40636248c
--- /dev/null
+++ b/apps/web/e2e/tests/chat/mobile-context-window-source.spec.ts
@@ -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);
+});
diff --git a/apps/web/hooks/domains/session/use-session-context-window.ts b/apps/web/hooks/domains/session/use-session-context-window.ts
index c0ca41a960..7faa717130 100644
--- a/apps/web/hooks/domains/session/use-session-context-window.ts
+++ b/apps/web/hooks/domains/session/use-session-context-window.ts
@@ -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).context_window;
- if (!storedContextWindow || typeof storedContextWindow !== "object") return;
-
- // Map stored context window to ContextWindowEntry
- const cw = storedContextWindow as Record;
- 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]);
diff --git a/apps/web/lib/state/slices/session-runtime/context-window.test.ts b/apps/web/lib/state/slices/session-runtime/context-window.test.ts
new file mode 100644
index 0000000000..56007bf547
--- /dev/null
+++ b/apps/web/lib/state/slices/session-runtime/context-window.test.ts
@@ -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();
+ });
+});
diff --git a/apps/web/lib/state/slices/session-runtime/context-window.ts b/apps/web/lib/state/slices/session-runtime/context-window.ts
new file mode 100644
index 0000000000..4a620fb037
--- /dev/null
+++ b/apps/web/lib/state/slices/session-runtime/context-window.ts
@@ -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;
+ 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),
+ };
+}
diff --git a/apps/web/lib/state/slices/session-runtime/types.ts b/apps/web/lib/state/slices/session-runtime/types.ts
index a280e6d22c..ac0c4ec372 100644
--- a/apps/web/lib/state/slices/session-runtime/types.ts
+++ b/apps/web/lib/state/slices/session-runtime/types.ts
@@ -153,6 +153,7 @@ export type ContextWindowEntry = {
used: number;
remaining: number;
efficiency: number;
+ source?: "acp" | "api";
timestamp?: string;
};
diff --git a/apps/web/lib/ws/handlers/agent-session.test.ts b/apps/web/lib/ws/handlers/agent-session.test.ts
index faf9023ae4..11e7f503ab 100644
--- a/apps/web/lib/ws/handlers/agent-session.test.ts
+++ b/apps/web/lib/ws/handlers/agent-session.test.ts
@@ -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();
diff --git a/apps/web/lib/ws/handlers/agent-session.ts b/apps/web/lib/ws/handlers/agent-session.ts
index 6eaca9bc1d..5449d2af6e 100644
--- a/apps/web/lib/ws/handlers/agent-session.ts
+++ b/apps/web/lib/ws/handlers/agent-session.ts
@@ -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, sessionId: string, payl
const metadata = payload.metadata;
if (!metadata || typeof metadata !== "object") return;
const contextWindow = (metadata as Record).context_window;
- if (!contextWindow || typeof contextWindow !== "object") return;
- const cw = contextWindow as Record;
- 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). */