Skip to content

Commit 18d3136

Browse files
committed
Simplify stale-conversation gate: extract hook, reuse helpers, trim store
Cleanup pass (no behavior change): - Extract useStaleConversationGate(sessionId, events) so SessionView stays a composition root instead of inlining the orchestration, mirroring the existing useBranchMismatchDialog pattern. - Reuse formatTokensCompact (fixes a 40K/40k inconsistency with the context indicator) and formatRelativeTimeLong in the dialog; drop the two bespoke local formatters. - Move the events[-1].ts staleness read into a core extractLastActivityAt() helper (with the heuristic documented) + tests. - Trim the gate store to just acknowledge() — reset()/isAcknowledged() were only exercised by their own tests; production uses the .has() selector. - Collapse the reopen condition to a positive `dismissed` predicate and de-duplicate the prompt-cache rationale across JSDoc blocks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RkLQSsdj7o4wQjcmjM1SD1
1 parent 29f5b8b commit 18d3136

7 files changed

Lines changed: 158 additions & 120 deletions

File tree

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
createContextUsageTracker,
55
DEFAULT_STALE_COSTLY_THRESHOLD,
66
extractContextUsage,
7+
extractLastActivityAt,
78
shouldWarnStaleCostlyConversation,
89
} from "./contextUsage";
910

@@ -228,3 +229,17 @@ describe("shouldWarnStaleCostlyConversation", () => {
228229
).toBe(true);
229230
});
230231
});
232+
233+
describe("extractLastActivityAt", () => {
234+
it("returns null for an empty event list", () => {
235+
expect(extractLastActivityAt([])).toBeNull();
236+
});
237+
238+
it("returns the ts of the most recent event", () => {
239+
const events: AcpMessage[] = [
240+
{ ...agentChunkEvent(), ts: 10 },
241+
{ ...usageUpdateEvent(50_000, 200_000), ts: 20 },
242+
];
243+
expect(extractLastActivityAt(events)).toBe(20);
244+
});
245+
});

packages/core/src/sessions/contextUsage.ts

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,8 @@ export interface StaleCostlyThreshold {
119119
/** Minimum context tokens for a conversation to count as "large". */
120120
tokens: number;
121121
/**
122-
* Minimum idle time (ms) before a conversation counts as "stale" — set at or
123-
* above the prompt-cache TTL so that, once exceeded, the cache is expired and
124-
* the next turn re-processes the whole prefix at full input price instead of
125-
* the ~10% cached-read rate.
122+
* Minimum idle time (ms) before a conversation counts as "stale". See
123+
* {@link DEFAULT_STALE_COSTLY_THRESHOLD} for how this bound is chosen.
126124
*/
127125
staleMs: number;
128126
}
@@ -147,17 +145,13 @@ export const DEFAULT_STALE_COSTLY_THRESHOLD: StaleCostlyThreshold = {
147145
};
148146

149147
/**
150-
* Decide whether to warn that continuing a conversation will be costly.
148+
* Decide whether to warn that continuing a conversation will be costly: true
149+
* when it is both large (>= `threshold.tokens`) and stale (idle >=
150+
* `threshold.staleMs`). See {@link DEFAULT_STALE_COSTLY_THRESHOLD} for the
151+
* pricing rationale behind the defaults.
151152
*
152-
* Flags a conversation that is both large (>= `threshold.tokens` of context)
153-
* and stale (idle >= `threshold.staleMs`). Staleness approximates whether the
154-
* Anthropic prompt cache has expired: continuing a stale, large conversation
155-
* re-sends the whole prefix at full input price rather than the cached-read
156-
* rate, so starting fresh is often cheaper.
157-
*
158-
* Pure and time-injected (no `Date.now()`) so it stays host-agnostic and
159-
* testable. A `null` `lastActivityAt` (no activity yet) never warns, and a
160-
* future timestamp (clock skew) reads as fresh, not stale.
153+
* Pure and time-injected (no `Date.now()`). A `null` `lastActivityAt` never
154+
* warns, and a future timestamp (clock skew) reads as fresh.
161155
*/
162156
export function shouldWarnStaleCostlyConversation(args: {
163157
usedTokens: number;
@@ -171,3 +165,15 @@ export function shouldWarnStaleCostlyConversation(args: {
171165
if (usedTokens < threshold.tokens) return false;
172166
return now - lastActivityAt >= threshold.staleMs;
173167
}
168+
169+
/**
170+
* Best-effort "time of last activity" for a session: the emit timestamp of the
171+
* most recent event, or null for an empty list. Heuristic proxy for
172+
* prompt-cache freshness — `ts` is stamped on *any* AcpMessage (agent chunks,
173+
* tool calls, client-side events), not only turns sent to the model, so a
174+
* purely local event can reset it without the cache being refreshed. Good
175+
* enough for a soft cost warning; not a billing signal.
176+
*/
177+
export function extractLastActivityAt(events: AcpMessage[]): number | null {
178+
return events.length > 0 ? events[events.length - 1].ts : null;
179+
}

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

Lines changed: 15 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { Pause, Spinner, Warning } from "@phosphor-icons/react";
2-
import { shouldWarnStaleCostlyConversation } from "@posthog/core/sessions/contextUsage";
32
import {
43
createLatestPlanTracker,
54
SESSION_SERVICE,
@@ -8,7 +7,6 @@ import {
87
import { useService } from "@posthog/di/react";
98
import type { AcpMessage } from "@posthog/shared";
109
import type { Task, TaskRunStatus } from "@posthog/shared/domain-types";
11-
import { useMeQuery } from "@posthog/ui/features/auth/useMeQuery";
1210
import { showOfflineToast } from "@posthog/ui/features/connectivity/connectivityToast";
1311
import {
1412
PromptInput,
@@ -35,7 +33,6 @@ import { StaleConversationCostDialog } from "@posthog/ui/features/sessions/compo
3533
import { SteerQueueToggle } from "@posthog/ui/features/sessions/components/SteerQueueToggle";
3634
import { ThreadView } from "@posthog/ui/features/sessions/components/ThreadView";
3735
import { CHAT_CONTENT_MAX_WIDTH } from "@posthog/ui/features/sessions/constants";
38-
import { useContextUsage } from "@posthog/ui/features/sessions/hooks/useContextUsage";
3936
import { useSessionEventsResidency } from "@posthog/ui/features/sessions/hooks/useSessionEventsResidency";
4037
import { useToggleMessagingMode } from "@posthog/ui/features/sessions/hooks/useToggleMessagingMode";
4138
import {
@@ -48,9 +45,9 @@ import {
4845
useSessionViewActions,
4946
useShowRawLogs,
5047
} from "@posthog/ui/features/sessions/sessionViewStore";
51-
import { useStaleConversationGateStore } from "@posthog/ui/features/sessions/staleConversationGateStore";
5248
import type { Plan } from "@posthog/ui/features/sessions/types";
5349
import { useSessionHandoffInProgress } from "@posthog/ui/features/sessions/useSession";
50+
import { useStaleConversationGate } from "@posthog/ui/features/sessions/useStaleConversationGate";
5451
import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore";
5552
import { useIsWorkspaceCloudRun } from "@posthog/ui/features/workspace/useWorkspace";
5653
import { useConnectivity } from "@posthog/ui/hooks/useConnectivity";
@@ -270,40 +267,9 @@ export function SessionView({
270267
[isOnline, onBeforeSubmit],
271268
);
272269

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]);
270+
// Warn PostHog staff before continuing a large, idle conversation whose
271+
// prompt cache has likely expired (see useStaleConversationGate).
272+
const staleGate = useStaleConversationGate(sessionId, events);
307273

308274
const [isDraggingFile, setIsDraggingFile] = useState(false);
309275
const editorRef = useRef<PromptInputHandle>(null);
@@ -657,13 +623,13 @@ export function SessionView({
657623
}
658624
>
659625
{taskId && <QueuedMessagesDock taskId={taskId} />}
660-
{costGateActive && !costGateDialogOpen && (
626+
{staleGate.dismissed && (
661627
<Flex justify="center" mb="2">
662628
<Button
663629
variant="soft"
664630
color="amber"
665631
size="1"
666-
onClick={() => setCostGateDismissedFor(null)}
632+
onClick={staleGate.onReopen}
667633
>
668634
<Warning size={14} weight="fill" />
669635
Conversation paused to avoid a costly reload —
@@ -672,31 +638,26 @@ export function SessionView({
672638
</Flex>
673639
)}
674640
<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-
}
641+
open={staleGate.dialogOpen}
642+
usedTokens={staleGate.usedTokens}
643+
lastActivityAt={staleGate.lastActivityAt}
644+
costUsd={staleGate.costUsd}
645+
onContinue={staleGate.onContinue}
646+
onOpenChange={staleGate.onDialogOpenChange}
687647
/>
688648
<PromptInput
689649
ref={editorRef}
690650
sessionId={sessionId}
691651
placeholder="Type a message... @ to mention files, ! for bash mode, / for skills"
692652
disabled={
693-
(!isRunning && !handoffInProgress) || costGateActive
653+
(!isRunning && !handoffInProgress) ||
654+
staleGate.active
694655
}
695656
submitDisabledExternal={
696657
handoffInProgress || !isOnline
697658
}
698659
submitTooltipOverride={
699-
costGateActive
660+
staleGate.active
700661
? "Large idle conversation — review the cost notice to continue"
701662
: !isOnline
702663
? "No internet connection"

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

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,30 @@
11
import { Warning } from "@phosphor-icons/react";
2+
import { formatRelativeTimeLong } from "@posthog/shared";
3+
import { formatTokensCompact } from "@posthog/ui/features/sessions/contextColors";
24
import { AlertDialog, Button, Flex } from "@radix-ui/themes";
35

46
interface StaleConversationCostDialogProps {
57
open: boolean;
68
usedTokens: number;
7-
idleMs: number;
9+
lastActivityAt: number | null;
810
/** Cumulative session cost so far, when the gateway reports it. */
911
costUsd: number | null;
1012
onContinue: () => void;
1113
onOpenChange: (open: boolean) => void;
1214
}
1315

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-
2916
export function StaleConversationCostDialog({
3017
open,
3118
usedTokens,
32-
idleMs,
19+
lastActivityAt,
3320
costUsd,
3421
onContinue,
3522
onOpenChange,
3623
}: StaleConversationCostDialogProps) {
24+
const activity =
25+
lastActivityAt !== null
26+
? `was last active ${formatRelativeTimeLong(lastActivityAt)}`
27+
: "has been idle";
3728
return (
3829
<AlertDialog.Root open={open} onOpenChange={onOpenChange}>
3930
<AlertDialog.Content maxWidth="460px" size="2">
@@ -44,10 +35,10 @@ export function StaleConversationCostDialog({
4435
</Flex>
4536
</AlertDialog.Title>
4637
<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
38+
This conversation holds about {formatTokensCompact(usedTokens)} tokens
39+
and {activity}. Its prompt cache has likely expired, so your next
40+
message re-processes the whole conversation at full input price
41+
instead of the ~10% cached rate
5142
{costUsd !== null ? ` (≈$${costUsd.toFixed(2)} spent so far)` : ""}.
5243
Starting a new conversation avoids the cost — continue only if you
5344
need this thread's context.
Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,22 @@
11
import { beforeEach, describe, expect, it } from "vitest";
22
import { useStaleConversationGateStore } from "./staleConversationGateStore";
33

4+
const acknowledged = (id: string) =>
5+
useStaleConversationGateStore.getState().acknowledgedSessions.has(id);
6+
47
describe("useStaleConversationGateStore", () => {
58
beforeEach(() => {
69
useStaleConversationGateStore.setState({ acknowledgedSessions: new Set() });
710
});
811

912
it("starts with nothing acknowledged", () => {
10-
expect(useStaleConversationGateStore.getState().isAcknowledged("s1")).toBe(
11-
false,
12-
);
13+
expect(acknowledged("s1")).toBe(false);
1314
});
1415

1516
it("acknowledges a single session without affecting others", () => {
1617
useStaleConversationGateStore.getState().acknowledge("s1");
17-
const state = useStaleConversationGateStore.getState();
18-
expect(state.isAcknowledged("s1")).toBe(true);
19-
expect(state.isAcknowledged("s2")).toBe(false);
18+
expect(acknowledged("s1")).toBe(true);
19+
expect(acknowledged("s2")).toBe(false);
2020
});
2121

2222
it("replaces the Set immutably on acknowledge", () => {
@@ -36,13 +36,4 @@ describe("useStaleConversationGateStore", () => {
3636
useStaleConversationGateStore.getState().acknowledgedSessions;
3737
expect(second).toBe(first);
3838
});
39-
40-
it("reset clears a single session's acknowledgement", () => {
41-
const { acknowledge, reset } = useStaleConversationGateStore.getState();
42-
acknowledge("s1");
43-
reset("s1");
44-
expect(useStaleConversationGateStore.getState().isAcknowledged("s1")).toBe(
45-
false,
46-
);
47-
});
4839
});

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

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,19 @@ interface StaleConversationGateState {
77

88
interface StaleConversationGateActions {
99
acknowledge: (sessionId: string) => void;
10-
isAcknowledged: (sessionId: string) => boolean;
11-
reset: (sessionId: string) => void;
1210
}
1311

1412
export type StaleConversationGateStore = StaleConversationGateState &
1513
StaleConversationGateActions;
1614

1715
/**
18-
* Tracks which sessions have dismissed the stale-costly-conversation cost
19-
* warning. Ephemeral view state (not persisted): dismissing is per-session and
20-
* only needs to last for the current app run.
16+
* Tracks which sessions have accepted the stale-costly-conversation cost
17+
* warning. Ephemeral view state (not persisted): acknowledgement is per-session
18+
* and only needs to last for the current app run. Read via the reactive
19+
* `acknowledgedSessions.has(id)` selector.
2120
*/
2221
export const useStaleConversationGateStore =
23-
create<StaleConversationGateStore>()((set, get) => ({
22+
create<StaleConversationGateStore>()((set) => ({
2423
acknowledgedSessions: new Set(),
2524

2625
acknowledge: (sessionId) =>
@@ -30,14 +29,4 @@ export const useStaleConversationGateStore =
3029
next.add(sessionId);
3130
return { acknowledgedSessions: next };
3231
}),
33-
34-
isAcknowledged: (sessionId) => get().acknowledgedSessions.has(sessionId),
35-
36-
reset: (sessionId) =>
37-
set((state) => {
38-
if (!state.acknowledgedSessions.has(sessionId)) return state;
39-
const next = new Set(state.acknowledgedSessions);
40-
next.delete(sessionId);
41-
return { acknowledgedSessions: next };
42-
}),
4332
}));

0 commit comments

Comments
 (0)