diff --git a/packages/app/src/desktop/electron/idle.test.ts b/packages/app/src/desktop/electron/idle.test.ts new file mode 100644 index 000000000..b3721d758 --- /dev/null +++ b/packages/app/src/desktop/electron/idle.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { invokeDesktopCommandMock } = vi.hoisted(() => ({ + invokeDesktopCommandMock: vi.fn<() => Promise>(async () => 12_000), +})); + +vi.mock("@/desktop/electron/invoke", () => ({ + invokeDesktopCommand: invokeDesktopCommandMock, +})); + +import { getDesktopSystemIdleTimeMs } from "./idle"; + +describe("getDesktopSystemIdleTimeMs", () => { + afterEach(() => { + invokeDesktopCommandMock.mockReset(); + vi.restoreAllMocks(); + }); + + it("invokes the desktop idle command and returns the millisecond value", async () => { + invokeDesktopCommandMock.mockResolvedValueOnce(4_200); + + const idleTimeMs = await getDesktopSystemIdleTimeMs(); + + expect(invokeDesktopCommandMock).toHaveBeenCalledWith("desktop_get_system_idle_time"); + expect(idleTimeMs).toBe(4_200); + }); + + it("returns null and logs once when IPC rejects", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const error = new Error("ipc failed"); + invokeDesktopCommandMock.mockRejectedValueOnce(error); + + const idleTimeMs = await getDesktopSystemIdleTimeMs(); + + expect(idleTimeMs).toBeNull(); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith("[DesktopIdle] Failed to read system idle time", error); + }); + + it("returns null and logs once when IPC returns null", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + invokeDesktopCommandMock.mockResolvedValueOnce(null); + + const idleTimeMs = await getDesktopSystemIdleTimeMs(); + + expect(idleTimeMs).toBeNull(); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith("[DesktopIdle] Invalid system idle time", null); + }); + + it("returns null and logs once when IPC returns NaN", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + invokeDesktopCommandMock.mockResolvedValueOnce(Number.NaN); + + const idleTimeMs = await getDesktopSystemIdleTimeMs(); + + expect(idleTimeMs).toBeNull(); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith("[DesktopIdle] Invalid system idle time", Number.NaN); + }); + + it("returns null and logs once when IPC returns a negative value", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + invokeDesktopCommandMock.mockResolvedValueOnce(-1); + + const idleTimeMs = await getDesktopSystemIdleTimeMs(); + + expect(idleTimeMs).toBeNull(); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith("[DesktopIdle] Invalid system idle time", -1); + }); + + it("returns 0 when IPC returns zero", async () => { + invokeDesktopCommandMock.mockResolvedValueOnce(0); + + const idleTimeMs = await getDesktopSystemIdleTimeMs(); + + expect(idleTimeMs).toBe(0); + }); +}); diff --git a/packages/app/src/desktop/electron/idle.ts b/packages/app/src/desktop/electron/idle.ts new file mode 100644 index 000000000..a3386f268 --- /dev/null +++ b/packages/app/src/desktop/electron/idle.ts @@ -0,0 +1,21 @@ +import { invokeDesktopCommand } from "@/desktop/electron/invoke"; + +const DESKTOP_SYSTEM_IDLE_COMMAND = "desktop_get_system_idle_time"; + +function isValidIdleTimeMs(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +export async function getDesktopSystemIdleTimeMs(): Promise { + try { + const idleTimeMs = await invokeDesktopCommand(DESKTOP_SYSTEM_IDLE_COMMAND); + if (!isValidIdleTimeMs(idleTimeMs)) { + console.warn("[DesktopIdle] Invalid system idle time", idleTimeMs); + return null; + } + return idleTimeMs; + } catch (error) { + console.warn("[DesktopIdle] Failed to read system idle time", error); + return null; + } +} diff --git a/packages/app/src/hooks/use-client-activity.test.tsx b/packages/app/src/hooks/use-client-activity.test.tsx new file mode 100644 index 000000000..61f86d188 --- /dev/null +++ b/packages/app/src/hooks/use-client-activity.test.tsx @@ -0,0 +1,215 @@ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useClientActivity } from "./use-client-activity"; + +type HeartbeatPayload = { + deviceType: "web" | "mobile"; + focusedAgentId: string | null; + lastActivityAt: string; + appVisible: boolean; + appVisibilityChangedAt?: string; +}; + +const { platformState, getDesktopSystemIdleTimeMs } = vi.hoisted(() => ({ + platformState: { + isWeb: true, + isNative: false, + isElectron: false, + }, + getDesktopSystemIdleTimeMs: vi.fn<() => Promise>(), +})); + +vi.mock("@/constants/platform", () => ({ + get isWeb() { + return platformState.isWeb; + }, + get isNative() { + return platformState.isNative; + }, + getIsElectron: () => platformState.isElectron, +})); + +vi.mock("@/desktop/electron/idle", () => ({ + getDesktopSystemIdleTimeMs, +})); + +vi.mock("react-native", () => ({ + AppState: { + currentState: "active", + addEventListener: vi.fn(() => ({ + remove: vi.fn(), + })), + }, +})); + +vi.mock("@server/client/daemon-client", () => ({})); + +function createTestClient() { + return { + isConnected: true, + subscribeConnectionStatus: vi.fn(() => vi.fn()), + sendHeartbeat: vi.fn<(payload: HeartbeatPayload) => void>(), + }; +} + +function latestHeartbeat(client: ReturnType): HeartbeatPayload { + const call = client.sendHeartbeat.mock.calls.at(-1); + if (!call) { + throw new Error("Expected a heartbeat"); + } + return call[0]; +} + +function heartbeatTimeMs(client: ReturnType): number { + return new Date(latestHeartbeat(client).lastActivityAt).getTime(); +} + +async function renderActivityHook({ + client = createTestClient(), +}: { + client?: ReturnType; +} = {}) { + function Probe() { + useClientActivity({ + client: client as unknown as Parameters[0]["client"], + focusedAgentId: "agent-1", + }); + return null; + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render(); + }); + + return { client, root }; +} + +async function advance(ms: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); +} + +describe("useClientActivity", () => { + let root: Root | null = null; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-19T10:00:00.000Z")); + + platformState.isWeb = true; + platformState.isNative = false; + platformState.isElectron = false; + getDesktopSystemIdleTimeMs.mockReset(); + + const dom = new JSDOM("", { + url: "http://localhost", + }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", dom.window); + vi.stubGlobal("document", dom.window.document); + }); + + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount(); + }); + root = null; + } + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("updates lastActivityAt from web pointer activity", async () => { + const rendered = await renderActivityHook(); + root = rendered.root; + + vi.setSystemTime(new Date("2026-04-19T10:00:05.250Z")); + window.dispatchEvent(new window.Event("pointerdown")); + + expect(heartbeatTimeMs(rendered.client)).toBe(Date.now()); + expect(getDesktopSystemIdleTimeMs).not.toHaveBeenCalled(); + }); + + it("drives lastActivityAt forward from Electron idle polling", async () => { + platformState.isElectron = true; + getDesktopSystemIdleTimeMs.mockResolvedValue(0); + const rendered = await renderActivityHook(); + root = rendered.root; + + await advance(5_000); + expect(getDesktopSystemIdleTimeMs).toHaveBeenCalledTimes(1); + + await advance(10_000); + + expect(heartbeatTimeMs(rendered.client)).toBe(Date.now()); + }); + + it("sets lastActivityAt to Date.now() minus the Electron idle time", async () => { + platformState.isElectron = true; + getDesktopSystemIdleTimeMs.mockResolvedValue(2_000); + const rendered = await renderActivityHook(); + root = rendered.root; + + await advance(5_000); + await advance(10_000); + + expect(heartbeatTimeMs(rendered.client)).toBe(Date.now() - 2_000); + }); + + it("skips failed Electron idle polls", async () => { + platformState.isElectron = true; + getDesktopSystemIdleTimeMs.mockResolvedValue(null); + const rendered = await renderActivityHook(); + root = rendered.root; + const previousLastActivityAt = heartbeatTimeMs(rendered.client); + + await advance(5_000); + await advance(10_000); + + expect(heartbeatTimeMs(rendered.client)).toBe(previousLastActivityAt); + }); + + it("never moves lastActivityAt backward from an idle poll", async () => { + platformState.isElectron = true; + getDesktopSystemIdleTimeMs.mockResolvedValue(20_000); + const rendered = await renderActivityHook(); + root = rendered.root; + + vi.setSystemTime(new Date("2026-04-19T10:00:05.000Z")); + window.dispatchEvent(new window.Event("pointerdown")); + const pointerActivityAt = latestHeartbeat(rendered.client).lastActivityAt; + + await advance(5_000); + expect(getDesktopSystemIdleTimeMs).toHaveBeenCalled(); + + await advance(10_000); + + expect(latestHeartbeat(rendered.client).lastActivityAt).toBe(pointerActivityAt); + }); + + it("keeps an Electron pointerdown newer than a stale idle poll", async () => { + platformState.isElectron = true; + getDesktopSystemIdleTimeMs.mockResolvedValue(20_000); + const rendered = await renderActivityHook(); + root = rendered.root; + + await advance(4_500); + window.dispatchEvent(new window.Event("pointerdown")); + const pointerActivityAt = latestHeartbeat(rendered.client).lastActivityAt; + + await advance(500); + expect(getDesktopSystemIdleTimeMs).toHaveBeenCalled(); + + await advance(10_000); + + expect(latestHeartbeat(rendered.client).lastActivityAt).toBe(pointerActivityAt); + }); +}); diff --git a/packages/app/src/hooks/use-client-activity.ts b/packages/app/src/hooks/use-client-activity.ts index 2855219fc..a244df33b 100644 --- a/packages/app/src/hooks/use-client-activity.ts +++ b/packages/app/src/hooks/use-client-activity.ts @@ -1,10 +1,12 @@ import { useEffect, useRef, useCallback } from "react"; import { AppState } from "react-native"; import type { DaemonClient } from "@server/client/daemon-client"; -import { isWeb, isNative } from "@/constants/platform"; +import { getIsElectron, isWeb, isNative } from "@/constants/platform"; +import { getDesktopSystemIdleTimeMs } from "@/desktop/electron/idle"; const HEARTBEAT_INTERVAL_MS = 15_000; const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000; +const DESKTOP_IDLE_POLL_INTERVAL_MS = 5_000; interface ClientActivityOptions { client: DaemonClient; @@ -130,6 +132,31 @@ export function useClientActivity({ }; }, [maybeSendImmediateHeartbeat, recordUserActivity, setAppVisible]); + // Track OS-wide activity in Electron so backgrounded desktop windows still report presence. + useEffect(() => { + if (!getIsElectron()) return; + + let disposed = false; + const pollSystemIdleTime = async () => { + const systemIdleMs = await getDesktopSystemIdleTimeMs(); + if (disposed || systemIdleMs === null) return; + + const systemLastActivityAtMs = Date.now() - systemIdleMs; + if (systemLastActivityAtMs > lastActivityAtRef.current.getTime()) { + lastActivityAtRef.current = new Date(systemLastActivityAtMs); + } + }; + + const interval = setInterval(() => { + void pollSystemIdleTime(); + }, DESKTOP_IDLE_POLL_INTERVAL_MS); + + return () => { + disposed = true; + clearInterval(interval); + }; + }, [client]); + // Send heartbeat on focused agent change useEffect(() => { if (prevFocusedAgentIdRef.current !== focusedAgentId) { diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index c07873cf5..2d1496138 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -1,7 +1,7 @@ import { type ChildProcess } from "node:child_process"; import { readFileSync } from "node:fs"; import path from "node:path"; -import { app, ipcMain } from "electron"; +import { app, ipcMain, powerMonitor } from "electron"; import log from "electron-log/main"; import { resolvePaseoHome, spawnProcess } from "@getpaseo/server"; import { @@ -444,6 +444,10 @@ function resolveCurrentUpdateVersion(): string { return resolveDesktopAppVersion(); } +function getSystemIdleTimeMs(): number { + return powerMonitor.getSystemIdleTime() * 1000; +} + // --------------------------------------------------------------------------- // IPC registration // --------------------------------------------------------------------------- @@ -456,6 +460,7 @@ export function createDaemonCommandHandlers(): Record restartDaemon(), desktop_daemon_logs: () => getDaemonLogs(), desktop_daemon_pairing: () => getDaemonPairing(), + desktop_get_system_idle_time: () => getSystemIdleTimeMs(), cli_daemon_status: () => getCliDaemonStatus(), write_attachment_base64: (args) => writeAttachmentBase64(args ?? {}), copy_attachment_file: (args) => copyAttachmentFileToManagedStorage(args ?? {}), diff --git a/packages/server/src/server/agent-attention-policy.test.ts b/packages/server/src/server/agent-attention-policy.test.ts index ed78e0509..fe3cae5ca 100644 --- a/packages/server/src/server/agent-attention-policy.test.ts +++ b/packages/server/src/server/agent-attention-policy.test.ts @@ -1,239 +1,173 @@ import { describe, expect, it } from "vitest"; import { - computeShouldNotifyClient, - computeShouldSendPush, - type ClientAttentionState, + computeNotificationPlan, + type ClientPresenceState, + PRESENCE_THRESHOLD_MS, } from "./agent-attention-policy.js"; -function state(overrides: Partial): ClientAttentionState { +function state(overrides: Partial): ClientPresenceState { return { - deviceType: null, focusedAgentId: null, - isStale: false, - appVisible: false, + lastActivityAtMs: null, ...overrides, }; } -describe("computeShouldNotifyClient", () => { - it("suppresses notifications when someone is actively focused on the agent", () => { - const activeOnAgent = state({ - deviceType: "web", +describe("computeNotificationPlan", () => { + const nowMs = Date.parse("2026-04-19T12:00:00.000Z"); + const staleAtMs = nowMs - PRESENCE_THRESHOLD_MS - 1; + const presentAtMs = nowMs - PRESENCE_THRESHOLD_MS + 1; + + it("does not suppress notifications when a focused client is stale", () => { + const staleFocused = state({ focusedAgentId: "agent-1", - isStale: false, - appVisible: true, - }); - const staleMobile = state({ - deviceType: "mobile", - focusedAgentId: null, - isStale: true, - appVisible: false, + lastActivityAtMs: staleAtMs, }); expect( - computeShouldNotifyClient({ - clientState: staleMobile, - allClientStates: [activeOnAgent, staleMobile], + computeNotificationPlan({ + allStates: [staleFocused], agentId: "agent-1", - }), - ).toBe(false); - }); - - it("notifies unidentified clients by default when agent is not actively focused", () => { - const unknownClient = state({ - deviceType: null, - focusedAgentId: null, - isStale: false, - appVisible: false, - }); - - expect( - computeShouldNotifyClient({ - clientState: unknownClient, - allClientStates: [unknownClient], - agentId: "agent-2", - }), - ).toBe(true); - }); - - it("notifies active visible clients when they are focused on an agent", () => { - const focusedWeb = state({ - deviceType: "web", - focusedAgentId: "agent-2", - isStale: false, - appVisible: true, - }); - - expect( - computeShouldNotifyClient({ - clientState: focusedWeb, - allClientStates: [focusedWeb], - agentId: "agent-3", - }), - ).toBe(true); - }); - - it("suppresses active clients that are not focused on an agent", () => { - const activeButNotFocused = state({ - deviceType: "web", - focusedAgentId: null, - isStale: false, - appVisible: true, - }); - - expect( - computeShouldNotifyClient({ - clientState: activeButNotFocused, - allClientStates: [activeButNotFocused], - agentId: "agent-4", - }), - ).toBe(false); - }); - - it("suppresses stale mobile notifications when an active web client exists", () => { - const staleMobile = state({ - deviceType: "mobile", - focusedAgentId: null, - isStale: true, - appVisible: false, - }); - const activeWeb = state({ - deviceType: "web", - focusedAgentId: null, - isStale: false, - appVisible: true, - }); - - expect( - computeShouldNotifyClient({ - clientState: staleMobile, - allClientStates: [staleMobile, activeWeb], - agentId: "agent-5", - }), - ).toBe(false); - }); - - it("allows stale mobile notifications when no active web client exists", () => { - const staleMobile = state({ - deviceType: "mobile", - focusedAgentId: null, - isStale: true, - appVisible: false, - }); - const staleWeb = state({ - deviceType: "web", - focusedAgentId: null, - isStale: true, - appVisible: false, - }); - - expect( - computeShouldNotifyClient({ - clientState: staleMobile, - allClientStates: [staleMobile, staleWeb], - agentId: "agent-6", - }), - ).toBe(true); - }); - - it("suppresses stale web notifications when a mobile client is also present", () => { - const staleWeb = state({ - deviceType: "web", - focusedAgentId: null, - isStale: true, - appVisible: false, - }); - const staleMobile = state({ - deviceType: "mobile", - focusedAgentId: null, - isStale: true, - appVisible: false, - }); - - expect( - computeShouldNotifyClient({ - clientState: staleWeb, - allClientStates: [staleWeb, staleMobile], - agentId: "agent-7", - }), - ).toBe(false); - }); - - it("allows stale web notifications when there are no mobile or unidentified clients", () => { - const staleWeb = state({ - deviceType: "web", - focusedAgentId: null, - isStale: true, - appVisible: false, - }); - - expect( - computeShouldNotifyClient({ - clientState: staleWeb, - allClientStates: [staleWeb], - agentId: "agent-8", - }), - ).toBe(true); - }); -}); - -describe("computeShouldSendPush", () => { - it("never sends push for error attention events", () => { - expect( - computeShouldSendPush({ - reason: "error", - allClientStates: [], - }), - ).toBe(false); - }); - - it("suppresses push when any active web client exists", () => { - expect( - computeShouldSendPush({ reason: "finished", - allClientStates: [ + nowMs, + }), + ).toEqual({ inAppRecipientIndex: null, shouldPush: true }); + }); + + it("suppresses notifications when a focused client is present", () => { + const staleFocused = state({ + focusedAgentId: "agent-1", + lastActivityAtMs: staleAtMs, + }); + const presentFocused = state({ + focusedAgentId: "agent-1", + lastActivityAtMs: presentAtMs, + }); + + expect( + computeNotificationPlan({ + allStates: [staleFocused, presentFocused], + agentId: "agent-1", + reason: "finished", + nowMs, + }), + ).toEqual({ inAppRecipientIndex: null, shouldPush: false }); + }); + + it("treats present clients focused on different agents as eligible", () => { + expect( + computeNotificationPlan({ + allStates: [ state({ - deviceType: "web", - isStale: false, - appVisible: true, + focusedAgentId: "agent-2", + lastActivityAtMs: nowMs - 1_000, }), ], + agentId: "agent-1", + reason: "finished", + nowMs, }), - ).toBe(false); + ).toEqual({ inAppRecipientIndex: 0, shouldPush: false }); }); - it("suppresses push when a mobile app is actively visible", () => { + it("chooses the present client with the greatest clamped activity timestamp", () => { expect( - computeShouldSendPush({ + computeNotificationPlan({ + allStates: [ + state({ lastActivityAtMs: nowMs - 10_000 }), + state({ lastActivityAtMs: nowMs - 1_000 }), + state({ lastActivityAtMs: staleAtMs }), + ], + agentId: "agent-1", reason: "permission", - allClientStates: [ - state({ - deviceType: "mobile", - isStale: false, - appVisible: true, - }), - ], + nowMs, }), - ).toBe(false); + ).toEqual({ inAppRecipientIndex: 1, shouldPush: false }); }); - it("sends push when no active web client or foreground mobile client exists", () => { + it("uses the lower index when present clients have identical timestamps", () => { expect( - computeShouldSendPush({ - reason: "finished", - allClientStates: [ - state({ - deviceType: "mobile", - isStale: true, - appVisible: false, - }), - state({ - deviceType: "web", - isStale: true, - appVisible: false, - }), + computeNotificationPlan({ + allStates: [ + state({ lastActivityAtMs: nowMs - 1_000 }), + state({ lastActivityAtMs: nowMs - 1_000 }), ], + agentId: "agent-1", + reason: "finished", + nowMs, }), - ).toBe(true); + ).toEqual({ inAppRecipientIndex: 0, shouldPush: false }); + }); + + it("clamps future timestamps to now and treats them as present", () => { + expect( + computeNotificationPlan({ + allStates: [ + state({ lastActivityAtMs: nowMs - 1 }), + state({ lastActivityAtMs: nowMs + 600_000 }), + ], + agentId: "agent-1", + reason: "permission", + nowMs, + }), + ).toEqual({ inAppRecipientIndex: 1, shouldPush: false }); + }); + + it("never treats no-heartbeat clients as present", () => { + expect( + computeNotificationPlan({ + allStates: [state({ lastActivityAtMs: null })], + agentId: "agent-1", + reason: "finished", + nowMs, + }), + ).toEqual({ inAppRecipientIndex: null, shouldPush: true }); + }); + + it("falls back to push for non-error attention when no clients are present", () => { + expect( + computeNotificationPlan({ + allStates: [state({ lastActivityAtMs: staleAtMs })], + agentId: "agent-1", + reason: "permission", + nowMs, + }), + ).toEqual({ inAppRecipientIndex: null, shouldPush: true }); + }); + + it("does not push error attention when no clients are present", () => { + expect( + computeNotificationPlan({ + allStates: [state({ lastActivityAtMs: staleAtMs })], + agentId: "agent-1", + reason: "error", + nowMs, + }), + ).toEqual({ inAppRecipientIndex: null, shouldPush: false }); + }); + + it("lets a foreground mobile-style client with recent activity win as most recent", () => { + expect( + computeNotificationPlan({ + allStates: [ + state({ focusedAgentId: "agent-2", lastActivityAtMs: nowMs - 20_000 }), + state({ focusedAgentId: null, lastActivityAtMs: nowMs - 500 }), + ], + agentId: "agent-1", + reason: "finished", + nowMs, + }), + ).toEqual({ inAppRecipientIndex: 1, shouldPush: false }); + }); + + it("selects no in-app recipient and pushes when two web-style clients are stale", () => { + expect( + computeNotificationPlan({ + allStates: [state({ lastActivityAtMs: staleAtMs }), state({ lastActivityAtMs: staleAtMs })], + agentId: "agent-1", + reason: "finished", + nowMs, + }), + ).toEqual({ inAppRecipientIndex: null, shouldPush: true }); }); }); diff --git a/packages/server/src/server/agent-attention-policy.ts b/packages/server/src/server/agent-attention-policy.ts index ac76776ca..8008b2735 100644 --- a/packages/server/src/server/agent-attention-policy.ts +++ b/packages/server/src/server/agent-attention-policy.ts @@ -1,88 +1,56 @@ import type { AgentAttentionReason } from "../shared/agent-attention-notification.js"; -export type ClientAttentionState = { - deviceType: "web" | "mobile" | null; +export const PRESENCE_THRESHOLD_MS = 180_000; + +export interface ClientPresenceState { + lastActivityAtMs: number | null; focusedAgentId: string | null; - isStale: boolean; - appVisible: boolean; -}; +} -type ComputeClientNotificationInput = { - clientState: ClientAttentionState; - allClientStates: ClientAttentionState[]; +export interface NotificationPlan { + inAppRecipientIndex: number | null; + shouldPush: boolean; +} + +type ComputeNotificationPlanInput = { + allStates: ClientPresenceState[]; agentId: string; -}; - -type ComputePushNotificationInput = { reason: AgentAttentionReason; - allClientStates: ClientAttentionState[]; + nowMs: number; }; -function hasActiveClientOnAgent(allClientStates: ClientAttentionState[], agentId: string): boolean { - return allClientStates.some( - (state) => state.focusedAgentId === agentId && state.appVisible && !state.isStale, - ); -} - -function hasActiveWebClient(allClientStates: ClientAttentionState[]): boolean { - return allClientStates.some((state) => state.deviceType === "web" && !state.isStale); -} - -function hasOtherCompetingClient( - clientState: ClientAttentionState, - allClientStates: ClientAttentionState[], -): boolean { - return allClientStates.some( - (state) => - state !== clientState && (state.deviceType === "mobile" || state.deviceType === null), - ); -} - -function hasActiveForegroundMobileClient(allClientStates: ClientAttentionState[]): boolean { - return allClientStates.some( - (state) => state.deviceType === "mobile" && state.appVisible && !state.isStale, - ); -} - -export function computeShouldNotifyClient({ - clientState, - allClientStates, +export function computeNotificationPlan({ + allStates, agentId, -}: ComputeClientNotificationInput): boolean { - if (hasActiveClientOnAgent(allClientStates, agentId)) { - return false; - } - - if (clientState.deviceType === null) { - return true; - } - - if (!clientState.isStale && clientState.appVisible && clientState.focusedAgentId !== null) { - return true; - } - - if (!clientState.isStale) { - return false; - } - - if (clientState.deviceType === "mobile") { - return !hasActiveWebClient(allClientStates); - } - - if (clientState.deviceType === "web") { - return !hasOtherCompetingClient(clientState, allClientStates); - } - - return true; -} - -export function computeShouldSendPush({ reason, - allClientStates, -}: ComputePushNotificationInput): boolean { - if (reason === "error") { - return false; + nowMs, +}: ComputeNotificationPlanInput): NotificationPlan { + let mostRecentPresentIndex: number | null = null; + let mostRecentPresentAtMs = Number.NEGATIVE_INFINITY; + + for (const [clientIndex, state] of allStates.entries()) { + const clampedActivityAtMs = + state.lastActivityAtMs === null ? null : Math.min(state.lastActivityAtMs, nowMs); + const isPresent = + clampedActivityAtMs !== null && nowMs - clampedActivityAtMs <= PRESENCE_THRESHOLD_MS; + + if (!isPresent) { + continue; + } + + if (state.focusedAgentId === agentId) { + return { inAppRecipientIndex: null, shouldPush: false }; + } + + if (clampedActivityAtMs > mostRecentPresentAtMs) { + mostRecentPresentIndex = clientIndex; + mostRecentPresentAtMs = clampedActivityAtMs; + } } - return !hasActiveWebClient(allClientStates) && !hasActiveForegroundMobileClient(allClientStates); + if (mostRecentPresentIndex !== null) { + return { inAppRecipientIndex: mostRecentPresentIndex, shouldPush: false }; + } + + return { inAppRecipientIndex: null, shouldPush: reason !== "error" }; } diff --git a/packages/server/src/server/client-activity.e2e.test.ts b/packages/server/src/server/client-activity.e2e.test.ts index a596ba7fb..77770e677 100644 --- a/packages/server/src/server/client-activity.e2e.test.ts +++ b/packages/server/src/server/client-activity.e2e.test.ts @@ -1,8 +1,11 @@ -import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/paseo-daemon.js"; import { DaemonClient } from "./test-utils/daemon-client.js"; import type { AgentStreamEventPayload } from "../shared/messages.js"; import type { AgentSnapshotPayload } from "./messages.js"; +import { PushService } from "./push/push-service.js"; +import { PushTokenStore } from "./push/token-store.js"; +import { PRESENCE_THRESHOLD_MS } from "./agent-attention-policy.js"; /** * Tests for client activity tracking and smart notifications. @@ -11,11 +14,9 @@ import type { AgentSnapshotPayload } from "./messages.js"; * We want to notify them where they'll see it. * * Rules: - * 1. If user is actively looking at the agent (focused + visible + recent activity) → no notification - * 2. If user is on a device but looking elsewhere → notify on that device - * 3. If web is stale (>2min no activity) but mobile is connected → notify mobile, not web - * 4. Mobile is the fallback - always notify if connected and web is stale - * 5. Switching tabs (appVisible=false) with recent activity → NO notification (user is still at computer) + * 1. If a present client is focused on the agent → no notification + * 2. Otherwise, notify the most recently active present client + * 3. If no client is present → send push for non-error attention * * Heartbeat contains: * - deviceType: "web" | "mobile" @@ -30,8 +31,14 @@ describe("client activity tracking", () => { let daemon: TestPaseoDaemon; let client1: DaemonClient; let client2: DaemonClient; + let sendPushSpy: ReturnType; + let getAllTokensSpy: ReturnType; beforeEach(async () => { + sendPushSpy = vi.spyOn(PushService.prototype, "sendPush").mockResolvedValue(undefined); + getAllTokensSpy = vi + .spyOn(PushTokenStore.prototype, "getAllTokens") + .mockReturnValue(["ExponentPushToken[activity-test]"]); daemon = await createTestPaseoDaemon(); }); @@ -39,6 +46,8 @@ describe("client activity tracking", () => { if (client1) await client1.close().catch(() => {}); if (client2) await client2.close().catch(() => {}); await daemon.close(); + sendPushSpy.mockRestore(); + getAllTokensSpy.mockRestore(); }, 30000); async function createClient(): Promise { @@ -143,7 +152,7 @@ describe("client activity tracking", () => { expect(attention.shouldNotify).toBe(true); }, 120000); - test("no notification when app is not visible but activity is recent (user just switched tabs)", async () => { + test("notification when app is not visible but activity is recent", async () => { client1 = await createClient(); const agent = await createAgent({ @@ -151,7 +160,7 @@ describe("client activity tracking", () => { title: "App Hidden Test", }); - // User switched away from the app but was active recently - they're still at the computer + // User switched away from the app but was active recently. client1.sendHeartbeat({ deviceType: "web", focusedAgentId: null, // null because app not visible @@ -167,10 +176,10 @@ describe("client activity tracking", () => { const attention = await attentionPromise; expect(attention.reason).toBe("finished"); - expect(attention.shouldNotify).toBe(false); + expect(attention.shouldNotify).toBe(true); }, 120000); - test("notification when activity is stale (user walked away for 2+ minutes)", async () => { + test("push fallback when activity is stale beyond the presence threshold", async () => { client1 = await createClient(); const agent = await createAgent({ @@ -178,8 +187,8 @@ describe("client activity tracking", () => { title: "Stale Activity Test", }); - // User had agent focused but no activity for 2+ minutes (stale threshold) - const staleTime = new Date(Date.now() - 125_000).toISOString(); // 2min 5sec ago + // User had agent focused but no activity beyond the presence threshold. + const staleTime = new Date(Date.now() - PRESENCE_THRESHOLD_MS - 5_000).toISOString(); client1.sendHeartbeat({ deviceType: "web", focusedAgentId: agent.id, @@ -195,7 +204,8 @@ describe("client activity tracking", () => { const attention = await attentionPromise; expect(attention.reason).toBe("finished"); - expect(attention.shouldNotify).toBe(true); + expect(attention.shouldNotify).toBe(false); + expect(sendPushSpy).toHaveBeenCalledTimes(1); }, 120000); test("notification when no heartbeat received (legacy/new client)", async () => { @@ -214,7 +224,8 @@ describe("client activity tracking", () => { const attention = await attentionPromise; expect(attention.reason).toBe("finished"); - expect(attention.shouldNotify).toBe(true); + expect(attention.shouldNotify).toBe(false); + expect(sendPushSpy).toHaveBeenCalledTimes(1); }, 120000); }); @@ -261,7 +272,7 @@ describe("client activity tracking", () => { expect(attention2.shouldNotify).toBe(false); }, 120000); - test("both notify when both web clients are inactive", async () => { + test("pushes when both web clients are inactive", async () => { client1 = await createClient(); client2 = await createClient(); @@ -270,7 +281,7 @@ describe("client activity tracking", () => { title: "Both Inactive Test", }); - const staleTime = new Date(Date.now() - 120_000).toISOString(); + const staleTime = new Date(Date.now() - PRESENCE_THRESHOLD_MS - 5_000).toISOString(); // Both clients stale client1.sendHeartbeat({ @@ -295,9 +306,46 @@ describe("client activity tracking", () => { const [attention1, attention2] = await Promise.all([attention1Promise, attention2Promise]); - // Both should notify - no one is watching - expect(attention1.shouldNotify).toBe(true); + // No stale client is selected for in-app; push handles the fallback. + expect(attention1.shouldNotify).toBe(false); + expect(attention2.shouldNotify).toBe(false); + expect(sendPushSpy).toHaveBeenCalledTimes(1); + }, 120000); + + test("notifies only the present Electron-style web client when Firefox is stale", async () => { + client1 = await createClient(); + client2 = await createClient(); + + const agent = await createAgent({ + client: client1, + title: "Stale Firefox Present Electron Test", + }); + + client1.sendHeartbeat({ + deviceType: "web", + focusedAgentId: agent.id, + lastActivityAt: new Date(Date.now() - PRESENCE_THRESHOLD_MS - 60_000).toISOString(), + appVisible: false, + }); + + client2.sendHeartbeat({ + deviceType: "web", + focusedAgentId: null, + lastActivityAt: new Date(Date.now() - 1_000).toISOString(), + appVisible: false, + }); + + await new Promise((r) => setTimeout(r, 100)); + + const attention1Promise = waitForAttentionRequired(client1, agent.id); + const attention2Promise = waitForAttentionRequired(client2, agent.id); + await client1.sendMessage(agent.id, "Say 'hello' and nothing else"); + + const [attention1, attention2] = await Promise.all([attention1Promise, attention2Promise]); + + expect(attention1.shouldNotify).toBe(false); expect(attention2.shouldNotify).toBe(true); + expect(sendPushSpy).not.toHaveBeenCalled(); }, 120000); }); @@ -327,7 +375,7 @@ describe("client activity tracking", () => { client2.sendHeartbeat({ deviceType: "mobile", focusedAgentId: null, - lastActivityAt: new Date(Date.now() - 300_000).toISOString(), // 5 min ago + lastActivityAt: new Date(Date.now() - PRESENCE_THRESHOLD_MS - 60_000).toISOString(), appVisible: false, }); @@ -357,7 +405,7 @@ describe("client activity tracking", () => { client1.sendHeartbeat({ deviceType: "web", focusedAgentId: null, - lastActivityAt: new Date(Date.now() - 120_000).toISOString(), + lastActivityAt: new Date(Date.now() - PRESENCE_THRESHOLD_MS - 5_000).toISOString(), appVisible: false, }); @@ -382,7 +430,7 @@ describe("client activity tracking", () => { expect(attention2.shouldNotify).toBe(false); }, 120000); - test("notify mobile only when web is stale", async () => { + test("notify mobile only when web is stale and mobile is present", async () => { client1 = await createClient(); // web client2 = await createClient(); // mobile @@ -395,15 +443,15 @@ describe("client activity tracking", () => { client1.sendHeartbeat({ deviceType: "web", focusedAgentId: agent.id, // was looking at agent - lastActivityAt: new Date(Date.now() - 120_000).toISOString(), // but 2 min ago + lastActivityAt: new Date(Date.now() - PRESENCE_THRESHOLD_MS - 5_000).toISOString(), appVisible: true, }); - // Mobile: connected but not active (phone in pocket) + // Mobile: present but not focused on the agent client2.sendHeartbeat({ deviceType: "mobile", focusedAgentId: null, - lastActivityAt: new Date(Date.now() - 300_000).toISOString(), // 5 min ago + lastActivityAt: new Date().toISOString(), appVisible: false, }); @@ -415,9 +463,9 @@ describe("client activity tracking", () => { const [attention1, attention2] = await Promise.all([attention1Promise, attention2Promise]); - // Web stale → don't notify web, notify mobile instead expect(attention1.shouldNotify).toBe(false); expect(attention2.shouldNotify).toBe(true); + expect(sendPushSpy).not.toHaveBeenCalled(); }, 120000); test("notify web when user active on web but looking at different agent", async () => { @@ -440,7 +488,7 @@ describe("client activity tracking", () => { client2.sendHeartbeat({ deviceType: "mobile", focusedAgentId: null, - lastActivityAt: new Date(Date.now() - 300_000).toISOString(), + lastActivityAt: new Date(Date.now() - PRESENCE_THRESHOLD_MS - 60_000).toISOString(), appVisible: false, }); @@ -458,7 +506,7 @@ describe("client activity tracking", () => { expect(attention2.shouldNotify).toBe(false); }, 120000); - test("notify both when both devices inactive and no one watching agent", async () => { + test("pushes when both devices are inactive and no one is watching agent", async () => { client1 = await createClient(); // web client2 = await createClient(); // mobile @@ -467,7 +515,7 @@ describe("client activity tracking", () => { title: "Both Inactive Test", }); - const staleTime = new Date(Date.now() - 120_000).toISOString(); + const staleTime = new Date(Date.now() - PRESENCE_THRESHOLD_MS - 5_000).toISOString(); // Web: stale client1.sendHeartbeat({ @@ -493,10 +541,9 @@ describe("client activity tracking", () => { const [attention1, attention2] = await Promise.all([attention1Promise, attention2Promise]); - // Mobile always notifies when no one is watching - // Web is stale so don't bother notifying there - expect(attention1.shouldNotify).toBe(false); // web stale - expect(attention2.shouldNotify).toBe(true); // mobile fallback + expect(attention1.shouldNotify).toBe(false); + expect(attention2.shouldNotify).toBe(false); + expect(sendPushSpy).toHaveBeenCalledTimes(1); }, 120000); }); @@ -505,7 +552,7 @@ describe("client activity tracking", () => { // =========================================================================== describe("edge cases", () => { - test("mobile notifies even with no heartbeat when web is stale", async () => { + test("pushes when web is stale and mobile has no heartbeat", async () => { client1 = await createClient(); // web client2 = await createClient(); // mobile - no heartbeat @@ -518,12 +565,11 @@ describe("client activity tracking", () => { client1.sendHeartbeat({ deviceType: "web", focusedAgentId: agent.id, - lastActivityAt: new Date(Date.now() - 120_000).toISOString(), + lastActivityAt: new Date(Date.now() - PRESENCE_THRESHOLD_MS - 5_000).toISOString(), appVisible: true, }); // Mobile: never sent heartbeat (new connection) - // Should still receive notification as fallback await new Promise((r) => setTimeout(r, 100)); @@ -533,13 +579,12 @@ describe("client activity tracking", () => { const [attention1, attention2] = await Promise.all([attention1Promise, attention2Promise]); - // Web stale → no notification - // Mobile has no heartbeat → treat as should notify (we don't know device type) expect(attention1.shouldNotify).toBe(false); - expect(attention2.shouldNotify).toBe(true); + expect(attention2.shouldNotify).toBe(false); + expect(sendPushSpy).toHaveBeenCalledTimes(1); }, 120000); - test("no notification when app not visible but activity recent (switched tabs recently)", async () => { + test("notification when app not visible but activity is recent", async () => { client1 = await createClient(); const agent = await createAgent({ @@ -547,7 +592,7 @@ describe("client activity tracking", () => { title: "Tab Switch Test", }); - // User just switched tabs but was active 10 seconds ago - still at computer + // User just switched tabs but was active 10 seconds ago. client1.sendHeartbeat({ deviceType: "web", focusedAgentId: null, // not focused - tab hidden @@ -562,11 +607,10 @@ describe("client activity tracking", () => { const attention = await attentionPromise; - // User is at the computer (recent activity) - no notification needed - expect(attention.shouldNotify).toBe(false); + expect(attention.shouldNotify).toBe(true); }, 120000); - test("no notification to either when both have recent activity (user is present)", async () => { + test("notifies only the lower-index client when both have identical recent activity", async () => { client1 = await createClient(); // web client2 = await createClient(); // mobile @@ -575,11 +619,13 @@ describe("client activity tracking", () => { title: "Both Recent Activity Test", }); - // Web: tab hidden but recent activity (user at computer, switched tabs) + const recentTime = new Date(Date.now() - 30_000).toISOString(); + + // Web: tab hidden but recent activity client1.sendHeartbeat({ deviceType: "web", focusedAgentId: null, - lastActivityAt: new Date(Date.now() - 30_000).toISOString(), // 30s ago + lastActivityAt: recentTime, appVisible: false, }); @@ -587,7 +633,7 @@ describe("client activity tracking", () => { client2.sendHeartbeat({ deviceType: "mobile", focusedAgentId: null, - lastActivityAt: new Date(Date.now() - 30_000).toISOString(), + lastActivityAt: recentTime, appVisible: false, }); @@ -599,9 +645,9 @@ describe("client activity tracking", () => { const [attention1, attention2] = await Promise.all([attention1Promise, attention2Promise]); - // Both have recent activity - user is present, no notification needed - expect(attention1.shouldNotify).toBe(false); + expect(attention1.shouldNotify).toBe(true); expect(attention2.shouldNotify).toBe(false); + expect(sendPushSpy).not.toHaveBeenCalled(); }, 120000); }); }); diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index 3bddaeeb1..b130e4dbb 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -109,6 +109,62 @@ function createServer(agentManagerOverrides?: Record) { return { server, agentManager }; } +function createOpenSocket() { + return { + readyState: 1, + send: vi.fn(), + close: vi.fn(), + on: vi.fn(), + once: vi.fn(), + }; +} + +function createSessionWithActivity( + activity: { + deviceType: "web" | "mobile"; + focusedAgentId: string | null; + lastActivityAt: Date; + appVisible: boolean; + appVisibilityChangedAt?: Date; + } | null, +) { + return { + getClientActivity: vi.fn(() => activity), + }; +} + +function connectClient( + server: VoiceAssistantWebSocketServer, + activity: { + deviceType: "web" | "mobile"; + focusedAgentId: string | null; + lastActivityAt: Date; + appVisible: boolean; + appVisibilityChangedAt?: Date; + } | null, +) { + const ws = createOpenSocket(); + (server as any).sessions.set(ws, { + session: createSessionWithActivity(activity), + clientId: "client-test", + appVersion: null, + connectionLogger: createLogger(), + sockets: new Set([ws]), + externalDisconnectCleanupTimeout: null, + }); + return ws; +} + +function readAttentionRequiredMessage(ws: ReturnType) { + const rawMessage = ws.send.mock.calls[0]?.[0]; + expect(typeof rawMessage).toBe("string"); + const message = JSON.parse(rawMessage as string); + expect(message.type).toBe("session"); + expect(message.message.type).toBe("agent_stream"); + expect(message.message.payload.event.type).toBe("attention_required"); + return message.message.payload.event; +} + describe("VoiceAssistantWebSocketServer notification payloads", () => { afterEach(() => { vi.clearAllMocks(); @@ -166,4 +222,59 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => { expect(pushMocks.sendPush).toHaveBeenCalledTimes(1); expect(getLastAssistantMessage).toHaveBeenCalledWith("agent-2"); }); + + it("routes a hidden stale focused browser tab's notification to the present Electron web client", async () => { + const { server } = createServer(); + const nowMs = Date.now(); + const electronWs = connectClient(server, { + deviceType: "web", + appVisible: false, + focusedAgentId: "agent-Y", + lastActivityAt: new Date(nowMs - 5_000), + }); + const firefoxWs = connectClient(server, { + deviceType: "web", + appVisible: false, + focusedAgentId: "agent-X", + lastActivityAt: new Date(nowMs - 300_000), + }); + + await (server as any).broadcastAgentAttention({ + agentId: "agent-X", + provider: "claude", + reason: "finished", + }); + + expect(readAttentionRequiredMessage(electronWs).shouldNotify).toBe(true); + expect(readAttentionRequiredMessage(firefoxWs).shouldNotify).toBe(false); + expect(pushMocks.sendPush).not.toHaveBeenCalled(); + }); + + it("pushes non-error attention when the only connected client has never sent a heartbeat", async () => { + const { server } = createServer(); + const ws = connectClient(server, null); + + await (server as any).broadcastAgentAttention({ + agentId: "agent-no-heartbeat", + provider: "claude", + reason: "finished", + }); + + expect(readAttentionRequiredMessage(ws).shouldNotify).toBe(false); + expect(pushMocks.sendPush).toHaveBeenCalledTimes(1); + }); + + it("does not push error attention when the only connected client has never sent a heartbeat", async () => { + const { server } = createServer(); + const ws = connectClient(server, null); + + await (server as any).broadcastAgentAttention({ + agentId: "agent-no-heartbeat", + provider: "claude", + reason: "error", + }); + + expect(readAttentionRequiredMessage(ws).shouldNotify).toBe(false); + expect(pushMocks.sendPush).not.toHaveBeenCalled(); + }); }); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index dff46c27d..a4c0bdebc 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -42,11 +42,7 @@ import type { ScriptRouteStore } from "./script-proxy.js"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js"; -import { - computeShouldNotifyClient, - computeShouldSendPush, - type ClientAttentionState, -} from "./agent-attention-policy.js"; +import { computeNotificationPlan, type ClientPresenceState } from "./agent-attention-policy.js"; import { buildAgentAttentionNotificationPayload, findLatestPermissionRequest, @@ -1319,8 +1315,6 @@ export class VoiceAssistantWebSocketServer { } } - private readonly ACTIVITY_THRESHOLD_MS = 120_000; - private incrementRuntimeCounter(counter: keyof WebSocketRuntimeCounters): void { this.runtimeCounters[counter] += 1; } @@ -1520,19 +1514,18 @@ export class VoiceAssistantWebSocketServer { this.runtimeWindowStartedAt = now; } - private getClientActivityState(session: Session): ClientAttentionState { + private getClientActivityState(session: Session): ClientPresenceState { const activity = session.getClientActivity(); if (!activity) { - return { deviceType: null, focusedAgentId: null, isStale: true, appVisible: false }; + return { + focusedAgentId: null, + lastActivityAtMs: null, + }; } - const now = Date.now(); - const ageMs = now - activity.lastActivityAt.getTime(); - const isStale = ageMs >= this.ACTIVITY_THRESHOLD_MS; + return { - deviceType: activity.deviceType, focusedAgentId: activity.focusedAgentId, - isStale, - appVisible: activity.appVisible, + lastActivityAtMs: activity.lastActivityAt.getTime(), }; } @@ -1543,7 +1536,7 @@ export class VoiceAssistantWebSocketServer { }): Promise { const clientEntries: Array<{ ws: WebSocketLike; - state: ClientAttentionState; + state: ClientPresenceState; }> = []; for (const [ws, connection] of this.sessions) { @@ -1554,6 +1547,7 @@ export class VoiceAssistantWebSocketServer { } const allStates = clientEntries.map((e) => e.state); + const nowMs = Date.now(); const agent = this.agentManager.getAgent(params.agentId); const assistantMessage = await this.agentManager.getLastAssistantMessage(params.agentId); const notification = buildAgentAttentionNotificationPayload({ @@ -1564,14 +1558,14 @@ export class VoiceAssistantWebSocketServer { permissionRequest: agent ? findLatestPermissionRequest(agent.pendingPermissions) : null, }); - // Push is only a fallback when the user is away from desktop/web. - // Also suppress push if they're actively using the mobile app. - const shouldSendPush = computeShouldSendPush({ + const plan = computeNotificationPlan({ + allStates, + agentId: params.agentId, reason: params.reason, - allClientStates: allStates, + nowMs, }); - if (shouldSendPush) { + if (plan.shouldPush) { const tokens = this.pushTokenStore.getAllTokens(); this.logger.info({ tokenCount: tokens.length }, "Sending push notification"); if (tokens.length > 0) { @@ -1579,13 +1573,9 @@ export class VoiceAssistantWebSocketServer { } } - for (const { ws, state } of clientEntries) { - const shouldNotify = computeShouldNotifyClient({ - clientState: state, - allClientStates: allStates, - agentId: params.agentId, - }); - + for (const [clientIndex, { ws }] of clientEntries.entries()) { + const shouldNotify = clientIndex === plan.inAppRecipientIndex; + const timestamp = new Date().toISOString(); const message = wrapSessionMessage({ type: "agent_stream", payload: { @@ -1594,11 +1584,11 @@ export class VoiceAssistantWebSocketServer { type: "attention_required", provider: params.provider, reason: params.reason, - timestamp: new Date().toISOString(), + timestamp, shouldNotify, notification, }, - timestamp: new Date().toISOString(), + timestamp, }, });