mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
80
packages/app/src/desktop/electron/idle.test.ts
Normal file
80
packages/app/src/desktop/electron/idle.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
21
packages/app/src/desktop/electron/idle.ts
Normal file
21
packages/app/src/desktop/electron/idle.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
215
packages/app/src/hooks/use-client-activity.test.tsx
Normal file
215
packages/app/src/hooks/use-client-activity.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ?? {}),
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user