Skip to content

Commit efc78d7

Browse files
authored
Merge branch 'main' into posthog-code/autoresearch-task-switch-latency
2 parents aebb540 + b092f19 commit efc78d7

7 files changed

Lines changed: 203 additions & 16 deletions

File tree

packages/core/src/sessions/contextUsage.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,31 @@ function usageUpdateEvent(used: number, size: number): AcpMessage {
1717
};
1818
}
1919

20+
function costUsageUpdateEvent(
21+
used: number,
22+
size: number,
23+
amount: number,
24+
currency = "USD",
25+
): AcpMessage {
26+
return {
27+
type: "acp_message",
28+
ts: 1,
29+
message: {
30+
jsonrpc: "2.0",
31+
method: "session/update",
32+
params: {
33+
sessionId: "s1",
34+
update: {
35+
sessionUpdate: "usage_update",
36+
used,
37+
size,
38+
cost: { amount, currency },
39+
},
40+
},
41+
},
42+
};
43+
}
44+
2045
function sizelessUsageUpdateEvent(used: number): AcpMessage {
2146
return {
2247
type: "acp_message",
@@ -119,6 +144,30 @@ describe("extractContextUsage", () => {
119144
expect(result?.breakdown?.conversation).toBe(45_500);
120145
});
121146

147+
it("reports null cost when no update carries a cost", () => {
148+
const result = extractContextUsage([usageUpdateEvent(50_000, 200_000)]);
149+
expect(result?.cost).toBeNull();
150+
});
151+
152+
it("surfaces the cost from a single turn", () => {
153+
const result = extractContextUsage([
154+
costUsageUpdateEvent(50_000, 200_000, 0.42),
155+
]);
156+
expect(result?.cost).toEqual({ amount: 0.42, currency: "USD" });
157+
});
158+
159+
it("sums cost across turns since each result reports only its own spend", () => {
160+
const result = extractContextUsage([
161+
costUsageUpdateEvent(40_000, 200_000, 0.4),
162+
costUsageUpdateEvent(90_000, 200_000, 0.35),
163+
costUsageUpdateEvent(120_000, 200_000, 0.25),
164+
]);
165+
// Context occupancy tracks the newest turn; cost accrues across all of them.
166+
expect(result?.used).toBe(120_000);
167+
expect(result?.cost?.amount).toBeCloseTo(1.0, 10);
168+
expect(result?.cost?.currency).toBe("USD");
169+
});
170+
122171
it("tolerates the double-underscore method prefix from extNotification", () => {
123172
const result = extractContextUsage([
124173
usageUpdateEvent(50_000, 200_000),
@@ -180,6 +229,28 @@ describe("createContextUsageTracker", () => {
180229
expect(tracker.update([earlier])?.used).toBe(50_000);
181230
});
182231

232+
it("accumulates cost only over newly appended turns", () => {
233+
const tracker = createContextUsageTracker();
234+
const first = costUsageUpdateEvent(40_000, 200_000, 0.4);
235+
236+
expect(tracker.update([first])?.cost?.amount).toBeCloseTo(0.4, 10);
237+
238+
const result = tracker.update([
239+
first,
240+
costUsageUpdateEvent(90_000, 200_000, 0.35),
241+
]);
242+
expect(result?.cost?.amount).toBeCloseTo(0.75, 10);
243+
});
244+
245+
it("matches the batch extractor for a cost-bearing log", () => {
246+
const tracker = createContextUsageTracker();
247+
const events = [
248+
costUsageUpdateEvent(40_000, 200_000, 0.4),
249+
costUsageUpdateEvent(90_000, 200_000, 0.35),
250+
];
251+
expect(tracker.update(events)).toEqual(extractContextUsage(events));
252+
});
253+
183254
it("rebuilds when the tail changes at the same length", () => {
184255
const tracker = createContextUsageTracker();
185256
const first = usageUpdateEvent(50_000, 200_000);

packages/core/src/sessions/contextUsage.ts

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,28 @@ export interface ContextUsage {
1515
used: number;
1616
size: number;
1717
percentage: number;
18+
/** Cumulative estimated session cost, summed across turns; `null` if none reported (e.g. codex). */
1819
cost: { amount: number; currency: string } | null;
1920
breakdown: ContextBreakdown | null;
2021
}
2122

22-
type ContextUsageAggregate = Omit<ContextUsage, "breakdown">;
23+
type ContextUsageAggregate = Omit<ContextUsage, "breakdown" | "cost">;
2324

2425
export function extractContextUsage(events: AcpMessage[]): ContextUsage | null {
2526
let aggregate: ContextUsageAggregate | null = null;
2627
let breakdown: ContextBreakdown | null = null;
28+
let costAmount: number | null = null;
29+
let costCurrency = "USD";
2730

31+
// Cost sums over every turn, so this can't early-break once the newest
32+
// aggregate/breakdown is found — it walks the full log.
2833
for (let i = events.length - 1; i >= 0; i--) {
2934
const msg = events[i].message;
35+
const cost = extractCost(msg);
36+
if (cost) {
37+
costAmount = (costAmount ?? 0) + cost.amount;
38+
costCurrency = cost.currency;
39+
}
3040
if (!aggregate) {
3141
aggregate = extractAggregate(msg);
3242
} else if (aggregate.size <= 0) {
@@ -37,36 +47,55 @@ export function extractContextUsage(events: AcpMessage[]): ContextUsage | null {
3747
if (!breakdown) {
3848
breakdown = extractBreakdown(msg);
3949
}
40-
if (aggregate && aggregate.size > 0 && breakdown) break;
4150
}
4251

4352
if (!aggregate) return null;
44-
return { ...aggregate, breakdown };
53+
return { ...aggregate, cost: toCost(costAmount, costCurrency), breakdown };
4554
}
4655

4756
interface ContextUsageState {
4857
aggregate: ContextUsageAggregate | null;
58+
costAmount: number | null;
59+
costCurrency: string;
4960
breakdown: ContextBreakdown | null;
5061
}
5162

5263
export function createContextUsageTracker() {
5364
return createAppendOnlyTracker<ContextUsageState, ContextUsage | null>({
54-
init: () => ({ aggregate: null, breakdown: null }),
65+
init: () => ({
66+
aggregate: null,
67+
costAmount: null,
68+
costCurrency: "USD",
69+
breakdown: null,
70+
}),
5571
processEvent: (state, event) => {
5672
const msg = event.message;
5773
const next = extractAggregate(msg);
5874
if (next) {
5975
state.aggregate = withCarriedSize(next, state.aggregate);
6076
}
77+
const cost = extractCost(msg);
78+
if (cost) {
79+
state.costAmount = (state.costAmount ?? 0) + cost.amount;
80+
state.costCurrency = cost.currency;
81+
}
6182
state.breakdown = extractBreakdown(msg) ?? state.breakdown;
6283
},
6384
getResult: (state) =>
6485
state.aggregate
65-
? { ...state.aggregate, breakdown: state.breakdown }
86+
? {
87+
...state.aggregate,
88+
cost: toCost(state.costAmount, state.costCurrency),
89+
breakdown: state.breakdown,
90+
}
6691
: null,
6792
});
6893
}
6994

95+
function toCost(amount: number | null, currency: string): ContextUsage["cost"] {
96+
return amount != null ? { amount, currency } : null;
97+
}
98+
7099
/**
71100
* An update that omits `size` must not wipe a previously known context window
72101
* (codex reports `modelContextWindow` intermittently), so keep the last known
@@ -116,11 +145,38 @@ function extractAggregate(
116145
const size = typeof update.size === "number" ? update.size : 0;
117146
const percentage =
118147
size > 0 ? Math.min(100, Math.round((update.used / size) * 100)) : 0;
148+
return { used: update.used, size, percentage };
149+
}
150+
}
151+
return null;
152+
}
153+
154+
function extractCost(
155+
msg: AcpMessage["message"],
156+
): { amount: number; currency: string } | null {
157+
if (
158+
"method" in msg &&
159+
msg.method === "session/update" &&
160+
!("id" in msg) &&
161+
"params" in msg
162+
) {
163+
const params = msg.params as
164+
| {
165+
update?: {
166+
sessionUpdate?: string;
167+
cost?: { amount: number; currency: string } | null;
168+
};
169+
}
170+
| undefined;
171+
const update = params?.update;
172+
if (
173+
update?.sessionUpdate === "usage_update" &&
174+
update.cost &&
175+
typeof update.cost.amount === "number"
176+
) {
119177
return {
120-
used: update.used,
121-
size,
122-
percentage,
123-
cost: update.cost ?? null,
178+
amount: update.cost.amount,
179+
currency: update.cost.currency ?? "USD",
124180
};
125181
}
126182
}

packages/shared/src/flags.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,5 @@ export const GLM_MODEL_FLAG = "posthog-code-glm-model";
2121
export const SPOKEN_NARRATION_FLAG = "posthog-code-spoken-narration";
2222
// Gates importing and relaying local MCP servers into cloud task runs.
2323
export const LOCAL_MCP_IMPORT_FLAG = "posthog-code-local-mcp-import";
24+
/** Per-task estimated cost readout in the context usage indicator. */
25+
export const TASK_COST_FLAG = "posthog-code-task-cost";

packages/ui/src/features/sessions/components/ContextBreakdownPopover.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
CONTEXT_CATEGORIES,
3+
formatCostUsd,
34
formatTokensCompact,
45
getOverallUsageColor,
56
} from "@posthog/ui/features/sessions/contextColors";
@@ -8,12 +9,14 @@ import { Flex, Text } from "@radix-ui/themes";
89

910
interface ContextBreakdownPopoverProps {
1011
usage: ContextUsage;
12+
showCost?: boolean;
1113
}
1214

1315
export function ContextBreakdownPopover({
1416
usage,
17+
showCost = false,
1518
}: ContextBreakdownPopoverProps) {
16-
const { used, size, percentage, breakdown } = usage;
19+
const { used, size, percentage, cost, breakdown } = usage;
1720
const fillColor = getOverallUsageColor(percentage);
1821
// The context window can be unknown (size 0) — show just the token count
1922
// rather than a misleading "~X / 0 tokens · 0% full".
@@ -71,6 +74,19 @@ export function ContextBreakdownPopover({
7174
Detailed breakdown available after the first response.
7275
</Text>
7376
)}
77+
78+
{showCost && cost && (
79+
<Flex
80+
align="center"
81+
justify="between"
82+
className="border-(--gray-4) border-t pt-2 text-[13px]"
83+
>
84+
<Text className="text-(--gray-11)">Estimated cost</Text>
85+
<Text className="font-medium text-(--gray-12) tabular-nums">
86+
{formatCostUsd(cost.amount)}
87+
</Text>
88+
</Flex>
89+
)}
7490
</Flex>
7591
);
7692
}

packages/ui/src/features/sessions/components/ContextUsageIndicator.test.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
import type { ContextUsage } from "@posthog/ui/features/sessions/hooks/useContextUsage";
22
import { Theme } from "@radix-ui/themes";
33
import { render, screen } from "@testing-library/react";
4-
import { describe, expect, it } from "vitest";
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
55
import { ContextUsageIndicator } from "./ContextUsageIndicator";
66

7+
const flagState = vi.hoisted(() => ({ enabled: false }));
8+
vi.mock("@posthog/ui/features/feature-flags/useFeatureFlag", () => ({
9+
useFeatureFlag: () => flagState.enabled,
10+
}));
11+
12+
beforeEach(() => {
13+
flagState.enabled = false;
14+
});
15+
716
function usage(overrides?: Partial<ContextUsage>): ContextUsage {
817
return {
918
used: 50_000,
@@ -53,6 +62,18 @@ describe("ContextUsageIndicator", () => {
5362
).toBeInTheDocument();
5463
});
5564

65+
it("appends the estimated cost to the label when the flag is enabled", () => {
66+
flagState.enabled = true;
67+
render(
68+
<Theme>
69+
<ContextUsageIndicator
70+
usage={usage({ cost: { amount: 0.42, currency: "USD" } })}
71+
/>
72+
</Theme>,
73+
);
74+
expect(screen.getByText(/50K\/200K · 25% · \$0\.42/)).toBeInTheDocument();
75+
});
76+
5677
it("renders a finite stroke offset at 0% (no NaN/Infinity)", () => {
5778
const { container } = render(
5879
<Theme>

packages/ui/src/features/sessions/components/ContextUsageIndicator.tsx

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import { TASK_COST_FLAG } from "@posthog/shared";
2+
import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag";
13
import {
4+
formatCostUsd,
25
formatTokensCompact,
36
getOverallUsageColor,
47
} from "@posthog/ui/features/sessions/contextColors";
@@ -16,14 +19,23 @@ interface ContextUsageIndicatorProps {
1619
}
1720

1821
export function ContextUsageIndicator({ usage }: ContextUsageIndicatorProps) {
22+
const costEnabled = useFeatureFlag(TASK_COST_FLAG) || import.meta.env.DEV;
23+
1924
if (!usage) return null;
2025

21-
const { used, size, percentage } = usage;
26+
const { used, size, percentage, cost } = usage;
2227
// The context window can be unknown (size 0) — show just the token count
2328
// rather than a misleading "X/0 · 0%".
2429
const hasSize = size > 0;
2530
const strokeDashoffset = CIRCUMFERENCE - (percentage / 100) * CIRCUMFERENCE;
2631
const color = getOverallUsageColor(percentage);
32+
const showCost = costEnabled && cost !== null;
33+
const tokenLabel = hasSize
34+
? `${formatTokensCompact(used)}/${formatTokensCompact(size)} · ${percentage}%`
35+
: formatTokensCompact(used);
36+
const label = showCost
37+
? `${tokenLabel} · ${formatCostUsd(cost.amount)}`
38+
: tokenLabel;
2739

2840
return (
2941
<Popover.Root>
@@ -66,15 +78,13 @@ export function ContextUsageIndicator({ usage }: ContextUsageIndicatorProps) {
6678
/>
6779
</svg>
6880
<Text className="text-[13px] text-muted-foreground tabular-nums">
69-
{hasSize
70-
? `${formatTokensCompact(used)}/${formatTokensCompact(size)} · ${percentage}%`
71-
: formatTokensCompact(used)}
81+
{label}
7282
</Text>
7383
</Flex>
7484
</button>
7585
</Popover.Trigger>
7686
<Popover.Content size="2" side="top" align="end" sideOffset={6}>
77-
<ContextBreakdownPopover usage={usage} />
87+
<ContextBreakdownPopover usage={usage} showCost={showCost} />
7888
</Popover.Content>
7989
</Popover.Root>
8090
);

packages/ui/src/features/sessions/contextColors.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,14 @@ export function formatTokensCompact(tokens: number): string {
2828
if (tokens >= 1000) return `${Math.round(tokens / 1000)}K`;
2929
return tokens.toString();
3030
}
31+
32+
/**
33+
* Formats a USD cost estimate for display. Sub-cent amounts collapse to
34+
* `<$0.01` so a non-zero spend never reads as free; everything else shows two
35+
* decimals ($0.42, $12.34).
36+
*/
37+
export function formatCostUsd(amount: number): string {
38+
if (amount <= 0) return "$0.00";
39+
if (amount < 0.01) return "<$0.01";
40+
return `$${amount.toFixed(2)}`;
41+
}

0 commit comments

Comments
 (0)