diff --git a/packages/core/src/telemetry/activity-detector.test.ts b/packages/core/src/telemetry/activity-detector.test.ts new file mode 100644 index 0000000000..dd5d56e5a9 --- /dev/null +++ b/packages/core/src/telemetry/activity-detector.test.ts @@ -0,0 +1,164 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + ActivityDetector, + getActivityDetector, + recordUserActivity, + isUserActive, +} from './activity-detector.js'; + +describe('ActivityDetector', () => { + let detector: ActivityDetector; + + beforeEach(() => { + detector = new ActivityDetector(1000); // 1 second idle threshold for testing + }); + + describe('constructor', () => { + it('should initialize with default idle threshold', () => { + const defaultDetector = new ActivityDetector(); + expect(defaultDetector).toBeInstanceOf(ActivityDetector); + }); + + it('should initialize with custom idle threshold', () => { + const customDetector = new ActivityDetector(5000); + expect(customDetector).toBeInstanceOf(ActivityDetector); + }); + }); + + describe('recordActivity', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + it('should update last activity time', () => { + const beforeTime = detector.getLastActivityTime(); + vi.advanceTimersByTime(100); + + detector.recordActivity(); + const afterTime = detector.getLastActivityTime(); + + expect(afterTime).toBeGreaterThan(beforeTime); + }); + }); + + describe('isUserActive', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + it('should return true immediately after construction', () => { + expect(detector.isUserActive()).toBe(true); + }); + + it('should return true within idle threshold', () => { + detector.recordActivity(); + expect(detector.isUserActive()).toBe(true); + }); + + it('should return false after idle threshold', () => { + // Advance time beyond idle threshold + vi.advanceTimersByTime(2000); // 2 seconds, threshold is 1 second + + expect(detector.isUserActive()).toBe(false); + }); + + it('should return true again after recording new activity', () => { + // Go idle + vi.advanceTimersByTime(2000); + expect(detector.isUserActive()).toBe(false); + + // Record new activity + detector.recordActivity(); + expect(detector.isUserActive()).toBe(true); + }); + }); + + describe('getTimeSinceLastActivity', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + it('should return time elapsed since last activity', () => { + detector.recordActivity(); + vi.advanceTimersByTime(500); + + const timeSince = detector.getTimeSinceLastActivity(); + expect(timeSince).toBe(500); + }); + }); + + describe('getLastActivityTime', () => { + it('should return the timestamp of last activity', () => { + const before = Date.now(); + detector.recordActivity(); + const activityTime = detector.getLastActivityTime(); + const after = Date.now(); + + expect(activityTime).toBeGreaterThanOrEqual(before); + expect(activityTime).toBeLessThanOrEqual(after); + }); + }); +}); + +describe('Global Activity Detector Functions', () => { + describe('global instance', () => { + it('should expose a global ActivityDetector via getActivityDetector', () => { + const detector = getActivityDetector(); + expect(detector).toBeInstanceOf(ActivityDetector); + }); + }); + + describe('getActivityDetector', () => { + it('should always return the global instance', () => { + const detector = getActivityDetector(); + const detectorAgain = getActivityDetector(); + expect(detectorAgain).toBe(detector); + }); + }); + + describe('recordUserActivity', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + it('should record activity on existing detector', () => { + const detector = getActivityDetector()!; + const beforeTime = detector.getLastActivityTime(); + vi.advanceTimersByTime(100); + + recordUserActivity(); + + const afterTime = detector.getLastActivityTime(); + expect(afterTime).toBeGreaterThan(beforeTime); + }); + }); + + describe('isUserActive', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + it('should reflect global detector state', () => { + expect(isUserActive()).toBe(true); + // Default idle threshold is 30s; advance beyond it + vi.advanceTimersByTime(31000); + expect(isUserActive()).toBe(false); + }); + }); +}); diff --git a/packages/core/src/telemetry/activity-detector.ts b/packages/core/src/telemetry/activity-detector.ts new file mode 100644 index 0000000000..84a03629f1 --- /dev/null +++ b/packages/core/src/telemetry/activity-detector.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Tracks user activity state to determine when memory monitoring should be active + */ +export class ActivityDetector { + private lastActivityTime: number = Date.now(); + private readonly idleThresholdMs: number; + + constructor(idleThresholdMs: number = 30000) { + this.idleThresholdMs = idleThresholdMs; + } + + /** + * Record user activity (called by CLI when user types, adds messages, etc.) + */ + recordActivity(): void { + this.lastActivityTime = Date.now(); + } + + /** + * Check if user is currently active (activity within idle threshold) + */ + isUserActive(): boolean { + const timeSinceActivity = Date.now() - this.lastActivityTime; + return timeSinceActivity < this.idleThresholdMs; + } + + /** + * Get time since last activity in milliseconds + */ + getTimeSinceLastActivity(): number { + return Date.now() - this.lastActivityTime; + } + + /** + * Get last activity timestamp + */ + getLastActivityTime(): number { + return this.lastActivityTime; + } +} + +// Global activity detector instance (eagerly created with default threshold) +const globalActivityDetector: ActivityDetector = new ActivityDetector(); + +/** + * Get global activity detector instance + */ +export function getActivityDetector(): ActivityDetector { + return globalActivityDetector; +} + +/** + * Record user activity (convenience function for CLI to call) + */ +export function recordUserActivity(): void { + globalActivityDetector.recordActivity(); +} + +/** + * Check if user is currently active (convenience function) + */ +export function isUserActive(): boolean { + return globalActivityDetector.isUserActive(); +} diff --git a/packages/core/src/telemetry/activity-types.ts b/packages/core/src/telemetry/activity-types.ts new file mode 100644 index 0000000000..c970a725c7 --- /dev/null +++ b/packages/core/src/telemetry/activity-types.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Types of user activities that can be tracked + */ +export enum ActivityType { + USER_INPUT_START = 'user_input_start', + USER_INPUT_END = 'user_input_end', + MESSAGE_ADDED = 'message_added', + TOOL_CALL_SCHEDULED = 'tool_call_scheduled', + TOOL_CALL_COMPLETED = 'tool_call_completed', + STREAM_START = 'stream_start', + STREAM_END = 'stream_end', + HISTORY_UPDATED = 'history_updated', + MANUAL_TRIGGER = 'manual_trigger', +} diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index e713593cf8..9560269b7a 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -59,3 +59,10 @@ export { SemanticAttributes } from '@opentelemetry/semantic-conventions'; export * from './uiTelemetry.js'; export { HighWaterMarkTracker } from './high-water-mark-tracker.js'; export { RateLimiter } from './rate-limiter.js'; +export { ActivityType } from './activity-types.js'; +export { + ActivityDetector, + getActivityDetector, + recordUserActivity, + isUserActive, +} from './activity-detector.js';