@vellumai/assistant 0.11.5-dev.202608232107.2bf6d61 → 0.11.5-dev.202608240233.8a9625a
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/client-os-metadata-persistence.test.ts +23 -10
- package/src/__tests__/conversation-seed-composer.test.ts +8 -0
- package/src/__tests__/conversation-slash-commands.test.ts +8 -0
- package/src/__tests__/disk-pressure-policy.test.ts +6 -0
- package/src/config/bundled-skills/schedule/SKILL.md +1 -1
- package/src/config/feature-flag-registry.json +8 -0
- package/src/daemon/conversation-slash.ts +6 -15
- package/src/daemon/disk-pressure-policy.ts +7 -1
- package/src/notifications/__tests__/assistant-reply-producer.test.ts +30 -7
- package/src/notifications/assistant-reply-producer.ts +7 -7
- package/src/notifications/conversation-seed-composer.ts +7 -2
- package/src/persistence/conversation-types.ts +11 -9
- package/src/runtime/__tests__/desktop-presence.test.ts +27 -4
- package/src/runtime/desktop-presence.ts +24 -21
package/package.json
CHANGED
|
@@ -65,7 +65,7 @@ import type {
|
|
|
65
65
|
import type { MessagingConversationContext } from "../daemon/conversation-messaging.js";
|
|
66
66
|
import { persistQueuedMessageBody } from "../daemon/conversation-messaging.js";
|
|
67
67
|
import type { MessageQueue } from "../daemon/conversation-queue-manager.js";
|
|
68
|
-
import {
|
|
68
|
+
import { isDesktopOriginatedUserMessage } from "../persistence/conversation-types.js";
|
|
69
69
|
|
|
70
70
|
function createWebTurnContext(
|
|
71
71
|
clientOs: string | undefined,
|
|
@@ -179,9 +179,9 @@ describe("client OS surface metadata persistence", () => {
|
|
|
179
179
|
* `Conversation.clientOs` is a live field that only a transport-carrying
|
|
180
180
|
* message refreshes, so a transport-less turn stamps whatever an earlier send
|
|
181
181
|
* left there. `clientOsFromRequest` separates the two, and
|
|
182
|
-
* {@link
|
|
182
|
+
* {@link isDesktopOriginatedUserMessage} (the row-origin read behind the
|
|
183
183
|
* `chat.assistant_reply` presence gate) requires it before letting an attended
|
|
184
|
-
*
|
|
184
|
+
* desktop suppress a push.
|
|
185
185
|
*/
|
|
186
186
|
describe("client OS per-row evidence marker", () => {
|
|
187
187
|
beforeEach(() => {
|
|
@@ -198,7 +198,20 @@ describe("client OS per-row evidence marker", () => {
|
|
|
198
198
|
|
|
199
199
|
expect(lastUserMetadata().client).toEqual({ os: "macos" });
|
|
200
200
|
expect(lastUserMetadata().clientOsFromRequest).toBe(true);
|
|
201
|
-
expect(
|
|
201
|
+
expect(isDesktopOriginatedUserMessage(lastUserMetadata())).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("recognizes a Windows app turn as desktop-originated", async () => {
|
|
205
|
+
const ctx = createWebTurnContext("windows");
|
|
206
|
+
await persistQueuedMessageBody(ctx, {
|
|
207
|
+
content: "hello",
|
|
208
|
+
requestId: "req-transport-os-windows",
|
|
209
|
+
requestClientOs: "windows",
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(lastUserMetadata().client).toEqual({ os: "windows" });
|
|
213
|
+
expect(lastUserMetadata().clientOsFromRequest).toBe(true);
|
|
214
|
+
expect(isDesktopOriginatedUserMessage(lastUserMetadata())).toBe(true);
|
|
202
215
|
});
|
|
203
216
|
|
|
204
217
|
// Mirrors the Mac-originated case above: a plain browser tab reports
|
|
@@ -214,7 +227,7 @@ describe("client OS per-row evidence marker", () => {
|
|
|
214
227
|
|
|
215
228
|
expect(lastUserMetadata().client).toEqual({ os: "web" });
|
|
216
229
|
expect(lastUserMetadata().clientOsFromRequest).toBe(true);
|
|
217
|
-
expect(
|
|
230
|
+
expect(isDesktopOriginatedUserMessage(lastUserMetadata())).toBe(false);
|
|
218
231
|
});
|
|
219
232
|
|
|
220
233
|
test("marks an OS this row's own request headers reported", async () => {
|
|
@@ -226,14 +239,14 @@ describe("client OS per-row evidence marker", () => {
|
|
|
226
239
|
});
|
|
227
240
|
|
|
228
241
|
expect(lastUserMetadata().clientOsFromRequest).toBe(true);
|
|
229
|
-
expect(
|
|
242
|
+
expect(isDesktopOriginatedUserMessage(lastUserMetadata())).toBe(true);
|
|
230
243
|
});
|
|
231
244
|
|
|
232
245
|
// The bug shape: a button tapped on the phone reaches a conversation whose
|
|
233
246
|
// last desktop send left `clientOs: "macos"` behind. The row still carries
|
|
234
247
|
// the inherited OS for telemetry, but nothing says this turn came from the
|
|
235
248
|
// Mac, so the reply push must survive.
|
|
236
|
-
test("leaves an inherited OS unmarked and not
|
|
249
|
+
test("leaves an inherited OS unmarked and not desktop-originated", async () => {
|
|
237
250
|
const ctx = createWebTurnContext("macos");
|
|
238
251
|
await persistQueuedMessageBody(ctx, {
|
|
239
252
|
content: "[User action on card surface: submit]",
|
|
@@ -242,7 +255,7 @@ describe("client OS per-row evidence marker", () => {
|
|
|
242
255
|
|
|
243
256
|
expect(lastUserMetadata().client).toEqual({ os: "macos" });
|
|
244
257
|
expect(lastUserMetadata().clientOsFromRequest).toBeUndefined();
|
|
245
|
-
expect(
|
|
258
|
+
expect(isDesktopOriginatedUserMessage(lastUserMetadata())).toBe(false);
|
|
246
259
|
});
|
|
247
260
|
|
|
248
261
|
// The marker vouches for the `client.os` that actually landed, not for
|
|
@@ -258,7 +271,7 @@ describe("client OS per-row evidence marker", () => {
|
|
|
258
271
|
|
|
259
272
|
expect(lastUserMetadata().client).toEqual({ os: "macos" });
|
|
260
273
|
expect(lastUserMetadata().clientOsFromRequest).toBeUndefined();
|
|
261
|
-
expect(
|
|
274
|
+
expect(isDesktopOriginatedUserMessage(lastUserMetadata())).toBe(false);
|
|
262
275
|
});
|
|
263
276
|
|
|
264
277
|
// The marker is derived from what this persist can see, so a bag value
|
|
@@ -273,7 +286,7 @@ describe("client OS per-row evidence marker", () => {
|
|
|
273
286
|
});
|
|
274
287
|
|
|
275
288
|
expect(lastUserMetadata().clientOsFromRequest).toBeUndefined();
|
|
276
|
-
expect(
|
|
289
|
+
expect(isDesktopOriginatedUserMessage(lastUserMetadata())).toBe(false);
|
|
277
290
|
});
|
|
278
291
|
|
|
279
292
|
test("leaves the marker off for a reported OS outside the vocabulary", async () => {
|
|
@@ -71,6 +71,14 @@ describe("resolveVerbosity", () => {
|
|
|
71
71
|
).toBe("rich");
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
test("explicit interfaceHint=windows overrides to rich", () => {
|
|
75
|
+
expect(
|
|
76
|
+
resolveVerbosity("telegram" as NotificationChannel, {
|
|
77
|
+
interfaceHint: "windows",
|
|
78
|
+
}),
|
|
79
|
+
).toBe("rich");
|
|
80
|
+
});
|
|
81
|
+
|
|
74
82
|
test("explicit interfaceHint=telegram overrides to compact", () => {
|
|
75
83
|
expect(
|
|
76
84
|
resolveVerbosity("vellum" as NotificationChannel, {
|
|
@@ -49,6 +49,14 @@ describe("resolveSlash /commands interface-aware help", () => {
|
|
|
49
49
|
]);
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
+
test("renders desktop command help for Windows", async () => {
|
|
53
|
+
const lines = await resolveCommandsLines(
|
|
54
|
+
makeSlashContext({ userMessageInterface: "windows" }),
|
|
55
|
+
);
|
|
56
|
+
expect(lines.some((line) => line.startsWith("/btw "))).toBe(true);
|
|
57
|
+
expect(lines.some((line) => line.startsWith("/fork "))).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
52
60
|
test("renders iOS command help with /fork", async () => {
|
|
53
61
|
const lines = await resolveCommandsLines(
|
|
54
62
|
makeSlashContext({ userMessageInterface: "ios" }),
|
|
@@ -90,6 +90,12 @@ describe("classifyDiskPressureTurnPolicy", () => {
|
|
|
90
90
|
metadata: localOwnerTurn,
|
|
91
91
|
expected: { action: "allow-cleanup-mode", reason: "local-owner" },
|
|
92
92
|
},
|
|
93
|
+
{
|
|
94
|
+
name: "locked Windows local owner without trust enters cleanup mode",
|
|
95
|
+
status: status({ acknowledged: true }),
|
|
96
|
+
metadata: { ...localOwnerTurn, sourceInterface: "windows" },
|
|
97
|
+
expected: { action: "allow-cleanup-mode", reason: "local-owner" },
|
|
98
|
+
},
|
|
93
99
|
{
|
|
94
100
|
name: "locked guardian enters cleanup mode",
|
|
95
101
|
status: status(),
|
|
@@ -158,7 +158,7 @@ Optionally pass `routing_hints` (a JSON object) to influence routing decisions (
|
|
|
158
158
|
2. **`interface` fallback** — if `source_channel` is absent (common for guardian/direct users), map the `interface` value to a channel name:
|
|
159
159
|
| `interface` value | Channel name |
|
|
160
160
|
| --- | --- |
|
|
161
|
-
| `macos`, `ios` | `vellum` |
|
|
161
|
+
| `macos`, `windows`, `ios` | `vellum` |
|
|
162
162
|
| `telegram` | `telegram` |
|
|
163
163
|
| `slack` | `slack` |
|
|
164
164
|
| `cli` | _(omit — no routable channel)_ |
|
|
@@ -108,6 +108,14 @@
|
|
|
108
108
|
"description": "Auto-expand completed tool call step groups instead of showing them collapsed",
|
|
109
109
|
"defaultEnabled": false
|
|
110
110
|
},
|
|
111
|
+
{
|
|
112
|
+
"id": "inline-assistant-intermediates",
|
|
113
|
+
"scope": "client",
|
|
114
|
+
"key": "inline-assistant-intermediates",
|
|
115
|
+
"label": "Inline Assistant Intermediates",
|
|
116
|
+
"description": "Render a completed response's intermediate activity inline at full size instead of collapsing it behind the Earlier activity disclosure",
|
|
117
|
+
"defaultEnabled": false
|
|
118
|
+
},
|
|
111
119
|
{
|
|
112
120
|
"id": "backward-releases",
|
|
113
121
|
"scope": "assistant",
|
|
@@ -295,6 +295,11 @@ function resolveStatusCommand(context: SlashContext): SlashResolution {
|
|
|
295
295
|
|
|
296
296
|
const CLEAN_HELP_LINE =
|
|
297
297
|
"/clean — Strip injected runtime context and reset memory injection state (no summarization)";
|
|
298
|
+
const FORK_CAPABLE_INTERFACES = new Set<InterfaceId>([
|
|
299
|
+
"macos",
|
|
300
|
+
"windows",
|
|
301
|
+
"ios",
|
|
302
|
+
]);
|
|
298
303
|
|
|
299
304
|
function resolveCommandsList(context?: SlashContext): string[] {
|
|
300
305
|
const fallbackLines = [
|
|
@@ -315,21 +320,7 @@ function resolveCommandsList(context?: SlashContext): string[] {
|
|
|
315
320
|
return fallbackLines;
|
|
316
321
|
}
|
|
317
322
|
|
|
318
|
-
if (context.userMessageInterface
|
|
319
|
-
return [
|
|
320
|
-
"/commands — List all available commands",
|
|
321
|
-
"/compact — Force context compaction immediately",
|
|
322
|
-
CLEAN_HELP_LINE,
|
|
323
|
-
"/context — Show conversation context usage",
|
|
324
|
-
"/model — List or switch inference profile",
|
|
325
|
-
"/models — List all available models",
|
|
326
|
-
"/status — Show conversation status and context usage",
|
|
327
|
-
"/btw — Ask a side question while the assistant is working",
|
|
328
|
-
"/fork — Fork the current conversation into a new branch",
|
|
329
|
-
];
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
if (context.userMessageInterface === "ios") {
|
|
323
|
+
if (FORK_CAPABLE_INTERFACES.has(context.userMessageInterface)) {
|
|
333
324
|
return [
|
|
334
325
|
"/commands — List all available commands",
|
|
335
326
|
"/compact — Force context compaction immediately",
|
|
@@ -57,7 +57,13 @@ const BACKGROUND_SOURCES = new Set([
|
|
|
57
57
|
"schedule",
|
|
58
58
|
"task",
|
|
59
59
|
]);
|
|
60
|
-
const LOCAL_OWNER_INTERFACES = new Set([
|
|
60
|
+
const LOCAL_OWNER_INTERFACES = new Set([
|
|
61
|
+
"macos",
|
|
62
|
+
"windows",
|
|
63
|
+
"web",
|
|
64
|
+
"vellum",
|
|
65
|
+
"cli",
|
|
66
|
+
]);
|
|
61
67
|
|
|
62
68
|
export function classifyDiskPressureTurnPolicy(
|
|
63
69
|
status: DiskPressureStatus,
|
|
@@ -200,6 +200,17 @@ function makeMacOriginatedMessage(): MessageRow {
|
|
|
200
200
|
});
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
function makeWindowsOriginatedMessage(): MessageRow {
|
|
204
|
+
return makeMessage({
|
|
205
|
+
metadata: JSON.stringify({
|
|
206
|
+
userMessageChannel: "vellum",
|
|
207
|
+
userMessageInterface: "web",
|
|
208
|
+
client: { os: "windows" },
|
|
209
|
+
clientOsFromRequest: true,
|
|
210
|
+
}),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
203
214
|
/**
|
|
204
215
|
* A turn opened from a plain browser tab, as opposed to the Electron desktop
|
|
205
216
|
* renderer sharing the same web bundle: `client.os` is `"web"` only when
|
|
@@ -347,7 +358,7 @@ describe("emitAssistantReplyNotification", () => {
|
|
|
347
358
|
desktopAttended = true;
|
|
348
359
|
});
|
|
349
360
|
|
|
350
|
-
test("marks the signal source-active while a
|
|
361
|
+
test("marks the signal source-active while a desktop reports itself attended", async () => {
|
|
351
362
|
await run();
|
|
352
363
|
|
|
353
364
|
expect(emitCalls).toHaveLength(1);
|
|
@@ -357,7 +368,16 @@ describe("emitAssistantReplyNotification", () => {
|
|
|
357
368
|
expect(desktopPresenceArgs).toEqual([[]]);
|
|
358
369
|
});
|
|
359
370
|
|
|
360
|
-
test("
|
|
371
|
+
test("marks a Windows-originated signal source-active while attended", async () => {
|
|
372
|
+
initiatingRow = makeWindowsOriginatedMessage();
|
|
373
|
+
|
|
374
|
+
await run();
|
|
375
|
+
|
|
376
|
+
expect(emitCalls).toHaveLength(1);
|
|
377
|
+
expect(emitCalls[0].attentionHints.visibleInSourceNow).toBe(true);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("leaves the signal live when no desktop is attended", async () => {
|
|
361
381
|
desktopAttended = false;
|
|
362
382
|
|
|
363
383
|
await run();
|
|
@@ -386,10 +406,13 @@ describe("emitAssistantReplyNotification", () => {
|
|
|
386
406
|
expect(warnCalls).toHaveLength(1);
|
|
387
407
|
});
|
|
388
408
|
|
|
389
|
-
// An attended
|
|
390
|
-
// can send from the phone minutes after last touching the keyboard,
|
|
409
|
+
// An attended desktop only speaks for a turn that desktop itself opened.
|
|
410
|
+
// The user can send from the phone minutes after last touching the keyboard,
|
|
391
411
|
// is exactly the reply this producer exists to push.
|
|
392
|
-
const
|
|
412
|
+
const NON_DESKTOP_ORIGIN_CASES: Array<{
|
|
413
|
+
name: string;
|
|
414
|
+
metadata: unknown;
|
|
415
|
+
}> = [
|
|
393
416
|
{
|
|
394
417
|
name: "the iOS app",
|
|
395
418
|
metadata: { client: { os: "ios" }, clientOsFromRequest: true },
|
|
@@ -412,7 +435,7 @@ describe("emitAssistantReplyNotification", () => {
|
|
|
412
435
|
// `macos` the conversation's live client state kept from an earlier
|
|
413
436
|
// desktop send. Without the marker that OS names an earlier turn, not
|
|
414
437
|
// this one, and the push the user is waiting for on their phone would
|
|
415
|
-
// be dropped by the attended
|
|
438
|
+
// be dropped by the attended desktop.
|
|
416
439
|
{
|
|
417
440
|
name: "a row whose macOS OS was inherited, not reported",
|
|
418
441
|
metadata: { userMessageInterface: "web", client: { os: "macos" } },
|
|
@@ -424,7 +447,7 @@ describe("emitAssistantReplyNotification", () => {
|
|
|
424
447
|
},
|
|
425
448
|
];
|
|
426
449
|
|
|
427
|
-
for (const { name, metadata } of
|
|
450
|
+
for (const { name, metadata } of NON_DESKTOP_ORIGIN_CASES) {
|
|
428
451
|
test(`leaves the signal live for a turn opened from ${name}`, async () => {
|
|
429
452
|
initiatingRow = makeMessage({ metadata: JSON.stringify(metadata) });
|
|
430
453
|
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
parseMessageMetadata,
|
|
24
24
|
} from "../persistence/conversation-crud.js";
|
|
25
25
|
import {
|
|
26
|
-
|
|
26
|
+
isDesktopOriginatedUserMessage,
|
|
27
27
|
isReplyPushIneligibleUserMessage,
|
|
28
28
|
resolveConversationKind,
|
|
29
29
|
} from "../persistence/conversation-types.js";
|
|
@@ -122,7 +122,7 @@ function readSuppressionMarkers(
|
|
|
122
122
|
*
|
|
123
123
|
* No `actorPrincipalId`: the platform delivers this push to the assistant
|
|
124
124
|
* owner's device tokens, and a pod has exactly one owner, so any attended
|
|
125
|
-
*
|
|
125
|
+
* desktop client is that owner's.
|
|
126
126
|
*/
|
|
127
127
|
function readDesktopAttended(rlog: pino.Logger): boolean {
|
|
128
128
|
try {
|
|
@@ -246,12 +246,12 @@ export async function emitAssistantReplyNotification(params: {
|
|
|
246
246
|
);
|
|
247
247
|
|
|
248
248
|
// Read as close to the emit as possible: nothing short-circuits on it.
|
|
249
|
-
// Presence only speaks for a turn the
|
|
250
|
-
// OS evidence. A turn sent from the phone still needs its push while the
|
|
251
|
-
//
|
|
249
|
+
// Presence only speaks for a turn the desktop itself opened, on that row's
|
|
250
|
+
// own OS evidence. A turn sent from the phone still needs its push while the
|
|
251
|
+
// desktop sits idle within the attendance window.
|
|
252
252
|
const desktopAttended =
|
|
253
253
|
isAssistantFeatureFlagEnabled(DESKTOP_PRESENCE_FLAG) &&
|
|
254
|
-
|
|
254
|
+
isDesktopOriginatedUserMessage(initiatingMetadata) &&
|
|
255
255
|
readDesktopAttended(rlog);
|
|
256
256
|
|
|
257
257
|
// Conversation-scoped web presence applies regardless of which device
|
|
@@ -276,7 +276,7 @@ export async function emitAssistantReplyNotification(params: {
|
|
|
276
276
|
// opting into v2.
|
|
277
277
|
urgency: "medium",
|
|
278
278
|
isAsyncBackground: false,
|
|
279
|
-
// Read weakly, as "at the surface this landed on": the attended
|
|
279
|
+
// Read weakly, as "at the surface this landed on": the attended desktop
|
|
280
280
|
// or focused web tab that opened the turn renders the reply in-app,
|
|
281
281
|
// so a redundant push would only duplicate what's already on screen.
|
|
282
282
|
visibleInSourceNow: desktopAttended || webFocused,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Generates richer seed content for notification conversations than the concise
|
|
5
5
|
* title/body used in native notification popups. Verbosity adapts to the
|
|
6
|
-
* delivery surface:
|
|
6
|
+
* delivery surface: Vellum desktop gets flowing prose, Telegram gets compact.
|
|
7
7
|
*
|
|
8
8
|
* Composes from `copy.title/body` rather than hardcoded English templates
|
|
9
9
|
* so LLM-localized copy is preserved for non-English users.
|
|
@@ -25,7 +25,12 @@ const CHANNEL_DEFAULT_INTERFACE: Record<string, InterfaceId> = {
|
|
|
25
25
|
telegram: "telegram",
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
-
const RICH_INTERFACES = new Set<InterfaceId>([
|
|
28
|
+
const RICH_INTERFACES = new Set<InterfaceId>([
|
|
29
|
+
"macos",
|
|
30
|
+
"windows",
|
|
31
|
+
"ios",
|
|
32
|
+
"web",
|
|
33
|
+
]);
|
|
29
34
|
|
|
30
35
|
/**
|
|
31
36
|
* Resolve verbosity level from delivery channel + optional interface hint.
|
|
@@ -228,21 +228,22 @@ export function isVoiceSessionUserMessage(
|
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
/**
|
|
231
|
-
* True when the row that opened the turn was sent from
|
|
231
|
+
* True when the row that opened the turn was sent from a desktop app, on that
|
|
232
232
|
* row's own evidence.
|
|
233
233
|
*
|
|
234
234
|
* Two markers, both required. The `client` bag's `os` entry is the only
|
|
235
235
|
* per-platform attribution on a message: `userMessageInterface` is `"web"` for
|
|
236
|
-
* the
|
|
237
|
-
* says that `os` was reported by this row's request or
|
|
238
|
-
* inherited from the conversation's live client state,
|
|
239
|
-
* of an earlier turn
|
|
240
|
-
* sent to from
|
|
236
|
+
* the desktop apps, the iOS app, and a desktop browser alike.
|
|
237
|
+
* `clientOsFromRequest` says that `os` was reported by this row's request or
|
|
238
|
+
* transport rather than inherited from the conversation's live client state,
|
|
239
|
+
* which names the surface of an earlier turn. A button tapped on the phone
|
|
240
|
+
* against a conversation last sent to from desktop persists the old OS with no
|
|
241
|
+
* marker.
|
|
241
242
|
*
|
|
242
243
|
* Origin a row did not report itself is origin unknown. Callers gate
|
|
243
|
-
* suppression on this, so unknown has to read as not-
|
|
244
|
+
* suppression on this, so unknown has to read as not-desktop.
|
|
244
245
|
*/
|
|
245
|
-
export function
|
|
246
|
+
export function isDesktopOriginatedUserMessage(
|
|
246
247
|
metadata: Record<string, unknown> | undefined,
|
|
247
248
|
): boolean {
|
|
248
249
|
if (metadata?.clientOsFromRequest !== true) {
|
|
@@ -252,7 +253,8 @@ export function isMacOriginatedUserMessage(
|
|
|
252
253
|
if (typeof client !== "object" || client === null) {
|
|
253
254
|
return false;
|
|
254
255
|
}
|
|
255
|
-
|
|
256
|
+
const clientOs = parseClientOs((client as Record<string, unknown>).os);
|
|
257
|
+
return clientOs === "macos" || clientOs === "windows";
|
|
256
258
|
}
|
|
257
259
|
|
|
258
260
|
/**
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Tests for the desktop presence policy.
|
|
3
3
|
*
|
|
4
4
|
* The invariant under test is fail-open: only a fresh `active` report from a
|
|
5
|
-
*
|
|
5
|
+
* desktop client answers `true`. Every other shape (stale, idle, away, no
|
|
6
6
|
* presence, wrong interface, no clients, hub throw) answers `false` so the
|
|
7
7
|
* push is sent. Scoping the read to an actor principal narrows what counts as
|
|
8
8
|
* attendance, so it can only ever turn a `true` into a `false`.
|
|
@@ -45,6 +45,15 @@ describe("isDesktopAttended", () => {
|
|
|
45
45
|
expect(isDesktopAttended()).toBe(true);
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
test("fresh active windows client is attended", () => {
|
|
49
|
+
registerClient({
|
|
50
|
+
clientId: "windows-1",
|
|
51
|
+
interfaceId: "windows",
|
|
52
|
+
presence: "active",
|
|
53
|
+
});
|
|
54
|
+
expect(isDesktopAttended()).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
48
57
|
test("active report older than the staleness bound is not attended", () => {
|
|
49
58
|
registerClient({ clientId: "mac-1", presence: "active" });
|
|
50
59
|
expect(isDesktopAttended({ now: afterStaleness() })).toBe(false);
|
|
@@ -92,9 +101,13 @@ describe("isDesktopAttended", () => {
|
|
|
92
101
|
}
|
|
93
102
|
});
|
|
94
103
|
|
|
95
|
-
test("one active
|
|
104
|
+
test("one active desktop client among several is enough", () => {
|
|
96
105
|
registerClient({ clientId: "mac-1", presence: "away" });
|
|
97
|
-
registerClient({
|
|
106
|
+
registerClient({
|
|
107
|
+
clientId: "windows-1",
|
|
108
|
+
interfaceId: "windows",
|
|
109
|
+
presence: "active",
|
|
110
|
+
});
|
|
98
111
|
expect(isDesktopAttended()).toBe(true);
|
|
99
112
|
});
|
|
100
113
|
});
|
|
@@ -109,6 +122,16 @@ describe("isDesktopAttended scoped to an actor principal", () => {
|
|
|
109
122
|
expect(isDesktopAttended({ actorPrincipalId: "actor-a" })).toBe(true);
|
|
110
123
|
});
|
|
111
124
|
|
|
125
|
+
test("matching actor principal on windows is attended", () => {
|
|
126
|
+
registerClient({
|
|
127
|
+
clientId: "windows-1",
|
|
128
|
+
interfaceId: "windows",
|
|
129
|
+
presence: "active",
|
|
130
|
+
actorPrincipalId: "actor-a",
|
|
131
|
+
});
|
|
132
|
+
expect(isDesktopAttended({ actorPrincipalId: "actor-a" })).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
112
135
|
test("another actor's attended mac does not count", () => {
|
|
113
136
|
registerClient({
|
|
114
137
|
clientId: "mac-1",
|
|
@@ -123,7 +146,7 @@ describe("isDesktopAttended scoped to an actor principal", () => {
|
|
|
123
146
|
expect(isDesktopAttended({ actorPrincipalId: "actor-a" })).toBe(false);
|
|
124
147
|
});
|
|
125
148
|
|
|
126
|
-
test("omitting the principal counts any attended
|
|
149
|
+
test("omitting the principal counts any attended desktop client", () => {
|
|
127
150
|
registerClient({
|
|
128
151
|
clientId: "mac-1",
|
|
129
152
|
presence: "active",
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
* Desktop presence policy.
|
|
3
3
|
*
|
|
4
4
|
* Answers exactly one question for notification producers: is the user
|
|
5
|
-
* demonstrably at their
|
|
5
|
+
* demonstrably at their desktop right now? Producers use the answer to skip a
|
|
6
6
|
* push that the user would see on screen anyway.
|
|
7
7
|
*
|
|
8
8
|
* Fails open by design. Presence is in-memory, best-effort, and reported by
|
|
9
9
|
* a client that can drop off at any time, so anything short of a fresh
|
|
10
|
-
* `active` report from a
|
|
10
|
+
* `active` report from a desktop client answers `false` and the push goes
|
|
11
11
|
* out. A missed notification is strictly worse than a redundant one.
|
|
12
12
|
*/
|
|
13
13
|
|
|
@@ -15,16 +15,17 @@ import { getLogger } from "../util/logger.js";
|
|
|
15
15
|
import { assistantEventHub } from "./assistant-event-hub.js";
|
|
16
16
|
|
|
17
17
|
const log = getLogger("desktop-presence");
|
|
18
|
+
const DESKTOP_INTERFACES = ["macos", "windows"] as const;
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
|
-
* Bounds how long suppression can outlive the
|
|
21
|
+
* Bounds how long suppression can outlive the desktop sleeping or dropping
|
|
21
22
|
* off, while still tolerating two dropped reports at the 30s report interval.
|
|
22
23
|
*/
|
|
23
24
|
export const PRESENCE_STALE_AFTER_MS = 90_000;
|
|
24
25
|
|
|
25
26
|
export interface DesktopAttendanceOptions {
|
|
26
27
|
/**
|
|
27
|
-
* Only count
|
|
28
|
+
* Only count desktop clients whose verified actor principal matches this id.
|
|
28
29
|
* A client that connected without a principal (legacy or service token)
|
|
29
30
|
* never matches a supplied id.
|
|
30
31
|
*/
|
|
@@ -34,13 +35,13 @@ export interface DesktopAttendanceOptions {
|
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
|
-
* Whether some
|
|
38
|
-
* Stale, absent, non-
|
|
38
|
+
* Whether some desktop client has reported `active` recently enough to trust.
|
|
39
|
+
* Stale, absent, non-desktop, and error reads all answer `false`.
|
|
39
40
|
*
|
|
40
41
|
* Callers whose notification targets one recipient (guardian-scoped or
|
|
41
42
|
* otherwise per-recipient pushes) must pass that recipient's
|
|
42
|
-
* `actorPrincipalId`, or another user's attended
|
|
43
|
-
* Omitting it treats any attended
|
|
43
|
+
* `actorPrincipalId`, or another user's attended desktop suppresses the push.
|
|
44
|
+
* Omitting it treats any attended desktop client as attendance, which only
|
|
44
45
|
* suits notifications with no single recipient.
|
|
45
46
|
*/
|
|
46
47
|
export function isDesktopAttended(
|
|
@@ -48,19 +49,21 @@ export function isDesktopAttended(
|
|
|
48
49
|
): boolean {
|
|
49
50
|
const { actorPrincipalId, now = new Date() } = options;
|
|
50
51
|
try {
|
|
51
|
-
return
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
52
|
+
return DESKTOP_INTERFACES.some((interfaceId) =>
|
|
53
|
+
assistantEventHub.listClientsByInterface(interfaceId).some((client) => {
|
|
54
|
+
if (
|
|
55
|
+
actorPrincipalId !== undefined &&
|
|
56
|
+
client.actorPrincipalId !== actorPrincipalId
|
|
57
|
+
) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return (
|
|
61
|
+
client.presence?.state === "active" &&
|
|
62
|
+
now.getTime() - client.presence.reportedAt.getTime() <=
|
|
63
|
+
PRESENCE_STALE_AFTER_MS
|
|
64
|
+
);
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
64
67
|
} catch (err) {
|
|
65
68
|
// Returning false sends the push, which is the safe direction.
|
|
66
69
|
log.warn({ err }, "desktop presence read failed; treating as unattended");
|