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). */