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:
@@ -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