Skip to content

Commit 199a7fc

Browse files
committed
Warn staff before continuing a stale, costly conversation
When a PostHog employee (is_staff) opens a large conversation (>= 40k context tokens) left idle past the ~5-minute prompt-cache TTL, its Anthropic prompt cache has likely expired, so the next turn re-processes the whole prefix at full input price instead of the ~10% cached-read rate. Grey out the composer and show an AlertDialog offering to start fresh or continue anyway; the choice is remembered per session and the gate can be reopened from an inline notice. - core: pure shouldWarnStaleCostlyConversation() helper + default threshold in contextUsage.ts, with parameterised tests - ui: StaleConversationCostDialog, a per-session acknowledgement store (staleConversationGateStore), and SessionView wiring that ORs the gate into PromptInput's `disabled`/tooltip Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RkLQSsdj7o4wQjcmjM1SD1
1 parent 3cfd449 commit 199a7fc

6 files changed

Lines changed: 386 additions & 3 deletions

File tree

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

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import type { AcpMessage } from "@posthog/shared";
22
import { describe, expect, it } from "vitest";
3-
import { createContextUsageTracker, extractContextUsage } from "./contextUsage";
3+
import {
4+
createContextUsageTracker,
5+
DEFAULT_STALE_COSTLY_THRESHOLD,
6+
extractContextUsage,
7+
shouldWarnStaleCostlyConversation,
8+
} from "./contextUsage";
49

510
function usageUpdateEvent(used: number, size: number): AcpMessage {
611
return {
@@ -132,3 +137,94 @@ describe("createContextUsageTracker", () => {
132137
expect(tracker.update(events)).toEqual(extractContextUsage(events));
133138
});
134139
});
140+
141+
describe("shouldWarnStaleCostlyConversation", () => {
142+
const now = 1_000_000_000;
143+
const threshold = { tokens: 40_000, staleMs: 5 * 60 * 1000 };
144+
145+
it.each([
146+
{
147+
name: "large + stale → warn",
148+
usedTokens: 50_000,
149+
idleMs: 10 * 60 * 1000,
150+
expected: true,
151+
},
152+
{
153+
name: "large + fresh → no warn",
154+
usedTokens: 50_000,
155+
idleMs: 60 * 1000,
156+
expected: false,
157+
},
158+
{
159+
name: "small + stale → no warn",
160+
usedTokens: 10_000,
161+
idleMs: 10 * 60 * 1000,
162+
expected: false,
163+
},
164+
{
165+
name: "small + fresh → no warn",
166+
usedTokens: 10_000,
167+
idleMs: 60 * 1000,
168+
expected: false,
169+
},
170+
{
171+
name: "exactly at both thresholds → warn",
172+
usedTokens: 40_000,
173+
idleMs: 5 * 60 * 1000,
174+
expected: true,
175+
},
176+
{
177+
name: "one token below the size threshold → no warn",
178+
usedTokens: 39_999,
179+
idleMs: 10 * 60 * 1000,
180+
expected: false,
181+
},
182+
{
183+
name: "one ms below the stale threshold → no warn",
184+
usedTokens: 50_000,
185+
idleMs: 5 * 60 * 1000 - 1,
186+
expected: false,
187+
},
188+
])("$name", ({ usedTokens, idleMs, expected }) => {
189+
expect(
190+
shouldWarnStaleCostlyConversation({
191+
usedTokens,
192+
lastActivityAt: now - idleMs,
193+
now,
194+
threshold,
195+
}),
196+
).toBe(expected);
197+
});
198+
199+
it("never warns without a last-activity timestamp", () => {
200+
expect(
201+
shouldWarnStaleCostlyConversation({
202+
usedTokens: 1_000_000,
203+
lastActivityAt: null,
204+
now,
205+
threshold,
206+
}),
207+
).toBe(false);
208+
});
209+
210+
it("treats a future timestamp (clock skew) as fresh", () => {
211+
expect(
212+
shouldWarnStaleCostlyConversation({
213+
usedTokens: 50_000,
214+
lastActivityAt: now + 60_000,
215+
now,
216+
threshold,
217+
}),
218+
).toBe(false);
219+
});
220+
221+
it("falls back to DEFAULT_STALE_COSTLY_THRESHOLD when none is given", () => {
222+
expect(
223+
shouldWarnStaleCostlyConversation({
224+
usedTokens: DEFAULT_STALE_COSTLY_THRESHOLD.tokens,
225+
lastActivityAt: now - DEFAULT_STALE_COSTLY_THRESHOLD.staleMs,
226+
now,
227+
}),
228+
).toBe(true);
229+
});
230+
});

packages/core/src/sessions/contextUsage.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,54 @@ function extractBreakdown(msg: AcpMessage["message"]): ContextBreakdown | null {
111111
const params = msg.params as { breakdown?: ContextBreakdown } | undefined;
112112
return params?.breakdown ?? null;
113113
}
114+
115+
/**
116+
* Threshold controlling when {@link shouldWarnStaleCostlyConversation} fires.
117+
*/
118+
export interface StaleCostlyThreshold {
119+
/** Minimum context tokens for a conversation to count as "large". */
120+
tokens: number;
121+
/**
122+
* Minimum idle time (ms) before a conversation counts as "stale". Set to
123+
* roughly the Anthropic prompt-cache TTL: once the cache expires, the next
124+
* turn re-processes the whole prefix at full input price instead of the
125+
* ~10% cached-read rate.
126+
*/
127+
staleMs: number;
128+
}
129+
130+
/**
131+
* Defaults for the stale-costly conversation warning: a conversation large
132+
* enough that a cold cache rebuild is noticeable, left idle past the default
133+
* 5-minute prompt-cache TTL.
134+
*/
135+
export const DEFAULT_STALE_COSTLY_THRESHOLD: StaleCostlyThreshold = {
136+
tokens: 40_000,
137+
staleMs: 5 * 60 * 1000,
138+
};
139+
140+
/**
141+
* Decide whether to warn that continuing a conversation will be costly.
142+
*
143+
* Flags a conversation that is both large (>= `threshold.tokens` of context)
144+
* and stale (idle >= `threshold.staleMs`). Staleness approximates whether the
145+
* Anthropic prompt cache has expired: continuing a stale, large conversation
146+
* re-sends the whole prefix at full input price rather than the cached-read
147+
* rate, so starting fresh is often cheaper.
148+
*
149+
* Pure and time-injected (no `Date.now()`) so it stays host-agnostic and
150+
* testable. A `null` `lastActivityAt` (no activity yet) never warns, and a
151+
* future timestamp (clock skew) reads as fresh, not stale.
152+
*/
153+
export function shouldWarnStaleCostlyConversation(args: {
154+
usedTokens: number;
155+
lastActivityAt: number | null;
156+
now: number;
157+
threshold?: StaleCostlyThreshold;
158+
}): boolean {
159+
const { usedTokens, lastActivityAt, now } = args;
160+
const threshold = args.threshold ?? DEFAULT_STALE_COSTLY_THRESHOLD;
161+
if (lastActivityAt === null) return false;
162+
if (usedTokens < threshold.tokens) return false;
163+
return now - lastActivityAt >= threshold.staleMs;
164+
}

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

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Pause, Spinner, Warning } from "@phosphor-icons/react";
2+
import { shouldWarnStaleCostlyConversation } from "@posthog/core/sessions/contextUsage";
23
import {
34
createLatestPlanTracker,
45
SESSION_SERVICE,
@@ -7,6 +8,7 @@ import {
78
import { useService } from "@posthog/di/react";
89
import type { AcpMessage } from "@posthog/shared";
910
import type { Task, TaskRunStatus } from "@posthog/shared/domain-types";
11+
import { useMeQuery } from "@posthog/ui/features/auth/useMeQuery";
1012
import { showOfflineToast } from "@posthog/ui/features/connectivity/connectivityToast";
1113
import {
1214
PromptInput,
@@ -29,9 +31,11 @@ import { QueuedMessagesDock } from "@posthog/ui/features/sessions/components/Que
2931
import { ReasoningLevelSelector } from "@posthog/ui/features/sessions/components/ReasoningLevelSelector";
3032
import { RawLogsView } from "@posthog/ui/features/sessions/components/raw-logs/RawLogsView";
3133
import { SessionResourcesBar } from "@posthog/ui/features/sessions/components/SessionResourcesBar";
34+
import { StaleConversationCostDialog } from "@posthog/ui/features/sessions/components/StaleConversationCostDialog";
3235
import { SteerQueueToggle } from "@posthog/ui/features/sessions/components/SteerQueueToggle";
3336
import { ThreadView } from "@posthog/ui/features/sessions/components/ThreadView";
3437
import { CHAT_CONTENT_MAX_WIDTH } from "@posthog/ui/features/sessions/constants";
38+
import { useContextUsage } from "@posthog/ui/features/sessions/hooks/useContextUsage";
3539
import { useSessionEventsResidency } from "@posthog/ui/features/sessions/hooks/useSessionEventsResidency";
3640
import { useToggleMessagingMode } from "@posthog/ui/features/sessions/hooks/useToggleMessagingMode";
3741
import {
@@ -44,6 +48,7 @@ import {
4448
useSessionViewActions,
4549
useShowRawLogs,
4650
} from "@posthog/ui/features/sessions/sessionViewStore";
51+
import { useStaleConversationGateStore } from "@posthog/ui/features/sessions/staleConversationGateStore";
4752
import type { Plan } from "@posthog/ui/features/sessions/types";
4853
import { useSessionHandoffInProgress } from "@posthog/ui/features/sessions/useSession";
4954
import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore";
@@ -265,6 +270,41 @@ export function SessionView({
265270
[isOnline, onBeforeSubmit],
266271
);
267272

273+
// Warn PostHog employees before continuing a large conversation that has gone
274+
// idle long enough that its Anthropic prompt cache has likely expired — the
275+
// next turn would re-process the whole prefix at full price rather than the
276+
// ~10% cached rate. Gated to staff via `is_staff`; dismissible per session.
277+
const contextUsage = useContextUsage(events);
278+
const { data: currentUser } = useMeQuery();
279+
const isStaff = currentUser?.is_staff === true;
280+
const staleConversationAcknowledged = useStaleConversationGateStore((s) =>
281+
s.acknowledgedSessions.has(sessionId),
282+
);
283+
const acknowledgeStaleConversation = useStaleConversationGateStore(
284+
(s) => s.acknowledge,
285+
);
286+
const lastActivityAt =
287+
events.length > 0 ? events[events.length - 1].ts : null;
288+
const now = Date.now();
289+
const costGateActive =
290+
isStaff &&
291+
!staleConversationAcknowledged &&
292+
shouldWarnStaleCostlyConversation({
293+
usedTokens: contextUsage?.used ?? 0,
294+
lastActivityAt,
295+
now,
296+
});
297+
// Track which session the cost dialog was dismissed for, so switching to a
298+
// different gated session re-opens it — without a reset effect.
299+
const [costGateDismissedFor, setCostGateDismissedFor] = useState<
300+
string | null
301+
>(null);
302+
const costGateDialogOpen =
303+
costGateActive && costGateDismissedFor !== sessionId;
304+
const handleContinueStaleConversation = useCallback(() => {
305+
acknowledgeStaleConversation(sessionId);
306+
}, [acknowledgeStaleConversation, sessionId]);
307+
268308
const [isDraggingFile, setIsDraggingFile] = useState(false);
269309
const editorRef = useRef<PromptInputHandle>(null);
270310
const dragCounterRef = useRef(0);
@@ -617,16 +657,50 @@ export function SessionView({
617657
}
618658
>
619659
{taskId && <QueuedMessagesDock taskId={taskId} />}
660+
{costGateActive && !costGateDialogOpen && (
661+
<Flex justify="center" mb="2">
662+
<Button
663+
variant="soft"
664+
color="amber"
665+
size="1"
666+
onClick={() => setCostGateDismissedFor(null)}
667+
>
668+
<Warning size={14} weight="fill" />
669+
Conversation paused to avoid a costly reload —
670+
review
671+
</Button>
672+
</Flex>
673+
)}
674+
<StaleConversationCostDialog
675+
open={costGateDialogOpen}
676+
usedTokens={contextUsage?.used ?? 0}
677+
idleMs={
678+
lastActivityAt !== null
679+
? Math.max(0, now - lastActivityAt)
680+
: 0
681+
}
682+
costUsd={contextUsage?.cost?.amount ?? null}
683+
onContinue={handleContinueStaleConversation}
684+
onOpenChange={(open) =>
685+
setCostGateDismissedFor(open ? null : sessionId)
686+
}
687+
/>
620688
<PromptInput
621689
ref={editorRef}
622690
sessionId={sessionId}
623691
placeholder="Type a message... @ to mention files, ! for bash mode, / for skills"
624-
disabled={!isRunning && !handoffInProgress}
692+
disabled={
693+
(!isRunning && !handoffInProgress) || costGateActive
694+
}
625695
submitDisabledExternal={
626696
handoffInProgress || !isOnline
627697
}
628698
submitTooltipOverride={
629-
!isOnline ? "No internet connection" : undefined
699+
costGateActive
700+
? "Large idle conversation — review the cost notice to continue"
701+
: !isOnline
702+
? "No internet connection"
703+
: undefined
630704
}
631705
isLoading={!!isPromptPending}
632706
isActiveSession={isActiveSession}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { Warning } from "@phosphor-icons/react";
2+
import { AlertDialog, Button, Flex } from "@radix-ui/themes";
3+
4+
interface StaleConversationCostDialogProps {
5+
open: boolean;
6+
usedTokens: number;
7+
idleMs: number;
8+
/** Cumulative session cost so far, when the gateway reports it. */
9+
costUsd: number | null;
10+
onContinue: () => void;
11+
onOpenChange: (open: boolean) => void;
12+
}
13+
14+
function formatTokens(tokens: number): string {
15+
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
16+
if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`;
17+
return `${tokens}`;
18+
}
19+
20+
function formatIdle(ms: number): string {
21+
const minutes = Math.round(ms / 60_000);
22+
if (minutes < 60) return `${Math.max(1, minutes)} min`;
23+
const hours = Math.round(minutes / 60);
24+
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"}`;
25+
const days = Math.round(hours / 24);
26+
return `${days} day${days === 1 ? "" : "s"}`;
27+
}
28+
29+
export function StaleConversationCostDialog({
30+
open,
31+
usedTokens,
32+
idleMs,
33+
costUsd,
34+
onContinue,
35+
onOpenChange,
36+
}: StaleConversationCostDialogProps) {
37+
return (
38+
<AlertDialog.Root open={open} onOpenChange={onOpenChange}>
39+
<AlertDialog.Content maxWidth="460px" size="2">
40+
<AlertDialog.Title className="text-base">
41+
<Flex align="center" gap="2">
42+
<Warning size={18} weight="fill" color="var(--orange-9)" />
43+
Continue this large, idle conversation?
44+
</Flex>
45+
</AlertDialog.Title>
46+
<AlertDialog.Description className="text-sm">
47+
This conversation holds about {formatTokens(usedTokens)} tokens and
48+
has been idle for {formatIdle(idleMs)}. Its prompt cache has likely
49+
expired, so your next message re-processes the whole conversation at
50+
full input price instead of the ~10% cached rate
51+
{costUsd !== null ? ` (≈$${costUsd.toFixed(2)} spent so far)` : ""}.
52+
Starting a new conversation avoids the cost — continue only if you
53+
need this thread's context.
54+
</AlertDialog.Description>
55+
56+
<Flex justify="end" gap="2" mt="4">
57+
<AlertDialog.Cancel>
58+
<Button variant="soft" color="gray" size="1">
59+
Not now
60+
</Button>
61+
</AlertDialog.Cancel>
62+
<AlertDialog.Action>
63+
<Button variant="solid" size="1" onClick={onContinue}>
64+
Continue anyway
65+
</Button>
66+
</AlertDialog.Action>
67+
</Flex>
68+
</AlertDialog.Content>
69+
</AlertDialog.Root>
70+
);
71+
}

0 commit comments

Comments
 (0)