From ca67b12eb5259c61dbce8c44fb0dba69430b8272 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Fri, 17 Jul 2026 10:46:07 -0400 Subject: [PATCH 1/4] feat(sessions): surface per-task cost estimate The Claude Agent SDK already reports a per-turn `total_cost_usd`, which flowed into `ContextUsage.cost` but was never rendered. Sum it across turns (each result reports only its own spend, matching how per-turn token usage is already accumulated) and show the running total in the context usage indicator chip and breakdown popover. Codex sessions emit tokens without a cost, so `cost` stays null and the UI hides it. Generated-By: PostHog Code Task-Id: f6d673c3-965e-4598-a4bd-663f2651a5a5 --- .../core/src/sessions/contextUsage.test.ts | 71 +++++++++++++++++ packages/core/src/sessions/contextUsage.ts | 79 ++++++++++++++++--- .../components/ContextBreakdownPopover.tsx | 16 +++- .../components/ContextUsageIndicator.tsx | 13 ++- .../ui/src/features/sessions/contextColors.ts | 11 +++ 5 files changed, 176 insertions(+), 14 deletions(-) diff --git a/packages/core/src/sessions/contextUsage.test.ts b/packages/core/src/sessions/contextUsage.test.ts index b753f257a0..4a7421e92f 100644 --- a/packages/core/src/sessions/contextUsage.test.ts +++ b/packages/core/src/sessions/contextUsage.test.ts @@ -17,6 +17,31 @@ function usageUpdateEvent(used: number, size: number): AcpMessage { }; } +function costUsageUpdateEvent( + used: number, + size: number, + amount: number, + currency = "USD", +): AcpMessage { + return { + type: "acp_message", + ts: 1, + message: { + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "s1", + update: { + sessionUpdate: "usage_update", + used, + size, + cost: { amount, currency }, + }, + }, + }, + }; +} + function sizelessUsageUpdateEvent(used: number): AcpMessage { return { type: "acp_message", @@ -119,6 +144,30 @@ describe("extractContextUsage", () => { expect(result?.breakdown?.conversation).toBe(45_500); }); + it("reports null cost when no update carries a cost", () => { + const result = extractContextUsage([usageUpdateEvent(50_000, 200_000)]); + expect(result?.cost).toBeNull(); + }); + + it("surfaces the cost from a single turn", () => { + const result = extractContextUsage([ + costUsageUpdateEvent(50_000, 200_000, 0.42), + ]); + expect(result?.cost).toEqual({ amount: 0.42, currency: "USD" }); + }); + + it("sums cost across turns since each result reports only its own spend", () => { + const result = extractContextUsage([ + costUsageUpdateEvent(40_000, 200_000, 0.4), + costUsageUpdateEvent(90_000, 200_000, 0.35), + costUsageUpdateEvent(120_000, 200_000, 0.25), + ]); + // Context occupancy tracks the newest turn; cost accrues across all of them. + expect(result?.used).toBe(120_000); + expect(result?.cost?.amount).toBeCloseTo(1.0, 10); + expect(result?.cost?.currency).toBe("USD"); + }); + it("tolerates the double-underscore method prefix from extNotification", () => { const result = extractContextUsage([ usageUpdateEvent(50_000, 200_000), @@ -180,6 +229,28 @@ describe("createContextUsageTracker", () => { expect(tracker.update([earlier])?.used).toBe(50_000); }); + it("accumulates cost only over newly appended turns", () => { + const tracker = createContextUsageTracker(); + const first = costUsageUpdateEvent(40_000, 200_000, 0.4); + + expect(tracker.update([first])?.cost?.amount).toBeCloseTo(0.4, 10); + + const result = tracker.update([ + first, + costUsageUpdateEvent(90_000, 200_000, 0.35), + ]); + expect(result?.cost?.amount).toBeCloseTo(0.75, 10); + }); + + it("matches the batch extractor for a cost-bearing log", () => { + const tracker = createContextUsageTracker(); + const events = [ + costUsageUpdateEvent(40_000, 200_000, 0.4), + costUsageUpdateEvent(90_000, 200_000, 0.35), + ]; + expect(tracker.update(events)).toEqual(extractContextUsage(events)); + }); + it("rebuilds when the tail changes at the same length", () => { const tracker = createContextUsageTracker(); const first = usageUpdateEvent(50_000, 200_000); diff --git a/packages/core/src/sessions/contextUsage.ts b/packages/core/src/sessions/contextUsage.ts index 6d37bc156c..06aee240cf 100644 --- a/packages/core/src/sessions/contextUsage.ts +++ b/packages/core/src/sessions/contextUsage.ts @@ -15,18 +15,33 @@ export interface ContextUsage { used: number; size: number; percentage: number; + /** + * Cumulative estimated cost of the whole session, summed across turns. `used` + * is a point-in-time snapshot of resident context, but each turn's cost is a + * spend that accrues, so cost is totalled rather than replaced. `null` when + * no turn reported a cost (e.g. codex, which emits tokens without a cost). + */ cost: { amount: number; currency: string } | null; breakdown: ContextBreakdown | null; } -type ContextUsageAggregate = Omit; +type ContextUsageAggregate = Omit; export function extractContextUsage(events: AcpMessage[]): ContextUsage | null { let aggregate: ContextUsageAggregate | null = null; let breakdown: ContextBreakdown | null = null; + let costAmount: number | null = null; + let costCurrency = "USD"; + // Cost sums over every turn, so this can't early-break once the newest + // aggregate/breakdown is found — it walks the full log. for (let i = events.length - 1; i >= 0; i--) { const msg = events[i].message; + const cost = extractCost(msg); + if (cost) { + costAmount = (costAmount ?? 0) + cost.amount; + costCurrency = cost.currency; + } if (!aggregate) { aggregate = extractAggregate(msg); } else if (aggregate.size <= 0) { @@ -37,36 +52,55 @@ export function extractContextUsage(events: AcpMessage[]): ContextUsage | null { if (!breakdown) { breakdown = extractBreakdown(msg); } - if (aggregate && aggregate.size > 0 && breakdown) break; } if (!aggregate) return null; - return { ...aggregate, breakdown }; + return { ...aggregate, cost: toCost(costAmount, costCurrency), breakdown }; } interface ContextUsageState { aggregate: ContextUsageAggregate | null; + costAmount: number | null; + costCurrency: string; breakdown: ContextBreakdown | null; } export function createContextUsageTracker() { return createAppendOnlyTracker({ - init: () => ({ aggregate: null, breakdown: null }), + init: () => ({ + aggregate: null, + costAmount: null, + costCurrency: "USD", + breakdown: null, + }), processEvent: (state, event) => { const msg = event.message; const next = extractAggregate(msg); if (next) { state.aggregate = withCarriedSize(next, state.aggregate); } + const cost = extractCost(msg); + if (cost) { + state.costAmount = (state.costAmount ?? 0) + cost.amount; + state.costCurrency = cost.currency; + } state.breakdown = extractBreakdown(msg) ?? state.breakdown; }, getResult: (state) => state.aggregate - ? { ...state.aggregate, breakdown: state.breakdown } + ? { + ...state.aggregate, + cost: toCost(state.costAmount, state.costCurrency), + breakdown: state.breakdown, + } : null, }); } +function toCost(amount: number | null, currency: string): ContextUsage["cost"] { + return amount != null ? { amount, currency } : null; +} + /** * An update that omits `size` must not wipe a previously known context window * (codex reports `modelContextWindow` intermittently), so keep the last known @@ -116,11 +150,38 @@ function extractAggregate( const size = typeof update.size === "number" ? update.size : 0; const percentage = size > 0 ? Math.min(100, Math.round((update.used / size) * 100)) : 0; + return { used: update.used, size, percentage }; + } + } + return null; +} + +function extractCost( + msg: AcpMessage["message"], +): { amount: number; currency: string } | null { + if ( + "method" in msg && + msg.method === "session/update" && + !("id" in msg) && + "params" in msg + ) { + const params = msg.params as + | { + update?: { + sessionUpdate?: string; + cost?: { amount: number; currency: string } | null; + }; + } + | undefined; + const update = params?.update; + if ( + update?.sessionUpdate === "usage_update" && + update.cost && + typeof update.cost.amount === "number" + ) { return { - used: update.used, - size, - percentage, - cost: update.cost ?? null, + amount: update.cost.amount, + currency: update.cost.currency ?? "USD", }; } } diff --git a/packages/ui/src/features/sessions/components/ContextBreakdownPopover.tsx b/packages/ui/src/features/sessions/components/ContextBreakdownPopover.tsx index 316ca05aa0..faab51bd86 100644 --- a/packages/ui/src/features/sessions/components/ContextBreakdownPopover.tsx +++ b/packages/ui/src/features/sessions/components/ContextBreakdownPopover.tsx @@ -1,5 +1,6 @@ import { CONTEXT_CATEGORIES, + formatCostUsd, formatTokensCompact, getOverallUsageColor, } from "@posthog/ui/features/sessions/contextColors"; @@ -13,7 +14,7 @@ interface ContextBreakdownPopoverProps { export function ContextBreakdownPopover({ usage, }: ContextBreakdownPopoverProps) { - const { used, size, percentage, breakdown } = usage; + const { used, size, percentage, cost, breakdown } = usage; const fillColor = getOverallUsageColor(percentage); // The context window can be unknown (size 0) — show just the token count // rather than a misleading "~X / 0 tokens · 0% full". @@ -71,6 +72,19 @@ export function ContextBreakdownPopover({ Detailed breakdown available after the first response. )} + + {cost && ( + + Estimated cost + + {formatCostUsd(cost.amount)} + + + )} ); } diff --git a/packages/ui/src/features/sessions/components/ContextUsageIndicator.tsx b/packages/ui/src/features/sessions/components/ContextUsageIndicator.tsx index 1ae3ac46a1..eb7fbdd995 100644 --- a/packages/ui/src/features/sessions/components/ContextUsageIndicator.tsx +++ b/packages/ui/src/features/sessions/components/ContextUsageIndicator.tsx @@ -1,4 +1,5 @@ import { + formatCostUsd, formatTokensCompact, getOverallUsageColor, } from "@posthog/ui/features/sessions/contextColors"; @@ -18,12 +19,18 @@ interface ContextUsageIndicatorProps { export function ContextUsageIndicator({ usage }: ContextUsageIndicatorProps) { if (!usage) return null; - const { used, size, percentage } = usage; + const { used, size, percentage, cost } = usage; // The context window can be unknown (size 0) — show just the token count // rather than a misleading "X/0 · 0%". const hasSize = size > 0; const strokeDashoffset = CIRCUMFERENCE - (percentage / 100) * CIRCUMFERENCE; const color = getOverallUsageColor(percentage); + const tokenLabel = hasSize + ? `${formatTokensCompact(used)}/${formatTokensCompact(size)} · ${percentage}%` + : formatTokensCompact(used); + const label = cost + ? `${tokenLabel} · ${formatCostUsd(cost.amount)}` + : tokenLabel; return ( @@ -66,9 +73,7 @@ export function ContextUsageIndicator({ usage }: ContextUsageIndicatorProps) { /> - {hasSize - ? `${formatTokensCompact(used)}/${formatTokensCompact(size)} · ${percentage}%` - : formatTokensCompact(used)} + {label} diff --git a/packages/ui/src/features/sessions/contextColors.ts b/packages/ui/src/features/sessions/contextColors.ts index 38c03621f5..a3e5896cfd 100644 --- a/packages/ui/src/features/sessions/contextColors.ts +++ b/packages/ui/src/features/sessions/contextColors.ts @@ -28,3 +28,14 @@ export function formatTokensCompact(tokens: number): string { if (tokens >= 1000) return `${Math.round(tokens / 1000)}K`; return tokens.toString(); } + +/** + * Formats a USD cost estimate for display. Sub-cent amounts collapse to + * `<$0.01` so a non-zero spend never reads as free; everything else shows two + * decimals ($0.42, $12.34). + */ +export function formatCostUsd(amount: number): string { + if (amount <= 0) return "$0.00"; + if (amount < 0.01) return "<$0.01"; + return `$${amount.toFixed(2)}`; +} From 9e64eac6d8c1ea71c7e1e2149ea6e92d879d401d Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Fri, 17 Jul 2026 10:53:42 -0400 Subject: [PATCH 2/4] feat(sessions): gate per-task cost behind posthog-code-task-cost flag Wrap the cost readout (indicator chip suffix and breakdown popover row) in a feature flag so it can roll out gradually. On in local dev via the import.meta.env.DEV fallback, matching the other UI flags. Generated-By: PostHog Code Task-Id: f6d673c3-965e-4598-a4bd-663f2651a5a5 --- packages/shared/src/flags.ts | 2 ++ .../sessions/components/ContextBreakdownPopover.tsx | 4 +++- .../sessions/components/ContextUsageIndicator.tsx | 9 +++++++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/flags.ts b/packages/shared/src/flags.ts index d692da1fda..8600cec7d0 100644 --- a/packages/shared/src/flags.ts +++ b/packages/shared/src/flags.ts @@ -21,3 +21,5 @@ export const GLM_MODEL_FLAG = "posthog-code-glm-model"; export const SPOKEN_NARRATION_FLAG = "posthog-code-spoken-narration"; // Gates importing and relaying local MCP servers into cloud task runs. export const LOCAL_MCP_IMPORT_FLAG = "posthog-code-local-mcp-import"; +/** Per-task estimated cost readout in the context usage indicator. */ +export const TASK_COST_FLAG = "posthog-code-task-cost"; diff --git a/packages/ui/src/features/sessions/components/ContextBreakdownPopover.tsx b/packages/ui/src/features/sessions/components/ContextBreakdownPopover.tsx index faab51bd86..4269e1504f 100644 --- a/packages/ui/src/features/sessions/components/ContextBreakdownPopover.tsx +++ b/packages/ui/src/features/sessions/components/ContextBreakdownPopover.tsx @@ -9,10 +9,12 @@ import { Flex, Text } from "@radix-ui/themes"; interface ContextBreakdownPopoverProps { usage: ContextUsage; + showCost?: boolean; } export function ContextBreakdownPopover({ usage, + showCost = false, }: ContextBreakdownPopoverProps) { const { used, size, percentage, cost, breakdown } = usage; const fillColor = getOverallUsageColor(percentage); @@ -73,7 +75,7 @@ export function ContextBreakdownPopover({ )} - {cost && ( + {showCost && cost && ( 0; const strokeDashoffset = CIRCUMFERENCE - (percentage / 100) * CIRCUMFERENCE; const color = getOverallUsageColor(percentage); + const showCost = costEnabled && cost !== null; const tokenLabel = hasSize ? `${formatTokensCompact(used)}/${formatTokensCompact(size)} · ${percentage}%` : formatTokensCompact(used); - const label = cost + const label = showCost ? `${tokenLabel} · ${formatCostUsd(cost.amount)}` : tokenLabel; @@ -79,7 +84,7 @@ export function ContextUsageIndicator({ usage }: ContextUsageIndicatorProps) { - + ); From f72c082df2db22333c91d195eccce00c65836ebf Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Fri, 17 Jul 2026 11:09:01 -0400 Subject: [PATCH 3/4] test(sessions): mock feature flag in ContextUsageIndicator test Gating the cost readout behind useFeatureFlag made the component call useService(FEATURE_FLAGS), which throws in the test's provider-less render. Mock the hook (matching the pattern used across the UI test suite) and add a case covering the cost suffix when the flag is on. Generated-By: PostHog Code Task-Id: f6d673c3-965e-4598-a4bd-663f2651a5a5 --- .../components/ContextUsageIndicator.test.tsx | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/features/sessions/components/ContextUsageIndicator.test.tsx b/packages/ui/src/features/sessions/components/ContextUsageIndicator.test.tsx index 49945a0f7d..08013c5181 100644 --- a/packages/ui/src/features/sessions/components/ContextUsageIndicator.test.tsx +++ b/packages/ui/src/features/sessions/components/ContextUsageIndicator.test.tsx @@ -1,9 +1,18 @@ import type { ContextUsage } from "@posthog/ui/features/sessions/hooks/useContextUsage"; import { Theme } from "@radix-ui/themes"; import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { ContextUsageIndicator } from "./ContextUsageIndicator"; +const flagState = vi.hoisted(() => ({ enabled: false })); +vi.mock("@posthog/ui/features/feature-flags/useFeatureFlag", () => ({ + useFeatureFlag: () => flagState.enabled, +})); + +beforeEach(() => { + flagState.enabled = false; +}); + function usage(overrides?: Partial): ContextUsage { return { used: 50_000, @@ -53,6 +62,18 @@ describe("ContextUsageIndicator", () => { ).toBeInTheDocument(); }); + it("appends the estimated cost to the label when the flag is enabled", () => { + flagState.enabled = true; + render( + + + , + ); + expect(screen.getByText(/50K\/200K · 25% · \$0\.42/)).toBeInTheDocument(); + }); + it("renders a finite stroke offset at 0% (no NaN/Infinity)", () => { const { container } = render( From d3368ab5e268b8472d60b73ba6cb13931c33e220 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Fri, 17 Jul 2026 11:31:50 -0400 Subject: [PATCH 4/4] docs(sessions): condense ContextUsage.cost comment to one line Generated-By: PostHog Code Task-Id: f6d673c3-965e-4598-a4bd-663f2651a5a5 --- packages/core/src/sessions/contextUsage.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/core/src/sessions/contextUsage.ts b/packages/core/src/sessions/contextUsage.ts index 06aee240cf..df3f4001e3 100644 --- a/packages/core/src/sessions/contextUsage.ts +++ b/packages/core/src/sessions/contextUsage.ts @@ -15,12 +15,7 @@ export interface ContextUsage { used: number; size: number; percentage: number; - /** - * Cumulative estimated cost of the whole session, summed across turns. `used` - * is a point-in-time snapshot of resident context, but each turn's cost is a - * spend that accrues, so cost is totalled rather than replaced. `null` when - * no turn reported a cost (e.g. codex, which emits tokens without a cost). - */ + /** Cumulative estimated session cost, summed across turns; `null` if none reported (e.g. codex). */ cost: { amount: number; currency: string } | null; breakdown: ContextBreakdown | null; }