Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 1 addition & 120 deletions packages/core/src/sessions/contextUsage.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
import type { AcpMessage } from "@posthog/shared";
import { describe, expect, it } from "vitest";
import {
createContextUsageTracker,
DEFAULT_STALE_COSTLY_THRESHOLD,
extractContextUsage,
extractLastActivityAt,
shouldWarnStaleCostlyConversation,
} from "./contextUsage";
import { createContextUsageTracker, extractContextUsage } from "./contextUsage";

function usageUpdateEvent(used: number, size: number): AcpMessage {
return {
Expand Down Expand Up @@ -196,116 +190,3 @@ describe("createContextUsageTracker", () => {
expect(tracker.update(events)).toEqual(extractContextUsage(events));
});
});

describe("shouldWarnStaleCostlyConversation", () => {
const now = 1_000_000_000;
const threshold = { tokens: 40_000, staleMs: 5 * 60 * 1000 };

it.each([
{
name: "large + stale → warn",
usedTokens: 50_000,
idleMs: 10 * 60 * 1000,
expected: true,
},
{
name: "large + fresh → no warn",
usedTokens: 50_000,
idleMs: 60 * 1000,
expected: false,
},
{
name: "small + stale → no warn",
usedTokens: 10_000,
idleMs: 10 * 60 * 1000,
expected: false,
},
{
name: "small + fresh → no warn",
usedTokens: 10_000,
idleMs: 60 * 1000,
expected: false,
},
{
name: "exactly at both thresholds → warn",
usedTokens: 40_000,
idleMs: 5 * 60 * 1000,
expected: true,
},
{
name: "one token below the size threshold → no warn",
usedTokens: 39_999,
idleMs: 10 * 60 * 1000,
expected: false,
},
{
name: "one ms below the stale threshold → no warn",
usedTokens: 50_000,
idleMs: 5 * 60 * 1000 - 1,
expected: false,
},
])("$name", ({ usedTokens, idleMs, expected }) => {
expect(
shouldWarnStaleCostlyConversation({
usedTokens,
lastActivityAt: now - idleMs,
now,
threshold,
}),
).toBe(expected);
});

it("never warns without a last-activity timestamp", () => {
expect(
shouldWarnStaleCostlyConversation({
usedTokens: 1_000_000,
lastActivityAt: null,
now,
threshold,
}),
).toBe(false);
});

it("treats a future timestamp (clock skew) as fresh", () => {
expect(
shouldWarnStaleCostlyConversation({
usedTokens: 50_000,
lastActivityAt: now + 60_000,
now,
threshold,
}),
).toBe(false);
});

it("falls back to DEFAULT_STALE_COSTLY_THRESHOLD when none is given", () => {
expect(
shouldWarnStaleCostlyConversation({
usedTokens: DEFAULT_STALE_COSTLY_THRESHOLD.tokens,
lastActivityAt: now - DEFAULT_STALE_COSTLY_THRESHOLD.staleMs,
now,
}),
).toBe(true);
});
});

describe("extractLastActivityAt", () => {
it("returns null for an empty event list", () => {
expect(extractLastActivityAt([])).toBeNull();
});

it("returns the ts of the most recent event", () => {
const events: AcpMessage[] = [
{ ...agentChunkEvent(), ts: 10 },
{ ...usageUpdateEvent(50_000, 200_000), ts: 20 },
];
expect(extractLastActivityAt(events)).toBe(20);
});

it("returns the maximum ts even when events are out of order", () => {
const events: AcpMessage[] = [
{ ...usageUpdateEvent(50_000, 200_000), ts: 30 },
{ ...agentChunkEvent(), ts: 10 },
];
expect(extractLastActivityAt(events)).toBe(30);
});
});
73 changes: 0 additions & 73 deletions packages/core/src/sessions/contextUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,76 +138,3 @@ function extractBreakdown(msg: AcpMessage["message"]): ContextBreakdown | null {
const params = msg.params as { breakdown?: ContextBreakdown } | undefined;
return params?.breakdown ?? null;
}

/**
* Threshold controlling when {@link shouldWarnStaleCostlyConversation} fires.
*/
export interface StaleCostlyThreshold {
/** Minimum context tokens for a conversation to count as "large". */
tokens: number;
/**
* Minimum idle time (ms) before a conversation counts as "stale". See
* {@link DEFAULT_STALE_COSTLY_THRESHOLD} for how this bound is chosen.
*/
staleMs: number;
}

/**
* Defaults for the stale-costly conversation warning.
*
* `tokens` (100k): only conversations big enough that a cold prefix rebuild is
* a real cost — roughly 10% of the 1M context window — trip the warning.
*
* `staleMs` (60 min): Anthropic ephemeral caches default to a 5-minute TTL and
* can opt into a 1-hour one; the effective value depends on what the Agent SDK
* requests, which we don't control or observe. We deliberately use the longer
* 1-hour bound so the cache is almost certainly cold before we warn — warning
* while it is still warm would nag about a continuation that is in fact cheap,
* the worse failure. Erring long only means we occasionally skip a warning,
* never that we nag needlessly.
*/
export const DEFAULT_STALE_COSTLY_THRESHOLD: StaleCostlyThreshold = {
tokens: 100_000,
staleMs: 60 * 60 * 1000,
};

/**
* Decide whether to warn that continuing a conversation will be costly: true
* when it is both large (>= `threshold.tokens`) and stale (idle >=
* `threshold.staleMs`). See {@link DEFAULT_STALE_COSTLY_THRESHOLD} for the
* pricing rationale behind the defaults.
*
* Pure and time-injected (no `Date.now()`). A `null` `lastActivityAt` never
* warns, and a future timestamp (clock skew) reads as fresh.
*/
export function shouldWarnStaleCostlyConversation(args: {
usedTokens: number;
lastActivityAt: number | null;
now: number;
threshold?: StaleCostlyThreshold;
}): boolean {
const { usedTokens, lastActivityAt, now } = args;
const threshold = args.threshold ?? DEFAULT_STALE_COSTLY_THRESHOLD;
if (lastActivityAt === null) return false;
if (usedTokens < threshold.tokens) return false;
return now - lastActivityAt >= threshold.staleMs;
}

/**
* Best-effort "time of last activity" for a session: the most recent (maximum)
* `ts` among events, or null for an empty list. Scans for the max rather than
* trusting positional order, so an out-of-order append can't report a staler
* time than reality. (Uses a loop, not the spread form of `Math.max`, which can
* overflow the stack on long event lists.) Heuristic proxy for prompt-cache
* freshness — `ts` is stamped on *any* AcpMessage (agent chunks, tool calls,
* client-side events), not only turns sent to the model. Good enough for a soft
* cost warning; not a billing signal.
*/
export function extractLastActivityAt(events: AcpMessage[]): number | null {
if (events.length === 0) return null;
let latest = events[0].ts;
for (let i = 1; i < events.length; i++) {
if (events[i].ts > latest) latest = events[i].ts;
}
return latest;
}
8 changes: 0 additions & 8 deletions packages/shared/src/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,12 +135,6 @@ export interface PromptSentProperties {
prompt_length_chars: number;
}

export interface StaleConversationGateChoiceProperties {
choice: "compact" | "continue" | "new_session";
used_tokens: number;
cost_usd: number | null;
}

// Git operations
export interface GitActionExecutedProperties {
action_type: GitActionType;
Expand Down Expand Up @@ -1012,7 +1006,6 @@ export const ANALYTICS_EVENTS = {
TASK_RUN_COMPLETED: "Task run completed",
TASK_RUN_CANCELLED: "Task run cancelled",
PROMPT_SENT: "Prompt sent",
STALE_CONVERSATION_GATE_CHOICE: "Stale conversation gate choice",

// Claude Code session import
CLAUDE_SESSIONS_SHOWN: "Claude Code sessions shown",
Expand Down Expand Up @@ -1167,7 +1160,6 @@ export type EventPropertyMap = {
[ANALYTICS_EVENTS.TASK_RUN_COMPLETED]: TaskRunCompletedProperties;
[ANALYTICS_EVENTS.TASK_RUN_CANCELLED]: TaskRunCancelledProperties;
[ANALYTICS_EVENTS.PROMPT_SENT]: PromptSentProperties;
[ANALYTICS_EVENTS.STALE_CONVERSATION_GATE_CHOICE]: StaleConversationGateChoiceProperties;

// Claude Code session import
[ANALYTICS_EVENTS.CLAUDE_SESSIONS_SHOWN]: ClaudeSessionsShownProperties;
Expand Down
79 changes: 2 additions & 77 deletions packages/ui/src/features/sessions/components/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,7 @@ import {
type SessionService,
} from "@posthog/core/sessions/sessionService";
import { useService } from "@posthog/di/react";
import {
type AcpMessage,
ANALYTICS_EVENTS,
type StaleConversationGateChoiceProperties,
} from "@posthog/shared";
import type { AcpMessage } from "@posthog/shared";
import type { Task, TaskRunStatus } from "@posthog/shared/domain-types";
import { showOfflineToast } from "@posthog/ui/features/connectivity/connectivityToast";
import {
Expand All @@ -33,7 +29,6 @@ import { QueuedMessagesDock } from "@posthog/ui/features/sessions/components/Que
import { ReasoningLevelSelector } from "@posthog/ui/features/sessions/components/ReasoningLevelSelector";
import { RawLogsView } from "@posthog/ui/features/sessions/components/raw-logs/RawLogsView";
import { SessionResourcesBar } from "@posthog/ui/features/sessions/components/SessionResourcesBar";
import { StaleConversationCostNotice } from "@posthog/ui/features/sessions/components/StaleConversationCostNotice";
import { SteerQueueToggle } from "@posthog/ui/features/sessions/components/SteerQueueToggle";
import { ThreadView } from "@posthog/ui/features/sessions/components/ThreadView";
import { CHAT_CONTENT_MAX_WIDTH } from "@posthog/ui/features/sessions/constants";
Expand All @@ -51,12 +46,10 @@ import {
} from "@posthog/ui/features/sessions/sessionViewStore";
import type { Plan } from "@posthog/ui/features/sessions/types";
import { useSessionHandoffInProgress } from "@posthog/ui/features/sessions/useSession";
import { useStaleConversationGate } from "@posthog/ui/features/sessions/useStaleConversationGate";
import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore";
import { useIsWorkspaceCloudRun } from "@posthog/ui/features/workspace/useWorkspace";
import { useConnectivity } from "@posthog/ui/hooks/useConnectivity";
import { toast } from "@posthog/ui/primitives/toast";
import { track } from "@posthog/ui/shell/analytics";
import {
pendingTaskPromptStoreApi,
usePendingTaskPrompt,
Expand Down Expand Up @@ -328,43 +321,6 @@ export function SessionView({
[isOnline, onBeforeSubmit],
);

// Warn PostHog staff before continuing a large, idle conversation whose
// prompt cache has likely expired (see useStaleConversationGate).
const staleGate = useStaleConversationGate(sessionId, events);

// Plain functions, not useCallbacks: the notice isn't memoized, and the
// values they close over (usage, cost) change with every streamed event.
const trackStaleGateChoice = (
choice: StaleConversationGateChoiceProperties["choice"],
) =>
track(ANALYTICS_EVENTS.STALE_CONVERSATION_GATE_CHOICE, {
choice,
used_tokens: staleGate.usedTokens,
cost_usd: staleGate.costUsd,
});

const handleStaleCompact = () => {
if (!isOnline) {
showOfflineToast();
return;
}
trackStaleGateChoice("compact");
staleGate.onContinue();
onSendPrompt("/compact");
};

const handleStaleContinue = () => {
trackStaleGateChoice("continue");
staleGate.onContinue();
};

const handleStaleNewSession = onNewSession
? () => {
trackStaleGateChoice("new_session");
onNewSession();
}
: undefined;

const [isDraggingFile, setIsDraggingFile] = useState(false);
const editorRef = useRef<PromptInputHandle>(null);
const dragCounterRef = useRef(0);
Expand Down Expand Up @@ -666,38 +622,7 @@ export function SessionView({
)}
</Flex>
</Flex>
) : hideInput ? null : staleGate.active ? (
// Replaces the composer (and any pending permission — answering
// one also resumes the costly turn) until the user chooses.
isRunning ? (
<ComposerSlot compact={compact}>
<StaleConversationCostNotice
usedTokens={staleGate.usedTokens}
lastActivityAt={staleGate.lastActivityAt}
costUsd={staleGate.costUsd}
onContinue={handleStaleContinue}
onCompact={
firstPendingPermission
? undefined
: handleStaleCompact
}
onNewSession={handleStaleNewSession}
/>
</ComposerSlot>
) : (
// While reconnecting the gate still covers the composer
// slot: handoff can leave pendingPermissions set, and the
// choices must not fire into a half-connected session.
<Flex
align="center"
justify="center"
gap="2"
className="min-h-[66px]"
>
<ConnectingToAgent />
</Flex>
)
) : firstPendingPermission ? (
) : hideInput ? null : firstPendingPermission ? (
<ComposerSlot compact={compact}>
<PermissionSelector
toolCall={firstPendingPermission.toolCall}
Expand Down
Loading
Loading