fix(server,app,desktop): route in-app notifications by presence cascade

Replaces the per-client stale/visibility policy with one shared
presence cascade so a backgrounded Firefox tab can no longer steal
in-app notifications from an active Electron desktop window.

Server (`agent-attention-policy.ts`): exports `PRESENCE_THRESHOLD_MS`
(3 min) and `computeNotificationPlan({ allStates, agentId, reason,
nowMs })` returning `{ inAppRecipientIndex, shouldPush }`. Cascade:
focus suppression requires presence; otherwise the most-recent
present client wins (lower-index tiebreak); push fires only when no
client is present, and never on `error`. `broadcastAgentAttention`
calls the policy once per event.

Electron (`daemon-manager.ts`, `idle.ts`): registers
`desktop_get_system_idle_time` returning `powerMonitor
.getSystemIdleTime() * 1000`. Renderer wrapper
`getDesktopSystemIdleTimeMs()` is best-effort: rejected IPC, null,
NaN, Infinity, or negative values return null without throwing.

Hook (`use-client-activity.ts`): Electron-only effect polls every 5s
and updates `lastActivityAtRef` only forward, so OS-wide activity
keeps a backgrounded Electron window present while in-window events
still update immediately.

`ClientHeartbeatMessageSchema` is unchanged; old/new clients and
daemons remain wire-compatible.

Tests: 52 focused tests across policy unit, dispatch, e2e, hook,
desktop wrapper, and permissions. Phase-3 dispatch test +
Phase-5 stale-poll test verified by mutation (breaking the focus
check / the `>` guard makes the exact regression fail).
This commit is contained in:
Mohamed Boudra
2026-04-19 21:48:39 +07:00
parent 128d6668ae
commit 06b3fc2ec6
10 changed files with 759 additions and 362 deletions

View File

@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const { invokeDesktopCommandMock } = vi.hoisted(() => ({
invokeDesktopCommandMock: vi.fn<() => Promise<unknown>>(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);
});
});

View File

@@ -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<number | null> {
try {
const idleTimeMs = await invokeDesktopCommand<unknown>(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;
}
}

View File

@@ -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<number | null>>(),
}));
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<typeof createTestClient>): HeartbeatPayload {
const call = client.sendHeartbeat.mock.calls.at(-1);
if (!call) {
throw new Error("Expected a heartbeat");
}
return call[0];
}
function heartbeatTimeMs(client: ReturnType<typeof createTestClient>): number {
return new Date(latestHeartbeat(client).lastActivityAt).getTime();
}
async function renderActivityHook({
client = createTestClient(),
}: {
client?: ReturnType<typeof createTestClient>;
} = {}) {
function Probe() {
useClientActivity({
client: client as unknown as Parameters<typeof useClientActivity>[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(<Probe />);
});
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("<!doctype html><html><body></body></html>", {
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);
});
});

View File

@@ -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) {

View File

@@ -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<string, DesktopCommandHand
restart_desktop_daemon: () => 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 ?? {}),

View File

@@ -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>): ClientAttentionState {
function state(overrides: Partial<ClientPresenceState>): 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 });
});
});

View File

@@ -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" };
}

View File

@@ -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<typeof vi.spyOn>;
let getAllTokensSpy: ReturnType<typeof vi.spyOn>;
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<DaemonClient> {
@@ -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);
});
});

View File

@@ -109,6 +109,62 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
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<typeof createOpenSocket>) {
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();
});
});

View File

@@ -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<void> {
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,
},
});