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:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { useEffect, useRef, useCallback } from "react";
|
||||||
import { AppState } from "react-native";
|
import { AppState } from "react-native";
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
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 HEARTBEAT_INTERVAL_MS = 15_000;
|
||||||
const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000;
|
const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000;
|
||||||
|
const DESKTOP_IDLE_POLL_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
interface ClientActivityOptions {
|
interface ClientActivityOptions {
|
||||||
client: DaemonClient;
|
client: DaemonClient;
|
||||||
@@ -130,6 +132,31 @@ export function useClientActivity({
|
|||||||
};
|
};
|
||||||
}, [maybeSendImmediateHeartbeat, recordUserActivity, setAppVisible]);
|
}, [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
|
// Send heartbeat on focused agent change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prevFocusedAgentIdRef.current !== focusedAgentId) {
|
if (prevFocusedAgentIdRef.current !== focusedAgentId) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type ChildProcess } from "node:child_process";
|
import { type ChildProcess } from "node:child_process";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { app, ipcMain } from "electron";
|
import { app, ipcMain, powerMonitor } from "electron";
|
||||||
import log from "electron-log/main";
|
import log from "electron-log/main";
|
||||||
import { resolvePaseoHome, spawnProcess } from "@getpaseo/server";
|
import { resolvePaseoHome, spawnProcess } from "@getpaseo/server";
|
||||||
import {
|
import {
|
||||||
@@ -444,6 +444,10 @@ function resolveCurrentUpdateVersion(): string {
|
|||||||
return resolveDesktopAppVersion();
|
return resolveDesktopAppVersion();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSystemIdleTimeMs(): number {
|
||||||
|
return powerMonitor.getSystemIdleTime() * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// IPC registration
|
// IPC registration
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -456,6 +460,7 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
|
|||||||
restart_desktop_daemon: () => restartDaemon(),
|
restart_desktop_daemon: () => restartDaemon(),
|
||||||
desktop_daemon_logs: () => getDaemonLogs(),
|
desktop_daemon_logs: () => getDaemonLogs(),
|
||||||
desktop_daemon_pairing: () => getDaemonPairing(),
|
desktop_daemon_pairing: () => getDaemonPairing(),
|
||||||
|
desktop_get_system_idle_time: () => getSystemIdleTimeMs(),
|
||||||
cli_daemon_status: () => getCliDaemonStatus(),
|
cli_daemon_status: () => getCliDaemonStatus(),
|
||||||
write_attachment_base64: (args) => writeAttachmentBase64(args ?? {}),
|
write_attachment_base64: (args) => writeAttachmentBase64(args ?? {}),
|
||||||
copy_attachment_file: (args) => copyAttachmentFileToManagedStorage(args ?? {}),
|
copy_attachment_file: (args) => copyAttachmentFileToManagedStorage(args ?? {}),
|
||||||
|
|||||||
@@ -1,239 +1,173 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
computeShouldNotifyClient,
|
computeNotificationPlan,
|
||||||
computeShouldSendPush,
|
type ClientPresenceState,
|
||||||
type ClientAttentionState,
|
PRESENCE_THRESHOLD_MS,
|
||||||
} from "./agent-attention-policy.js";
|
} from "./agent-attention-policy.js";
|
||||||
|
|
||||||
function state(overrides: Partial<ClientAttentionState>): ClientAttentionState {
|
function state(overrides: Partial<ClientPresenceState>): ClientPresenceState {
|
||||||
return {
|
return {
|
||||||
deviceType: null,
|
|
||||||
focusedAgentId: null,
|
focusedAgentId: null,
|
||||||
isStale: false,
|
lastActivityAtMs: null,
|
||||||
appVisible: false,
|
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("computeShouldNotifyClient", () => {
|
describe("computeNotificationPlan", () => {
|
||||||
it("suppresses notifications when someone is actively focused on the agent", () => {
|
const nowMs = Date.parse("2026-04-19T12:00:00.000Z");
|
||||||
const activeOnAgent = state({
|
const staleAtMs = nowMs - PRESENCE_THRESHOLD_MS - 1;
|
||||||
deviceType: "web",
|
const presentAtMs = nowMs - PRESENCE_THRESHOLD_MS + 1;
|
||||||
|
|
||||||
|
it("does not suppress notifications when a focused client is stale", () => {
|
||||||
|
const staleFocused = state({
|
||||||
focusedAgentId: "agent-1",
|
focusedAgentId: "agent-1",
|
||||||
isStale: false,
|
lastActivityAtMs: staleAtMs,
|
||||||
appVisible: true,
|
|
||||||
});
|
|
||||||
const staleMobile = state({
|
|
||||||
deviceType: "mobile",
|
|
||||||
focusedAgentId: null,
|
|
||||||
isStale: true,
|
|
||||||
appVisible: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
computeShouldNotifyClient({
|
computeNotificationPlan({
|
||||||
clientState: staleMobile,
|
allStates: [staleFocused],
|
||||||
allClientStates: [activeOnAgent, staleMobile],
|
|
||||||
agentId: "agent-1",
|
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",
|
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({
|
state({
|
||||||
deviceType: "web",
|
focusedAgentId: "agent-2",
|
||||||
isStale: false,
|
lastActivityAtMs: nowMs - 1_000,
|
||||||
appVisible: true,
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
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(
|
expect(
|
||||||
computeShouldSendPush({
|
computeNotificationPlan({
|
||||||
|
allStates: [
|
||||||
|
state({ lastActivityAtMs: nowMs - 10_000 }),
|
||||||
|
state({ lastActivityAtMs: nowMs - 1_000 }),
|
||||||
|
state({ lastActivityAtMs: staleAtMs }),
|
||||||
|
],
|
||||||
|
agentId: "agent-1",
|
||||||
reason: "permission",
|
reason: "permission",
|
||||||
allClientStates: [
|
nowMs,
|
||||||
state({
|
|
||||||
deviceType: "mobile",
|
|
||||||
isStale: false,
|
|
||||||
appVisible: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
).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(
|
expect(
|
||||||
computeShouldSendPush({
|
computeNotificationPlan({
|
||||||
reason: "finished",
|
allStates: [
|
||||||
allClientStates: [
|
state({ lastActivityAtMs: nowMs - 1_000 }),
|
||||||
state({
|
state({ lastActivityAtMs: nowMs - 1_000 }),
|
||||||
deviceType: "mobile",
|
|
||||||
isStale: true,
|
|
||||||
appVisible: false,
|
|
||||||
}),
|
|
||||||
state({
|
|
||||||
deviceType: "web",
|
|
||||||
isStale: true,
|
|
||||||
appVisible: false,
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
|
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";
|
import type { AgentAttentionReason } from "../shared/agent-attention-notification.js";
|
||||||
|
|
||||||
export type ClientAttentionState = {
|
export const PRESENCE_THRESHOLD_MS = 180_000;
|
||||||
deviceType: "web" | "mobile" | null;
|
|
||||||
|
export interface ClientPresenceState {
|
||||||
|
lastActivityAtMs: number | null;
|
||||||
focusedAgentId: string | null;
|
focusedAgentId: string | null;
|
||||||
isStale: boolean;
|
}
|
||||||
appVisible: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ComputeClientNotificationInput = {
|
export interface NotificationPlan {
|
||||||
clientState: ClientAttentionState;
|
inAppRecipientIndex: number | null;
|
||||||
allClientStates: ClientAttentionState[];
|
shouldPush: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComputeNotificationPlanInput = {
|
||||||
|
allStates: ClientPresenceState[];
|
||||||
agentId: string;
|
agentId: string;
|
||||||
};
|
|
||||||
|
|
||||||
type ComputePushNotificationInput = {
|
|
||||||
reason: AgentAttentionReason;
|
reason: AgentAttentionReason;
|
||||||
allClientStates: ClientAttentionState[];
|
nowMs: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function hasActiveClientOnAgent(allClientStates: ClientAttentionState[], agentId: string): boolean {
|
export function computeNotificationPlan({
|
||||||
return allClientStates.some(
|
allStates,
|
||||||
(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,
|
|
||||||
agentId,
|
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,
|
reason,
|
||||||
allClientStates,
|
nowMs,
|
||||||
}: ComputePushNotificationInput): boolean {
|
}: ComputeNotificationPlanInput): NotificationPlan {
|
||||||
if (reason === "error") {
|
let mostRecentPresentIndex: number | null = null;
|
||||||
return false;
|
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 { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||||
import { DaemonClient } from "./test-utils/daemon-client.js";
|
import { DaemonClient } from "./test-utils/daemon-client.js";
|
||||||
import type { AgentStreamEventPayload } from "../shared/messages.js";
|
import type { AgentStreamEventPayload } from "../shared/messages.js";
|
||||||
import type { AgentSnapshotPayload } from "./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.
|
* 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.
|
* We want to notify them where they'll see it.
|
||||||
*
|
*
|
||||||
* Rules:
|
* Rules:
|
||||||
* 1. If user is actively looking at the agent (focused + visible + recent activity) → no notification
|
* 1. If a present client is focused on the agent → no notification
|
||||||
* 2. If user is on a device but looking elsewhere → notify on that device
|
* 2. Otherwise, notify the most recently active present client
|
||||||
* 3. If web is stale (>2min no activity) but mobile is connected → notify mobile, not web
|
* 3. If no client is present → send push for non-error attention
|
||||||
* 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)
|
|
||||||
*
|
*
|
||||||
* Heartbeat contains:
|
* Heartbeat contains:
|
||||||
* - deviceType: "web" | "mobile"
|
* - deviceType: "web" | "mobile"
|
||||||
@@ -30,8 +31,14 @@ describe("client activity tracking", () => {
|
|||||||
let daemon: TestPaseoDaemon;
|
let daemon: TestPaseoDaemon;
|
||||||
let client1: DaemonClient;
|
let client1: DaemonClient;
|
||||||
let client2: DaemonClient;
|
let client2: DaemonClient;
|
||||||
|
let sendPushSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let getAllTokensSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
sendPushSpy = vi.spyOn(PushService.prototype, "sendPush").mockResolvedValue(undefined);
|
||||||
|
getAllTokensSpy = vi
|
||||||
|
.spyOn(PushTokenStore.prototype, "getAllTokens")
|
||||||
|
.mockReturnValue(["ExponentPushToken[activity-test]"]);
|
||||||
daemon = await createTestPaseoDaemon();
|
daemon = await createTestPaseoDaemon();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,6 +46,8 @@ describe("client activity tracking", () => {
|
|||||||
if (client1) await client1.close().catch(() => {});
|
if (client1) await client1.close().catch(() => {});
|
||||||
if (client2) await client2.close().catch(() => {});
|
if (client2) await client2.close().catch(() => {});
|
||||||
await daemon.close();
|
await daemon.close();
|
||||||
|
sendPushSpy.mockRestore();
|
||||||
|
getAllTokensSpy.mockRestore();
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
async function createClient(): Promise<DaemonClient> {
|
async function createClient(): Promise<DaemonClient> {
|
||||||
@@ -143,7 +152,7 @@ describe("client activity tracking", () => {
|
|||||||
expect(attention.shouldNotify).toBe(true);
|
expect(attention.shouldNotify).toBe(true);
|
||||||
}, 120000);
|
}, 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();
|
client1 = await createClient();
|
||||||
|
|
||||||
const agent = await createAgent({
|
const agent = await createAgent({
|
||||||
@@ -151,7 +160,7 @@ describe("client activity tracking", () => {
|
|||||||
title: "App Hidden Test",
|
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({
|
client1.sendHeartbeat({
|
||||||
deviceType: "web",
|
deviceType: "web",
|
||||||
focusedAgentId: null, // null because app not visible
|
focusedAgentId: null, // null because app not visible
|
||||||
@@ -167,10 +176,10 @@ describe("client activity tracking", () => {
|
|||||||
const attention = await attentionPromise;
|
const attention = await attentionPromise;
|
||||||
|
|
||||||
expect(attention.reason).toBe("finished");
|
expect(attention.reason).toBe("finished");
|
||||||
expect(attention.shouldNotify).toBe(false);
|
expect(attention.shouldNotify).toBe(true);
|
||||||
}, 120000);
|
}, 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();
|
client1 = await createClient();
|
||||||
|
|
||||||
const agent = await createAgent({
|
const agent = await createAgent({
|
||||||
@@ -178,8 +187,8 @@ describe("client activity tracking", () => {
|
|||||||
title: "Stale Activity Test",
|
title: "Stale Activity Test",
|
||||||
});
|
});
|
||||||
|
|
||||||
// User had agent focused but no activity for 2+ minutes (stale threshold)
|
// User had agent focused but no activity beyond the presence threshold.
|
||||||
const staleTime = new Date(Date.now() - 125_000).toISOString(); // 2min 5sec ago
|
const staleTime = new Date(Date.now() - PRESENCE_THRESHOLD_MS - 5_000).toISOString();
|
||||||
client1.sendHeartbeat({
|
client1.sendHeartbeat({
|
||||||
deviceType: "web",
|
deviceType: "web",
|
||||||
focusedAgentId: agent.id,
|
focusedAgentId: agent.id,
|
||||||
@@ -195,7 +204,8 @@ describe("client activity tracking", () => {
|
|||||||
const attention = await attentionPromise;
|
const attention = await attentionPromise;
|
||||||
|
|
||||||
expect(attention.reason).toBe("finished");
|
expect(attention.reason).toBe("finished");
|
||||||
expect(attention.shouldNotify).toBe(true);
|
expect(attention.shouldNotify).toBe(false);
|
||||||
|
expect(sendPushSpy).toHaveBeenCalledTimes(1);
|
||||||
}, 120000);
|
}, 120000);
|
||||||
|
|
||||||
test("notification when no heartbeat received (legacy/new client)", async () => {
|
test("notification when no heartbeat received (legacy/new client)", async () => {
|
||||||
@@ -214,7 +224,8 @@ describe("client activity tracking", () => {
|
|||||||
const attention = await attentionPromise;
|
const attention = await attentionPromise;
|
||||||
|
|
||||||
expect(attention.reason).toBe("finished");
|
expect(attention.reason).toBe("finished");
|
||||||
expect(attention.shouldNotify).toBe(true);
|
expect(attention.shouldNotify).toBe(false);
|
||||||
|
expect(sendPushSpy).toHaveBeenCalledTimes(1);
|
||||||
}, 120000);
|
}, 120000);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -261,7 +272,7 @@ describe("client activity tracking", () => {
|
|||||||
expect(attention2.shouldNotify).toBe(false);
|
expect(attention2.shouldNotify).toBe(false);
|
||||||
}, 120000);
|
}, 120000);
|
||||||
|
|
||||||
test("both notify when both web clients are inactive", async () => {
|
test("pushes when both web clients are inactive", async () => {
|
||||||
client1 = await createClient();
|
client1 = await createClient();
|
||||||
client2 = await createClient();
|
client2 = await createClient();
|
||||||
|
|
||||||
@@ -270,7 +281,7 @@ describe("client activity tracking", () => {
|
|||||||
title: "Both Inactive Test",
|
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
|
// Both clients stale
|
||||||
client1.sendHeartbeat({
|
client1.sendHeartbeat({
|
||||||
@@ -295,9 +306,46 @@ describe("client activity tracking", () => {
|
|||||||
|
|
||||||
const [attention1, attention2] = await Promise.all([attention1Promise, attention2Promise]);
|
const [attention1, attention2] = await Promise.all([attention1Promise, attention2Promise]);
|
||||||
|
|
||||||
// Both should notify - no one is watching
|
// No stale client is selected for in-app; push handles the fallback.
|
||||||
expect(attention1.shouldNotify).toBe(true);
|
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(attention2.shouldNotify).toBe(true);
|
||||||
|
expect(sendPushSpy).not.toHaveBeenCalled();
|
||||||
}, 120000);
|
}, 120000);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -327,7 +375,7 @@ describe("client activity tracking", () => {
|
|||||||
client2.sendHeartbeat({
|
client2.sendHeartbeat({
|
||||||
deviceType: "mobile",
|
deviceType: "mobile",
|
||||||
focusedAgentId: null,
|
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,
|
appVisible: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -357,7 +405,7 @@ describe("client activity tracking", () => {
|
|||||||
client1.sendHeartbeat({
|
client1.sendHeartbeat({
|
||||||
deviceType: "web",
|
deviceType: "web",
|
||||||
focusedAgentId: null,
|
focusedAgentId: null,
|
||||||
lastActivityAt: new Date(Date.now() - 120_000).toISOString(),
|
lastActivityAt: new Date(Date.now() - PRESENCE_THRESHOLD_MS - 5_000).toISOString(),
|
||||||
appVisible: false,
|
appVisible: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -382,7 +430,7 @@ describe("client activity tracking", () => {
|
|||||||
expect(attention2.shouldNotify).toBe(false);
|
expect(attention2.shouldNotify).toBe(false);
|
||||||
}, 120000);
|
}, 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
|
client1 = await createClient(); // web
|
||||||
client2 = await createClient(); // mobile
|
client2 = await createClient(); // mobile
|
||||||
|
|
||||||
@@ -395,15 +443,15 @@ describe("client activity tracking", () => {
|
|||||||
client1.sendHeartbeat({
|
client1.sendHeartbeat({
|
||||||
deviceType: "web",
|
deviceType: "web",
|
||||||
focusedAgentId: agent.id, // was looking at agent
|
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,
|
appVisible: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mobile: connected but not active (phone in pocket)
|
// Mobile: present but not focused on the agent
|
||||||
client2.sendHeartbeat({
|
client2.sendHeartbeat({
|
||||||
deviceType: "mobile",
|
deviceType: "mobile",
|
||||||
focusedAgentId: null,
|
focusedAgentId: null,
|
||||||
lastActivityAt: new Date(Date.now() - 300_000).toISOString(), // 5 min ago
|
lastActivityAt: new Date().toISOString(),
|
||||||
appVisible: false,
|
appVisible: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -415,9 +463,9 @@ describe("client activity tracking", () => {
|
|||||||
|
|
||||||
const [attention1, attention2] = await Promise.all([attention1Promise, attention2Promise]);
|
const [attention1, attention2] = await Promise.all([attention1Promise, attention2Promise]);
|
||||||
|
|
||||||
// Web stale → don't notify web, notify mobile instead
|
|
||||||
expect(attention1.shouldNotify).toBe(false);
|
expect(attention1.shouldNotify).toBe(false);
|
||||||
expect(attention2.shouldNotify).toBe(true);
|
expect(attention2.shouldNotify).toBe(true);
|
||||||
|
expect(sendPushSpy).not.toHaveBeenCalled();
|
||||||
}, 120000);
|
}, 120000);
|
||||||
|
|
||||||
test("notify web when user active on web but looking at different agent", async () => {
|
test("notify web when user active on web but looking at different agent", async () => {
|
||||||
@@ -440,7 +488,7 @@ describe("client activity tracking", () => {
|
|||||||
client2.sendHeartbeat({
|
client2.sendHeartbeat({
|
||||||
deviceType: "mobile",
|
deviceType: "mobile",
|
||||||
focusedAgentId: null,
|
focusedAgentId: null,
|
||||||
lastActivityAt: new Date(Date.now() - 300_000).toISOString(),
|
lastActivityAt: new Date(Date.now() - PRESENCE_THRESHOLD_MS - 60_000).toISOString(),
|
||||||
appVisible: false,
|
appVisible: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -458,7 +506,7 @@ describe("client activity tracking", () => {
|
|||||||
expect(attention2.shouldNotify).toBe(false);
|
expect(attention2.shouldNotify).toBe(false);
|
||||||
}, 120000);
|
}, 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
|
client1 = await createClient(); // web
|
||||||
client2 = await createClient(); // mobile
|
client2 = await createClient(); // mobile
|
||||||
|
|
||||||
@@ -467,7 +515,7 @@ describe("client activity tracking", () => {
|
|||||||
title: "Both Inactive Test",
|
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
|
// Web: stale
|
||||||
client1.sendHeartbeat({
|
client1.sendHeartbeat({
|
||||||
@@ -493,10 +541,9 @@ describe("client activity tracking", () => {
|
|||||||
|
|
||||||
const [attention1, attention2] = await Promise.all([attention1Promise, attention2Promise]);
|
const [attention1, attention2] = await Promise.all([attention1Promise, attention2Promise]);
|
||||||
|
|
||||||
// Mobile always notifies when no one is watching
|
expect(attention1.shouldNotify).toBe(false);
|
||||||
// Web is stale so don't bother notifying there
|
expect(attention2.shouldNotify).toBe(false);
|
||||||
expect(attention1.shouldNotify).toBe(false); // web stale
|
expect(sendPushSpy).toHaveBeenCalledTimes(1);
|
||||||
expect(attention2.shouldNotify).toBe(true); // mobile fallback
|
|
||||||
}, 120000);
|
}, 120000);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -505,7 +552,7 @@ describe("client activity tracking", () => {
|
|||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
describe("edge cases", () => {
|
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
|
client1 = await createClient(); // web
|
||||||
client2 = await createClient(); // mobile - no heartbeat
|
client2 = await createClient(); // mobile - no heartbeat
|
||||||
|
|
||||||
@@ -518,12 +565,11 @@ describe("client activity tracking", () => {
|
|||||||
client1.sendHeartbeat({
|
client1.sendHeartbeat({
|
||||||
deviceType: "web",
|
deviceType: "web",
|
||||||
focusedAgentId: agent.id,
|
focusedAgentId: agent.id,
|
||||||
lastActivityAt: new Date(Date.now() - 120_000).toISOString(),
|
lastActivityAt: new Date(Date.now() - PRESENCE_THRESHOLD_MS - 5_000).toISOString(),
|
||||||
appVisible: true,
|
appVisible: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mobile: never sent heartbeat (new connection)
|
// Mobile: never sent heartbeat (new connection)
|
||||||
// Should still receive notification as fallback
|
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
|
||||||
@@ -533,13 +579,12 @@ describe("client activity tracking", () => {
|
|||||||
|
|
||||||
const [attention1, attention2] = await Promise.all([attention1Promise, attention2Promise]);
|
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(attention1.shouldNotify).toBe(false);
|
||||||
expect(attention2.shouldNotify).toBe(true);
|
expect(attention2.shouldNotify).toBe(false);
|
||||||
|
expect(sendPushSpy).toHaveBeenCalledTimes(1);
|
||||||
}, 120000);
|
}, 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();
|
client1 = await createClient();
|
||||||
|
|
||||||
const agent = await createAgent({
|
const agent = await createAgent({
|
||||||
@@ -547,7 +592,7 @@ describe("client activity tracking", () => {
|
|||||||
title: "Tab Switch Test",
|
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({
|
client1.sendHeartbeat({
|
||||||
deviceType: "web",
|
deviceType: "web",
|
||||||
focusedAgentId: null, // not focused - tab hidden
|
focusedAgentId: null, // not focused - tab hidden
|
||||||
@@ -562,11 +607,10 @@ describe("client activity tracking", () => {
|
|||||||
|
|
||||||
const attention = await attentionPromise;
|
const attention = await attentionPromise;
|
||||||
|
|
||||||
// User is at the computer (recent activity) - no notification needed
|
expect(attention.shouldNotify).toBe(true);
|
||||||
expect(attention.shouldNotify).toBe(false);
|
|
||||||
}, 120000);
|
}, 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
|
client1 = await createClient(); // web
|
||||||
client2 = await createClient(); // mobile
|
client2 = await createClient(); // mobile
|
||||||
|
|
||||||
@@ -575,11 +619,13 @@ describe("client activity tracking", () => {
|
|||||||
title: "Both Recent Activity Test",
|
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({
|
client1.sendHeartbeat({
|
||||||
deviceType: "web",
|
deviceType: "web",
|
||||||
focusedAgentId: null,
|
focusedAgentId: null,
|
||||||
lastActivityAt: new Date(Date.now() - 30_000).toISOString(), // 30s ago
|
lastActivityAt: recentTime,
|
||||||
appVisible: false,
|
appVisible: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -587,7 +633,7 @@ describe("client activity tracking", () => {
|
|||||||
client2.sendHeartbeat({
|
client2.sendHeartbeat({
|
||||||
deviceType: "mobile",
|
deviceType: "mobile",
|
||||||
focusedAgentId: null,
|
focusedAgentId: null,
|
||||||
lastActivityAt: new Date(Date.now() - 30_000).toISOString(),
|
lastActivityAt: recentTime,
|
||||||
appVisible: false,
|
appVisible: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -599,9 +645,9 @@ describe("client activity tracking", () => {
|
|||||||
|
|
||||||
const [attention1, attention2] = await Promise.all([attention1Promise, attention2Promise]);
|
const [attention1, attention2] = await Promise.all([attention1Promise, attention2Promise]);
|
||||||
|
|
||||||
// Both have recent activity - user is present, no notification needed
|
expect(attention1.shouldNotify).toBe(true);
|
||||||
expect(attention1.shouldNotify).toBe(false);
|
|
||||||
expect(attention2.shouldNotify).toBe(false);
|
expect(attention2.shouldNotify).toBe(false);
|
||||||
|
expect(sendPushSpy).not.toHaveBeenCalled();
|
||||||
}, 120000);
|
}, 120000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -109,6 +109,62 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
|
|||||||
return { server, agentManager };
|
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", () => {
|
describe("VoiceAssistantWebSocketServer notification payloads", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -166,4 +222,59 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
|
|||||||
expect(pushMocks.sendPush).toHaveBeenCalledTimes(1);
|
expect(pushMocks.sendPush).toHaveBeenCalledTimes(1);
|
||||||
expect(getLastAssistantMessage).toHaveBeenCalledWith("agent-2");
|
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 { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||||
import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js";
|
import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js";
|
||||||
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
|
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
|
||||||
import {
|
import { computeNotificationPlan, type ClientPresenceState } from "./agent-attention-policy.js";
|
||||||
computeShouldNotifyClient,
|
|
||||||
computeShouldSendPush,
|
|
||||||
type ClientAttentionState,
|
|
||||||
} from "./agent-attention-policy.js";
|
|
||||||
import {
|
import {
|
||||||
buildAgentAttentionNotificationPayload,
|
buildAgentAttentionNotificationPayload,
|
||||||
findLatestPermissionRequest,
|
findLatestPermissionRequest,
|
||||||
@@ -1319,8 +1315,6 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly ACTIVITY_THRESHOLD_MS = 120_000;
|
|
||||||
|
|
||||||
private incrementRuntimeCounter(counter: keyof WebSocketRuntimeCounters): void {
|
private incrementRuntimeCounter(counter: keyof WebSocketRuntimeCounters): void {
|
||||||
this.runtimeCounters[counter] += 1;
|
this.runtimeCounters[counter] += 1;
|
||||||
}
|
}
|
||||||
@@ -1520,19 +1514,18 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
this.runtimeWindowStartedAt = now;
|
this.runtimeWindowStartedAt = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getClientActivityState(session: Session): ClientAttentionState {
|
private getClientActivityState(session: Session): ClientPresenceState {
|
||||||
const activity = session.getClientActivity();
|
const activity = session.getClientActivity();
|
||||||
if (!activity) {
|
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 {
|
return {
|
||||||
deviceType: activity.deviceType,
|
|
||||||
focusedAgentId: activity.focusedAgentId,
|
focusedAgentId: activity.focusedAgentId,
|
||||||
isStale,
|
lastActivityAtMs: activity.lastActivityAt.getTime(),
|
||||||
appVisible: activity.appVisible,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1543,7 +1536,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const clientEntries: Array<{
|
const clientEntries: Array<{
|
||||||
ws: WebSocketLike;
|
ws: WebSocketLike;
|
||||||
state: ClientAttentionState;
|
state: ClientPresenceState;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
for (const [ws, connection] of this.sessions) {
|
for (const [ws, connection] of this.sessions) {
|
||||||
@@ -1554,6 +1547,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allStates = clientEntries.map((e) => e.state);
|
const allStates = clientEntries.map((e) => e.state);
|
||||||
|
const nowMs = Date.now();
|
||||||
const agent = this.agentManager.getAgent(params.agentId);
|
const agent = this.agentManager.getAgent(params.agentId);
|
||||||
const assistantMessage = await this.agentManager.getLastAssistantMessage(params.agentId);
|
const assistantMessage = await this.agentManager.getLastAssistantMessage(params.agentId);
|
||||||
const notification = buildAgentAttentionNotificationPayload({
|
const notification = buildAgentAttentionNotificationPayload({
|
||||||
@@ -1564,14 +1558,14 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
permissionRequest: agent ? findLatestPermissionRequest(agent.pendingPermissions) : null,
|
permissionRequest: agent ? findLatestPermissionRequest(agent.pendingPermissions) : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Push is only a fallback when the user is away from desktop/web.
|
const plan = computeNotificationPlan({
|
||||||
// Also suppress push if they're actively using the mobile app.
|
allStates,
|
||||||
const shouldSendPush = computeShouldSendPush({
|
agentId: params.agentId,
|
||||||
reason: params.reason,
|
reason: params.reason,
|
||||||
allClientStates: allStates,
|
nowMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (shouldSendPush) {
|
if (plan.shouldPush) {
|
||||||
const tokens = this.pushTokenStore.getAllTokens();
|
const tokens = this.pushTokenStore.getAllTokens();
|
||||||
this.logger.info({ tokenCount: tokens.length }, "Sending push notification");
|
this.logger.info({ tokenCount: tokens.length }, "Sending push notification");
|
||||||
if (tokens.length > 0) {
|
if (tokens.length > 0) {
|
||||||
@@ -1579,13 +1573,9 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const { ws, state } of clientEntries) {
|
for (const [clientIndex, { ws }] of clientEntries.entries()) {
|
||||||
const shouldNotify = computeShouldNotifyClient({
|
const shouldNotify = clientIndex === plan.inAppRecipientIndex;
|
||||||
clientState: state,
|
const timestamp = new Date().toISOString();
|
||||||
allClientStates: allStates,
|
|
||||||
agentId: params.agentId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const message = wrapSessionMessage({
|
const message = wrapSessionMessage({
|
||||||
type: "agent_stream",
|
type: "agent_stream",
|
||||||
payload: {
|
payload: {
|
||||||
@@ -1594,11 +1584,11 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
type: "attention_required",
|
type: "attention_required",
|
||||||
provider: params.provider,
|
provider: params.provider,
|
||||||
reason: params.reason,
|
reason: params.reason,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp,
|
||||||
shouldNotify,
|
shouldNotify,
|
||||||
notification,
|
notification,
|
||||||
},
|
},
|
||||||
timestamp: new Date().toISOString(),
|
timestamp,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user