diff --git a/packages/core/src/sessions/contextUsage.test.ts b/packages/core/src/sessions/contextUsage.test.ts index b0a3d5cca0..b753f257a0 100644 --- a/packages/core/src/sessions/contextUsage.test.ts +++ b/packages/core/src/sessions/contextUsage.test.ts @@ -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 { @@ -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); - }); -}); diff --git a/packages/core/src/sessions/contextUsage.ts b/packages/core/src/sessions/contextUsage.ts index 1753577c2b..6d37bc156c 100644 --- a/packages/core/src/sessions/contextUsage.ts +++ b/packages/core/src/sessions/contextUsage.ts @@ -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; -} diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 62850de083..18ac01c6f8 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -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; @@ -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", @@ -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; diff --git a/packages/ui/src/features/sessions/components/SessionView.tsx b/packages/ui/src/features/sessions/components/SessionView.tsx index 387b6e5804..6ca5b8b7e6 100644 --- a/packages/ui/src/features/sessions/components/SessionView.tsx +++ b/packages/ui/src/features/sessions/components/SessionView.tsx @@ -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 { @@ -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"; @@ -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, @@ -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(null); const dragCounterRef = useRef(0); @@ -666,38 +622,7 @@ export function SessionView({ )} - ) : hideInput ? null : staleGate.active ? ( - // Replaces the composer (and any pending permission — answering - // one also resumes the costly turn) until the user chooses. - isRunning ? ( - - - - ) : ( - // 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. - - - - ) - ) : firstPendingPermission ? ( + ) : hideInput ? null : firstPendingPermission ? ( = { - title: "Sessions/StaleConversationCostNotice", - component: StaleConversationCostNotice, - decorators: [ - (Story) => ( - - - - - - - ), - ], -}; - -export default meta; -type Story = StoryObj; - -/** - * Stand-in chat content, capped at the same width as real thread content - * (ConversationView) so the notice reads at its true relative width. - */ -function FakeConversation() { - return ( - - - {[ - "Investigate detached element memory leaks", - "Ran 3 commands, 1 subagent", - "Review posted. Now the sticky summary comment:", - "Pushed and in sync. Now the simplify pass on this HEAD:", - "1 subagent - simplify pass on 68452", - ].map((line) => ( - - - {line} - - - ))} - - - ); -} - -const TWO_HOURS_AGO = Date.now() - 2 * 60 * 60 * 1000; - -export const Default: Story = { - args: { - usedTokens: 481_000, - lastActivityAt: TWO_HOURS_AGO, - costUsd: 12.34, - onContinue: () => {}, - onCompact: () => {}, - onNewSession: () => {}, - }, -}; - -export const WithoutNewSessionOrCost: Story = { - args: { - usedTokens: 128_000, - lastActivityAt: TWO_HOURS_AGO, - costUsd: null, - onContinue: () => {}, - onCompact: () => {}, - }, -}; - -/** Compact is hidden while a permission is pending — two-option layout. */ -export const PermissionPending: Story = { - args: { - usedTokens: 481_000, - lastActivityAt: TWO_HOURS_AGO, - costUsd: 12.34, - onContinue: () => {}, - onNewSession: () => {}, - }, -}; diff --git a/packages/ui/src/features/sessions/components/StaleConversationCostNotice.tsx b/packages/ui/src/features/sessions/components/StaleConversationCostNotice.tsx deleted file mode 100644 index f22ac6f087..0000000000 --- a/packages/ui/src/features/sessions/components/StaleConversationCostNotice.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { formatUsd } from "@posthog/core/billing/spendAnalysisFormat"; -import { formatRelativeTimeLong } from "@posthog/shared"; -import { formatTokensCompact } from "@posthog/ui/features/sessions/contextColors"; -import { ActionSelector } from "@posthog/ui/primitives/ActionSelector"; - -const COMPACT_OPTION = "compact"; -const CONTINUE_OPTION = "continue"; -const NEW_SESSION_OPTION = "new-session"; - -interface StaleConversationCostNoticeProps { - usedTokens: number; - lastActivityAt: number | null; - /** Cumulative session cost so far, when the gateway reports it. */ - costUsd: number | null; - onContinue: () => void; - /** - * Compact the thread: pay the reload once, then every later turn is - * smaller. Omitted while a permission is pending — a queued /compact would - * land after answering it, paying the reload twice. - */ - onCompact?: () => void; - onNewSession?: () => void; -} - -/** - * Composer state shown in place of the prompt input when PostHog staff return - * to a large, idle conversation whose prompt cache has likely expired. Uses - * the same ActionSelector as permission prompts, so the user must choose how - * to continue before they can type again. - */ -export function StaleConversationCostNotice({ - usedTokens, - lastActivityAt, - costUsd, - onContinue, - onCompact, - onNewSession, -}: StaleConversationCostNoticeProps) { - const activity = - lastActivityAt !== null - ? `was last active ${formatRelativeTimeLong(lastActivityAt)}` - : "has been idle"; - const spent = - costUsd !== null ? ` (≈${formatUsd(costUsd)} spent so far)` : ""; - return ( - { - if (optionId === COMPACT_OPTION) onCompact?.(); - else if (optionId === CONTINUE_OPTION) onContinue(); - else if (optionId === NEW_SESSION_OPTION) onNewSession?.(); - }} - /> - ); -} diff --git a/packages/ui/src/features/sessions/staleConversationGateStore.test.ts b/packages/ui/src/features/sessions/staleConversationGateStore.test.ts deleted file mode 100644 index 09db3dff3b..0000000000 --- a/packages/ui/src/features/sessions/staleConversationGateStore.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { useStaleConversationGateStore } from "./staleConversationGateStore"; - -const state = () => useStaleConversationGateStore.getState(); -const acknowledged = (id: string) => state().acknowledgedSessions.has(id); -const engaged = (id: string) => state().engagedSessions.has(id); - -describe("useStaleConversationGateStore", () => { - beforeEach(() => { - useStaleConversationGateStore.setState({ - engagedSessions: new Map(), - acknowledgedSessions: new Set(), - }); - }); - - it("starts with nothing engaged or acknowledged", () => { - expect(engaged("s1")).toBe(false); - expect(acknowledged("s1")).toBe(false); - }); - - it("engages a single session with its last-activity snapshot", () => { - state().engage("s1", 1000); - expect(state().engagedSessions.get("s1")).toBe(1000); - expect(engaged("s2")).toBe(false); - }); - - it("keeps the first engagement snapshot when engaged again", () => { - state().engage("s1", 1000); - state().engage("s1", 2000); - expect(state().engagedSessions.get("s1")).toBe(1000); - }); - - it("does not re-engage an acknowledged session", () => { - state().engage("s1", 1000); - state().acknowledge("s1"); - state().engage("s1", 2000); - expect(engaged("s1")).toBe(false); - }); - - it("acknowledges a single session and releases its engagement", () => { - state().engage("s1", 1000); - state().engage("s2", 1000); - state().acknowledge("s1"); - expect(acknowledged("s1")).toBe(true); - expect(engaged("s1")).toBe(false); - expect(acknowledged("s2")).toBe(false); - expect(engaged("s2")).toBe(true); - }); - - it("replaces the Set immutably on acknowledge", () => { - const before = state().acknowledgedSessions; - state().acknowledge("s1"); - const after = state().acknowledgedSessions; - expect(after).not.toBe(before); - expect(before.has("s1")).toBe(false); - }); - - it("is idempotent — acknowledging twice keeps the same reference", () => { - state().acknowledge("s1"); - const first = state().acknowledgedSessions; - state().acknowledge("s1"); - const second = state().acknowledgedSessions; - expect(second).toBe(first); - }); - - it("acknowledging a never-engaged session keeps the engaged Map reference", () => { - state().engage("s2", 1000); - const before = state().engagedSessions; - state().acknowledge("s1"); - expect(state().engagedSessions).toBe(before); - }); -}); diff --git a/packages/ui/src/features/sessions/staleConversationGateStore.ts b/packages/ui/src/features/sessions/staleConversationGateStore.ts deleted file mode 100644 index 9035adf6f4..0000000000 --- a/packages/ui/src/features/sessions/staleConversationGateStore.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { create } from "zustand"; - -interface StaleConversationGateState { - /** - * Sessions where the gate engaged, keyed to the last-activity time observed - * at that moment (null when no activity was observed). Latched because - * reconnecting to a stale session immediately appends freshly-stamped - * events (usage updates, handshakes) that would otherwise make the - * conversation look active again and dismiss the warning before the user - * has chosen. - */ - engagedSessions: Map; - /** Sessions where the user accepted the "large + idle = costly" warning. */ - acknowledgedSessions: Set; -} - -interface StaleConversationGateActions { - engage: (sessionId: string, lastActivityAt: number | null) => void; - acknowledge: (sessionId: string) => void; -} - -export type StaleConversationGateStore = StaleConversationGateState & - StaleConversationGateActions; - -/** - * Tracks which sessions have engaged and which have accepted the - * stale-costly-conversation cost warning. Ephemeral view state (not - * persisted): both only need to last for the current app run. - */ -export const useStaleConversationGateStore = - create()((set) => ({ - engagedSessions: new Map(), - acknowledgedSessions: new Set(), - - engage: (sessionId, lastActivityAt) => - set((state) => { - if ( - state.engagedSessions.has(sessionId) || - state.acknowledgedSessions.has(sessionId) - ) { - return state; - } - const next = new Map(state.engagedSessions); - next.set(sessionId, lastActivityAt); - return { engagedSessions: next }; - }), - - acknowledge: (sessionId) => - set((state) => { - if (state.acknowledgedSessions.has(sessionId)) return state; - const nextAcknowledged = new Set(state.acknowledgedSessions); - nextAcknowledged.add(sessionId); - if (!state.engagedSessions.has(sessionId)) { - return { acknowledgedSessions: nextAcknowledged }; - } - const nextEngaged = new Map(state.engagedSessions); - nextEngaged.delete(sessionId); - return { - acknowledgedSessions: nextAcknowledged, - engagedSessions: nextEngaged, - }; - }), - })); diff --git a/packages/ui/src/features/sessions/useStaleConversationGate.ts b/packages/ui/src/features/sessions/useStaleConversationGate.ts deleted file mode 100644 index e71e0c9d24..0000000000 --- a/packages/ui/src/features/sessions/useStaleConversationGate.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { - extractLastActivityAt, - shouldWarnStaleCostlyConversation, -} from "@posthog/core/sessions/contextUsage"; -import type { AcpMessage } from "@posthog/shared"; -import { useMeQuery } from "@posthog/ui/features/auth/useMeQuery"; -import { useContextUsage } from "@posthog/ui/features/sessions/hooks/useContextUsage"; -import { useStaleConversationGateStore } from "@posthog/ui/features/sessions/staleConversationGateStore"; -import { useCallback, useEffect } from "react"; - -export interface StaleConversationGate { - /** Gate engaged — block the conversation until the user chooses. */ - active: boolean; - usedTokens: number; - /** Last activity as observed when the gate engaged, not the live value. */ - lastActivityAt: number | null; - costUsd: number | null; - /** "Continue anyway" — permanently acknowledge the warning for this session. */ - onContinue: () => void; -} - -/** - * Gates continuation of a large, idle conversation for PostHog staff, whose - * Anthropic prompt cache has likely expired (see - * {@link shouldWarnStaleCostlyConversation}). - * - * The gate latches: opening a stale session reconnects the agent, which - * immediately emits freshly-stamped events (usage updates, handshakes), so - * the raw staleness check flips back off before the user has seen the - * warning. Once engaged, only acknowledging releases it. - */ -export function useStaleConversationGate( - sessionId: string, - events: AcpMessage[], -): StaleConversationGate { - const contextUsage = useContextUsage(events); - const { data: currentUser } = useMeQuery(); - const isStaff = currentUser?.is_staff === true; - const engaged = useStaleConversationGateStore((s) => - s.engagedSessions.has(sessionId), - ); - const engagedLastActivityAt = useStaleConversationGateStore((s) => - s.engagedSessions.get(sessionId), - ); - const acknowledged = useStaleConversationGateStore((s) => - s.acknowledgedSessions.has(sessionId), - ); - const engage = useStaleConversationGateStore((s) => s.engage); - const acknowledge = useStaleConversationGateStore((s) => s.acknowledge); - - const usedTokens = contextUsage?.used ?? 0; - const liveLastActivityAt = extractLastActivityAt(events); - const shouldEngage = - isStaff && - !acknowledged && - shouldWarnStaleCostlyConversation({ - usedTokens, - lastActivityAt: liveLastActivityAt, - now: Date.now(), - }); - - useEffect(() => { - if (shouldEngage) engage(sessionId, liveLastActivityAt); - }, [shouldEngage, engage, sessionId, liveLastActivityAt]); - - const onContinue = useCallback( - () => acknowledge(sessionId), - [acknowledge, sessionId], - ); - - return { - // shouldEngage covers the first paint before the effect latches; - // engaged covers every render after (reconnect events flip - // shouldEngage back off — see the hook doc). - active: shouldEngage || engaged, - usedTokens, - lastActivityAt: engaged - ? (engagedLastActivityAt ?? null) - : liveLastActivityAt, - costUsd: contextUsage?.cost?.amount ?? null, - onContinue, - }; -}