@vellumai/assistant 0.12.3-dev.202609222214.c4d457d → 0.12.3-dev.202609230043.e6fddec

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.
@@ -67,8 +67,31 @@ describe("notificationAvatarDiscHex", () => {
67
67
  });
68
68
 
69
69
  describe("notificationAvatarSvg", () => {
70
+ test("fills the entire circular crop with a custom image", () => {
71
+ const svg = notificationAvatarSvg({
72
+ kind: "image",
73
+ innerPngBase64: PNG_BASE64,
74
+ accentHex: "#E9642F",
75
+ });
76
+
77
+ expect(attributes("image", svg)).toMatchObject({
78
+ x: "0",
79
+ y: "0",
80
+ width: "256",
81
+ height: "256",
82
+ preserveAspectRatio: "xMidYMid slice",
83
+ "clip-path": "url(#notification-avatar-disc)",
84
+ });
85
+ expect(allAttributes("circle", svg)[1]).toEqual({
86
+ cx: "128",
87
+ cy: "128",
88
+ r: "128",
89
+ });
90
+ });
91
+
70
92
  test("draws one disc and one inset avatar at the default size", () => {
71
93
  const svg = notificationAvatarSvg({
94
+ kind: "character",
72
95
  innerPngBase64: PNG_BASE64,
73
96
  accentHex: "#E9642F",
74
97
  });
@@ -100,6 +123,7 @@ describe("notificationAvatarSvg", () => {
100
123
  // upload fill the square instead of letterboxing is the pair of attributes
101
124
  // asserted here.
102
125
  const svg = notificationAvatarSvg({
126
+ kind: "character",
103
127
  innerPngBase64: PNG_BASE64,
104
128
  accentHex: "#E9642F",
105
129
  });
@@ -121,6 +145,7 @@ describe("notificationAvatarSvg", () => {
121
145
 
122
146
  test("scales the geometry to a custom size", () => {
123
147
  const svg = notificationAvatarSvg({
148
+ kind: "character",
124
149
  innerPngBase64: PNG_BASE64,
125
150
  accentHex: "#E9642F",
126
151
  size: 100,
@@ -142,6 +167,7 @@ describe("notificationAvatarSvg", () => {
142
167
 
143
168
  test("carries a non-PNG inner raster with its own media type", () => {
144
169
  const svg = notificationAvatarSvg({
170
+ kind: "character",
145
171
  innerPngBase64: PNG_BASE64,
146
172
  innerMediaType: "image/jpeg",
147
173
  accentHex: "#E9642F",
@@ -154,6 +180,7 @@ describe("notificationAvatarSvg", () => {
154
180
 
155
181
  test("paints the fallback disc when there is no accent", () => {
156
182
  const svg = notificationAvatarSvg({
183
+ kind: "character",
157
184
  innerPngBase64: PNG_BASE64,
158
185
  accentHex: null,
159
186
  });
@@ -164,8 +191,21 @@ describe("notificationAvatarSvg", () => {
164
191
  });
165
192
 
166
193
  describe("notificationAvatarGeometry", () => {
194
+ test("custom images reach the disc edge at any output size", () => {
195
+ expect(notificationAvatarGeometry("image")).toEqual({
196
+ radius: 128,
197
+ offset: 0,
198
+ inner: 256,
199
+ });
200
+ expect(notificationAvatarGeometry("image", 100)).toEqual({
201
+ radius: 50,
202
+ offset: 0,
203
+ inner: 100,
204
+ });
205
+ });
206
+
167
207
  test("derives the disc, the border and the avatar edge from the size", () => {
168
- expect(notificationAvatarGeometry(100)).toEqual({
208
+ expect(notificationAvatarGeometry("character", 100)).toEqual({
169
209
  radius: 50,
170
210
  offset: 11,
171
211
  inner: 78,
@@ -173,15 +213,19 @@ describe("notificationAvatarGeometry", () => {
173
213
  });
174
214
 
175
215
  test("measures the default size when given none", () => {
176
- expect(notificationAvatarGeometry()).toEqual(
177
- notificationAvatarGeometry(NOTIFICATION_AVATAR_SIZE),
216
+ expect(notificationAvatarGeometry("character")).toEqual(
217
+ notificationAvatarGeometry("character", NOTIFICATION_AVATAR_SIZE),
178
218
  );
179
219
  });
180
220
 
181
221
  test("is the geometry the SVG is drawn with", () => {
182
222
  const size = 100;
183
- const { radius, offset, inner } = notificationAvatarGeometry(size);
223
+ const { radius, offset, inner } = notificationAvatarGeometry(
224
+ "character",
225
+ size,
226
+ );
184
227
  const svg = notificationAvatarSvg({
228
+ kind: "character",
185
229
  innerPngBase64: PNG_BASE64,
186
230
  accentHex: null,
187
231
  size,
@@ -15,11 +15,12 @@
15
15
  */
16
16
 
17
17
  import { isAvatarAccentHex } from "./accent.js";
18
+ import type { AvatarKind } from "./manifest.js";
18
19
 
19
20
  /** Output edge in pixels: large enough for an iOS notification thumbnail. */
20
21
  export const NOTIFICATION_AVATAR_SIZE = 256;
21
22
 
22
- /** Free disc on each side, as a fraction of the edge; the avatar gets the rest. */
23
+ /** Free disc on each side of a character avatar, as a fraction of the edge. */
23
24
  export const NOTIFICATION_AVATAR_INSET = 0.11;
24
25
 
25
26
  /** The disc when the assistant has no accent. */
@@ -48,7 +49,7 @@ export const NOTIFICATION_AVATAR_MAX_LOCAL_BYTES = 512 * 1024;
48
49
  * Bumped whenever the drawing above changes, so a sync keyed on it re-uploads
49
50
  * a disc rendered by an older spec even when the avatar itself is unchanged.
50
51
  */
51
- export const NOTIFICATION_AVATAR_SPEC_VERSION = 1;
52
+ export const NOTIFICATION_AVATAR_SPEC_VERSION = 2;
52
53
 
53
54
  /** The id the inner raster's clip path is referenced by inside the document. */
54
55
  const CLIP_ID = "notification-avatar-disc";
@@ -74,24 +75,26 @@ export function notificationAvatarDiscHex(accentHex: string | null): string {
74
75
 
75
76
  /** Raster formats an `<image>` href carries here; what resvg and a canvas both decode. */
76
77
  export type NotificationAvatarMediaType =
77
- | "image/png"
78
- | "image/jpeg"
79
- | "image/gif";
78
+ "image/png" | "image/jpeg" | "image/gif";
80
79
 
81
80
  /**
82
81
  * The three measurements the drawing derives from the edge: the disc's radius,
83
82
  * which is also its centre; the free border around the avatar; and the
84
83
  * avatar's own edge. Every rasterizer reads them from here, so the daemon's
85
84
  * SVG and the desktop canvas cannot drift.
85
+ * Custom images fill the disc; character avatars retain space around their
86
+ * silhouette.
86
87
  */
87
88
  export function notificationAvatarGeometry(
89
+ kind: Exclude<AvatarKind, "none">,
88
90
  size: number = NOTIFICATION_AVATAR_SIZE,
89
91
  ): { radius: number; offset: number; inner: number } {
90
- const offset = size * NOTIFICATION_AVATAR_INSET;
92
+ const offset = kind === "character" ? size * NOTIFICATION_AVATAR_INSET : 0;
91
93
  return { radius: size / 2, offset, inner: size - 2 * offset };
92
94
  }
93
95
 
94
96
  export interface NotificationAvatarSvgOptions {
97
+ kind: Exclude<AvatarKind, "none">;
95
98
  /** The avatar raster to draw inside the disc, base64 with no data prefix. */
96
99
  innerPngBase64: string;
97
100
  /** What `innerPngBase64` holds; PNG unless an upload arrived as a JPEG or a GIF. */
@@ -107,7 +110,7 @@ function px(value: number): string {
107
110
 
108
111
  /**
109
112
  * The notification avatar as an SVG document: a filled disc with the avatar
110
- * drawn inset into it.
113
+ * filling it for custom images or inset for character avatars.
111
114
  *
112
115
  * The inner raster is cover-cropped (`xMidYMid slice`), matching what the web
113
116
  * canvas does, so a portrait or landscape upload fills the square instead of
@@ -119,12 +122,13 @@ function px(value: number): string {
119
122
  * rasterizer is known to render it.
120
123
  */
121
124
  export function notificationAvatarSvg({
125
+ kind,
122
126
  innerPngBase64,
123
127
  innerMediaType = "image/png",
124
128
  accentHex,
125
129
  size = NOTIFICATION_AVATAR_SIZE,
126
130
  }: NotificationAvatarSvgOptions): string {
127
- const { radius, offset, inner } = notificationAvatarGeometry(size);
131
+ const { radius, offset, inner } = notificationAvatarGeometry(kind, size);
128
132
  const href = `data:${innerMediaType};base64,${innerPngBase64}`;
129
133
  const disc = `cx="${px(radius)}" cy="${px(radius)}" r="${px(radius)}"`;
130
134
  return (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.3-dev.202609222214.c4d457d",
3
+ "version": "0.12.3-dev.202609230043.e6fddec",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -109,6 +109,41 @@ async function executeSkillLoad(
109
109
  // ── Tests ─────────────────────────────────────────────────────────────────
110
110
 
111
111
  describe("skill_load inline command expansion", () => {
112
+ test("read-only preloading returns instructions without running inline commands", async () => {
113
+ writeSkill(
114
+ "readonly-example",
115
+ "readonly-example",
116
+ "Example skill",
117
+ "Context: !`echo example`",
118
+ );
119
+ const result = await skillLoadTool.execute(
120
+ { skill: "readonly-example" },
121
+ {
122
+ conversationId: "conversation-1",
123
+ workingDir: TEST_DIR,
124
+ trustClass: "guardian",
125
+ },
126
+ { readOnly: true },
127
+ );
128
+ expect(result.isError).toBe(false);
129
+ expect(result.content).toContain("[inline command not executed]");
130
+ expect(runInlineCommandCalls).toHaveLength(0);
131
+ expect(mockAutoInstall).not.toHaveBeenCalled();
132
+ });
133
+
134
+ test("read-only preloading does not install missing skills", async () => {
135
+ const result = await skillLoadTool.execute(
136
+ { skill: "missing-readonly-skill" },
137
+ {
138
+ conversationId: "conversation-1",
139
+ workingDir: TEST_DIR,
140
+ trustClass: "guardian",
141
+ },
142
+ { readOnly: true },
143
+ );
144
+ expect(result.isError).toBe(true);
145
+ expect(mockAutoInstall).not.toHaveBeenCalled();
146
+ });
112
147
  beforeEach(() => {
113
148
  mkdirSync(join(TEST_DIR, "skills"), { recursive: true });
114
149
  runInlineCommandCalls.length = 0;
@@ -63,9 +63,6 @@ const TRAITS = { bodyShape: "blob", eyeStyle: "curious", color: "green" };
63
63
  const GREEN_DISC = { r: 0xe6, g: 0xf1, b: 0xe7 };
64
64
  /** #E9642F mixed 14% into white. */
65
65
  const ORANGE_DISC = { r: 0xfc, g: 0xe9, b: 0xe2 };
66
- /** The neutral disc every accent-less avatar wears. */
67
- const FALLBACK_DISC = { r: 0xec, g: 0xef, b: 0xea };
68
-
69
66
  /** On the disc and clear of the inset avatar square, which starts at y = 28.16. */
70
67
  const DISC_SAMPLE = { x: 128, y: 8 };
71
68
  /** Outside the inscribed disc, where the render leaves nothing at all. */
@@ -233,7 +230,7 @@ describe("renderNotificationAvatarPng", () => {
233
230
  );
234
231
 
235
232
  nativeTest(
236
- "renders an uploaded image on the neutral disc when it has no accent",
233
+ "fills the circle with an uploaded image when it has no accent",
237
234
  async () => {
238
235
  await writeUpload("png", null);
239
236
 
@@ -245,7 +242,9 @@ describe("renderNotificationAvatarPng", () => {
245
242
  expect(image.height).toBe(256);
246
243
  expect(image.at(CORNER.x, CORNER.y).a).toBe(0);
247
244
  expect(image.at(DISC_SAMPLE.x, DISC_SAMPLE.y)).toEqual({
248
- ...FALLBACK_DISC,
245
+ r: 0x20,
246
+ g: 0x60,
247
+ b: 0xc0,
249
248
  a: 255,
250
249
  });
251
250
  expect(image.at(128, 128)).toEqual({ r: 0x20, g: 0x60, b: 0xc0, a: 255 });
@@ -253,23 +252,34 @@ describe("renderNotificationAvatarPng", () => {
253
252
  NATIVE_RENDER_TEST_TIMEOUT_MS,
254
253
  );
255
254
 
256
- nativeTest(
257
- "cover-crops a wide upload and clips it to the disc",
258
- async () => {
259
- // 4:1, so a letterboxing render would leave disc colour above and below
260
- // the avatar inside the inset square.
261
- await writeUpload("png", null, { width: 256, height: 64 });
262
-
263
- const image = await pixels((await renderNotificationAvatarPng())!);
264
- // Inside the inset square (which starts at 28.16) near its top edge:
265
- // filled by a cover-crop, disc colour by a letterbox.
266
- expect(image.at(128, 35)).toEqual({ r: 0x20, g: 0x60, b: 0xc0, a: 255 });
267
- // Inside the inset square but outside the disc, so only the clip can
268
- // keep the avatar out of it.
269
- expect(image.at(30, 30).a).toBe(0);
270
- },
271
- NATIVE_RENDER_TEST_TIMEOUT_MS,
272
- );
255
+ for (const [shape, width, height] of [
256
+ ["landscape", 256, 64],
257
+ ["portrait", 64, 256],
258
+ ] as const) {
259
+ nativeTest(
260
+ `cover-crops a ${shape} upload to the full disc`,
261
+ async () => {
262
+ await writeUpload("png", null, { width, height });
263
+
264
+ const image = await pixels((await renderNotificationAvatarPng())!);
265
+ for (const [x, y] of [
266
+ [128, 8],
267
+ [8, 128],
268
+ [128, 248],
269
+ [248, 128],
270
+ ]) {
271
+ expect(image.at(x!, y!)).toEqual({
272
+ r: 0x20,
273
+ g: 0x60,
274
+ b: 0xc0,
275
+ a: 255,
276
+ });
277
+ }
278
+ expect(image.at(30, 30).a).toBe(0);
279
+ },
280
+ NATIVE_RENDER_TEST_TIMEOUT_MS,
281
+ );
282
+ }
273
283
 
274
284
  nativeTest(
275
285
  "transcodes a WebP upload, which resvg cannot decode on its own",
@@ -281,20 +291,18 @@ describe("renderNotificationAvatarPng", () => {
281
291
 
282
292
  const image = await pixels(png!);
283
293
  expect(image.at(CORNER.x, CORNER.y).a).toBe(0);
284
- expect(image.at(DISC_SAMPLE.x, DISC_SAMPLE.y)).toEqual({
285
- ...FALLBACK_DISC,
286
- a: 255,
287
- });
288
294
  // WebP is lossy, so the transcode moves the flat fill by a channel step
289
295
  // or two; what matters is that the avatar is drawn at all.
290
- const centre = image.at(128, 128);
291
- expect(centre.a).toBe(255);
292
- for (const [actual, expected] of [
293
- [centre.r, 0x20],
294
- [centre.g, 0x60],
295
- [centre.b, 0xc0],
296
- ]) {
297
- expect(Math.abs(actual! - expected!)).toBeLessThanOrEqual(4);
296
+ for (const point of [{ x: 128, y: 128 }, DISC_SAMPLE]) {
297
+ const pixel = image.at(point.x, point.y);
298
+ expect(pixel.a).toBe(255);
299
+ for (const [actual, expected] of [
300
+ [pixel.r, 0x20],
301
+ [pixel.g, 0x60],
302
+ [pixel.b, 0xc0],
303
+ ]) {
304
+ expect(Math.abs(actual! - expected!)).toBeLessThanOrEqual(4);
305
+ }
298
306
  }
299
307
  },
300
308
  NATIVE_RENDER_TEST_TIMEOUT_MS,
@@ -222,6 +222,7 @@ export async function renderNotificationAvatarPng(
222
222
  const Resvg = getResvg();
223
223
  const resvg = new Resvg(
224
224
  notificationAvatarSvg({
225
+ kind: state.kind,
225
226
  innerPngBase64: source.bytes.toString("base64"),
226
227
  innerMediaType: source.mediaType,
227
228
  accentHex: resolveNotificationAccentHex(state),
@@ -143,6 +143,10 @@ mock.module("../../persistence/conversation-crud.js", () => ({
143
143
  recordConversationPersistedSeq: () => {},
144
144
  }));
145
145
 
146
+ import {
147
+ clearHubClients,
148
+ registerHubClient,
149
+ } from "../../__tests__/helpers/hub-clients.js";
146
150
  import { setConfig } from "../../__tests__/helpers/set-config.js";
147
151
  import type { PreparedModelCall } from "../../agent/loop.js";
148
152
  import { selectWinningProfile } from "../../config/llm-resolver.js";
@@ -2326,21 +2330,30 @@ describe("front-door leg tool suppression", () => {
2326
2330
  });
2327
2331
 
2328
2332
  describe("desktop skill preactivation", () => {
2333
+ afterEach(() => clearHubClients(assistantEventHub));
2334
+
2329
2335
  async function preactivatedFor(turn: Record<string, unknown>): Promise<{
2330
2336
  skillIds: string[];
2331
2337
  proxyInterfaces: unknown[];
2332
2338
  skillIdsDuringLoop: string[];
2339
+ promptDuringLoop: string | null;
2333
2340
  }> {
2334
2341
  const skillIds: string[] = [];
2335
2342
  const proxyInterfaces: unknown[] = [];
2336
2343
  let skillIdsDuringLoop: string[] = [];
2344
+ let prompt: string | null = null;
2345
+ let promptDuringLoop: string | null = null;
2337
2346
  const fake = makeFakeConversation({
2338
2347
  processing: false,
2339
2348
  runAgentLoop: async () => {
2340
2349
  skillIdsDuringLoop = [...skillIds];
2350
+ promptDuringLoop = prompt;
2341
2351
  },
2342
2352
  });
2343
2353
  Object.assign(fake.conversation, {
2354
+ setVoiceCallControlPrompt: (value: string | null) => {
2355
+ prompt = value;
2356
+ },
2344
2357
  addPreactivatedSkillId: (id: string) => {
2345
2358
  skillIds.push(id);
2346
2359
  },
@@ -2356,9 +2369,58 @@ describe("desktop skill preactivation", () => {
2356
2369
  ...turn,
2357
2370
  });
2358
2371
  await flushMicrotasks();
2359
- return { skillIds, proxyInterfaces, skillIdsDuringLoop };
2372
+ return { skillIds, proxyInterfaces, skillIdsDuringLoop, promptDuringLoop };
2360
2373
  }
2361
2374
 
2375
+ test("a shared-screen turn receives the annotation instructions and schemas before inference", async () => {
2376
+ registerHubClient({
2377
+ hub: assistantEventHub,
2378
+ clientId: "annotation-client",
2379
+ interfaceId: "macos",
2380
+ actorPrincipalId: "user-123",
2381
+ capabilities: ["host_cu", "host_cu_annotate"],
2382
+ });
2383
+ const result = await preactivatedFor({
2384
+ routingLeg: "escalated",
2385
+ macosDesktopSession: true,
2386
+ screenSharing: true,
2387
+ actorPrincipalId: "user-123",
2388
+ });
2389
+ expect(result.skillIdsDuringLoop).toContain("screen-annotation");
2390
+ expect(result.promptDuringLoop).toContain("ID: screen-annotation");
2391
+ expect(result.promptDuringLoop).toContain("Name the thing.");
2392
+ expect(result.promptDuringLoop).toContain("screen_point_at");
2393
+ expect(result.promptDuringLoop).toContain("screen_clear_marks");
2394
+ expect(result.promptDuringLoop).toContain('"target"');
2395
+ expect(result.promptDuringLoop).toContain("skill_execute");
2396
+ });
2397
+
2398
+ test.each([
2399
+ { screenSharing: false },
2400
+ { actorPrincipalId: "other-user" },
2401
+ { routingLeg: "front-door" },
2402
+ { macosDesktopSession: false },
2403
+ ])(
2404
+ "does not preload annotation for an ineligible turn: %j",
2405
+ async (override) => {
2406
+ registerHubClient({
2407
+ hub: assistantEventHub,
2408
+ clientId: "annotation-client",
2409
+ interfaceId: "macos",
2410
+ actorPrincipalId: "user-123",
2411
+ capabilities: ["host_cu", "host_cu_annotate"],
2412
+ });
2413
+ const result = await preactivatedFor({
2414
+ routingLeg: "escalated",
2415
+ macosDesktopSession: true,
2416
+ screenSharing: true,
2417
+ actorPrincipalId: "user-123",
2418
+ ...override,
2419
+ });
2420
+ expect(result.promptDuringLoop).not.toContain("ID: screen-annotation");
2421
+ },
2422
+ );
2423
+
2362
2424
  test("an escalated leg of a macOS desktop session starts with computer use active", async () => {
2363
2425
  const result = await preactivatedFor({
2364
2426
  routingLeg: "escalated",
@@ -0,0 +1,44 @@
1
+ import { shouldAttachHostProxyForCapability } from "../daemon/host-proxy-preactivation.js";
2
+ import { skillLoadTool } from "../tools/skills/load.js";
3
+ import type { ToolContext } from "../tools/types.js";
4
+ import { getLogger } from "../util/logger.js";
5
+
6
+ const log = getLogger("voice-screen-annotation");
7
+
8
+ export async function loadVoiceScreenAnnotation(
9
+ context: ToolContext,
10
+ ): Promise<string | null> {
11
+ if (
12
+ !shouldAttachHostProxyForCapability(
13
+ "host_cu_annotate",
14
+ context.transportInterface,
15
+ context.sourceActorPrincipalId,
16
+ )
17
+ ) {
18
+ return null;
19
+ }
20
+ try {
21
+ const result = await skillLoadTool.execute(
22
+ { skill: "screen-annotation" },
23
+ context,
24
+ { readOnly: true },
25
+ );
26
+ if (result.isError) {
27
+ log.warn(
28
+ { conversationId: context.conversationId, error: result.content },
29
+ "Could not preload screen annotation",
30
+ );
31
+ return null;
32
+ }
33
+ return [
34
+ "The user is sharing their screen. skill_load has automatically loaded screen-annotation for this turn. Use its tools through skill_execute when pointing at the shared screen.",
35
+ result.content,
36
+ ].join("\n\n");
37
+ } catch (err) {
38
+ log.warn(
39
+ { err, conversationId: context.conversationId },
40
+ "Could not preload screen annotation",
41
+ );
42
+ return null;
43
+ }
44
+ }
@@ -32,7 +32,9 @@ import { CONVERSATION_BUSY_MESSAGE } from "../daemon/conversation-messaging.js";
32
32
  import type { ModeSessionSourceHandle } from "../daemon/conversation-mode-session.js";
33
33
  import { resolveChannelCapabilities } from "../daemon/conversation-runtime-assembly.js";
34
34
  import { getOrCreateConversation } from "../daemon/conversation-store.js";
35
+ import { getEffectiveEnabledPluginSet } from "../daemon/conversation-tool-setup.js";
35
36
  import { preactivateHostProxySkills } from "../daemon/host-proxy-preactivation.js";
37
+ import { resolveTrustClass } from "../daemon/trust-context.js";
36
38
  import type { TrustContext } from "../daemon/trust-context-types.js";
37
39
  import {
38
40
  newestPersistedSightFrame,
@@ -78,6 +80,7 @@ import {
78
80
  } from "./voice-control-protocol.js";
79
81
  import { judgeEscalation } from "./voice-escalation-judge.js";
80
82
  import type { VoiceEscalationTarget } from "./voice-escalation-target.js";
83
+ import { loadVoiceScreenAnnotation } from "./voice-screen-annotation.js";
81
84
  import {
82
85
  createFrontDoorStreamGate,
83
86
  escalatedContinuationRule,
@@ -452,6 +455,8 @@ export interface VoiceTurnOptions {
452
455
  * session reports `macos` for its channel capabilities, iOS included.
453
456
  */
454
457
  macosDesktopSession?: boolean;
458
+ /** The desktop client currently shares a surface with this voice session. */
459
+ screenSharing?: boolean;
455
460
  /** Whether this is an inbound call (no outbound task). */
456
461
  isInbound: boolean;
457
462
  /** The outbound call task, if any. */
@@ -2108,6 +2113,23 @@ export async function startVoiceTurn(
2108
2113
  sourceInterface,
2109
2114
  sourceActorPrincipalId,
2110
2115
  );
2116
+ if (opts.screenSharing === true) {
2117
+ const annotation = await loadVoiceScreenAnnotation({
2118
+ conversationId: opts.conversationId,
2119
+ workingDir: conversation.workingDir,
2120
+ trustClass: resolveTrustClass(opts.trustContext),
2121
+ transportInterface: sourceInterface,
2122
+ clientOs: "macos",
2123
+ enabledPluginSet: getEffectiveEnabledPluginSet(conversation),
2124
+ sourceActorPrincipalId,
2125
+ signal: opts.signal,
2126
+ });
2127
+ if (annotation !== null) {
2128
+ conversation.setVoiceCallControlPrompt(
2129
+ [voiceCallControlPrompt, annotation].filter(Boolean).join("\n\n"),
2130
+ );
2131
+ }
2132
+ }
2111
2133
  }
2112
2134
  await conversation.runAgentLoop(persistedContent, messageId, {
2113
2135
  ...(opts.subagentNotification?.cronRunId
@@ -238,6 +238,33 @@ function assistantDeltaTexts(frames: LiveVoiceServerFrame[]): string[] {
238
238
  }
239
239
 
240
240
  describe("LiveVoiceSession assistant turn", () => {
241
+ test.each([true, false])(
242
+ "passes current screen sharing state %p to the desktop voice bridge",
243
+ async (screenSharing) => {
244
+ const startVoiceTurn = mock(async (_options: VoiceTurnOptions) => ({
245
+ turnId: "bridge-turn-1",
246
+ abort: mock(),
247
+ }));
248
+ const { session, transcriber } = createSessionHarness({
249
+ startFrame: { ...START_FRAME, client: "macos" },
250
+ startVoiceTurn,
251
+ });
252
+ await session.start();
253
+ await session.handleClientFrame({
254
+ type: "update_config",
255
+ screenSharing: true,
256
+ });
257
+ await session.handleClientFrame({ type: "update_config", screenSharing });
258
+ transcriber.emit({ type: "final", text: "Show me the speed control" });
259
+ await session.handleClientFrame({ type: "ptt_release" });
260
+ expect(startVoiceTurn.mock.calls[0]?.[0]).toMatchObject({
261
+ macosDesktopSession: true,
262
+ screenSharing,
263
+ });
264
+ await session.close("client_end");
265
+ },
266
+ );
267
+
241
268
  test("runs final transcripts through the voice bridge and forwards ordered assistant events", async () => {
242
269
  const startVoiceTurn = mock(async (options: VoiceTurnOptions) => {
243
270
  options.callbacks?.assistant_text_delta?.({
@@ -77,6 +77,28 @@ describe("parseLiveVoiceClientTextFrame", () => {
77
77
  expect(result).toEqual({ ok: true, frame });
78
78
  });
79
79
 
80
+ test.each([true, false])(
81
+ "parses screen sharing state %p",
82
+ (screenSharing) => {
83
+ const frame = { type: "update_config", screenSharing } as const;
84
+ expect(parseLiveVoiceClientTextFrame(JSON.stringify(frame))).toEqual({
85
+ ok: true,
86
+ frame,
87
+ });
88
+ },
89
+ );
90
+
91
+ test("rejects malformed screen sharing state", () => {
92
+ expect(
93
+ parseLiveVoiceClientTextFrame(
94
+ JSON.stringify({
95
+ type: "update_config",
96
+ screenSharing: "true",
97
+ }),
98
+ ).ok,
99
+ ).toBe(false);
100
+ });
101
+
80
102
  test("parses an update_config frame with a single field, omitting the other", () => {
81
103
  const result = parseLiveVoiceClientTextFrame(
82
104
  JSON.stringify({ type: "update_config", silenceThresholdMs: 900 }),
@@ -1217,6 +1217,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1217
1217
  private releaseModeSessionResidency?: () => void;
1218
1218
  private cameraModeSessions?: CameraModeSessionProducer;
1219
1219
  private sightFrameSequence: Promise<void> = Promise.resolve();
1220
+ private screenSharing = false;
1220
1221
  /**
1221
1222
  * Mirrors phase changes to the iOS Live Activity through the platform, for
1222
1223
  * the case the client cannot cover: an app backgrounded long enough for iOS
@@ -2084,13 +2085,13 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
2084
2085
  }
2085
2086
 
2086
2087
  /**
2087
- * Apply a mid-session `update_config` frame: retune the live turn detector's
2088
- * pause ("pause before reply") and/or the barge-in guard ("interrupt
2089
- * sensitivity") without reconnecting. Each field is optional and independent;
2090
- * changes take effect from the next utterance. A no-op on manual (non-
2091
- * server_vad) sessions, which have no turn detector.
2088
+ * Apply screen-sharing state and turn-detection tuning independently.
2089
+ * Changes take effect from the next utterance.
2092
2090
  */
2093
2091
  private applyConfigUpdate(frame: LiveVoiceClientUpdateConfigFrame): void {
2092
+ if (frame.screenSharing !== undefined) {
2093
+ this.screenSharing = frame.screenSharing;
2094
+ }
2094
2095
  if (frame.silenceThresholdMs !== undefined) {
2095
2096
  this.turnDetector?.setSilenceThresholdMs(frame.silenceThresholdMs);
2096
2097
  this.silenceThresholdMs = frame.silenceThresholdMs;
@@ -6602,7 +6603,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
6602
6603
  userMessageInterface: "macos",
6603
6604
  assistantMessageInterface: "macos",
6604
6605
  ...(this.context.startFrame.client === "macos"
6605
- ? { macosDesktopSession: true }
6606
+ ? { macosDesktopSession: true, screenSharing: this.screenSharing }
6606
6607
  : {}),
6607
6608
  voiceTelemetry: {
6608
6609
  sessionId: this.context.sessionId,
@@ -276,16 +276,16 @@ export interface LiveVoiceClientEndFrame {
276
276
  }
277
277
 
278
278
  /**
279
- * Mid-session tuning update: applies the same turn-detection knobs the start
280
- * frame carries to the *running* session, so the client can retune "pause
281
- * before reply" / "interrupt sensitivity" without reconnecting. Each field is
282
- * optional and independently applied; the same bounds as the start frame apply.
283
- * Only meaningful for `server_vad` sessions.
279
+ * Mid-session screen-sharing state and turn-detection tuning. Each field is
280
+ * optional and independently applied. Tuning uses the start frame's bounds
281
+ * and affects `server_vad` sessions.
284
282
  */
285
283
  export interface LiveVoiceClientUpdateConfigFrame {
286
284
  readonly type: "update_config";
287
285
  readonly silenceThresholdMs?: number;
288
286
  readonly bargeInMinSpeechMs?: number;
287
+ /** A shared desktop surface is available for annotation. */
288
+ readonly screenSharing?: boolean;
289
289
  }
290
290
 
291
291
  /**
@@ -1279,6 +1279,14 @@ function validateSightFrameTiming(
1279
1279
  function validateUpdateConfigFrame(
1280
1280
  value: Record<string, unknown>,
1281
1281
  ): LiveVoiceParseResult<LiveVoiceClientUpdateConfigFrame> {
1282
+ if ("screenSharing" in value && typeof value.screenSharing !== "boolean") {
1283
+ return protocolError(
1284
+ "invalid_field",
1285
+ "update_config field screenSharing must be a boolean",
1286
+ "screenSharing",
1287
+ "update_config",
1288
+ );
1289
+ }
1282
1290
  if (
1283
1291
  "silenceThresholdMs" in value &&
1284
1292
  !isIntInRange(
@@ -1315,6 +1323,9 @@ function validateUpdateConfigFrame(
1315
1323
  ok: true,
1316
1324
  frame: {
1317
1325
  type: "update_config",
1326
+ ...(typeof value.screenSharing === "boolean"
1327
+ ? { screenSharing: value.screenSharing }
1328
+ : {}),
1318
1329
  ...(typeof value.silenceThresholdMs === "number"
1319
1330
  ? { silenceThresholdMs: value.silenceThresholdMs }
1320
1331
  : {}),
@@ -33,6 +33,10 @@ export const GMAIL_API_BASE_URL =
33
33
  * requests Calendar-only). Resolving against Gmail read access turns a
34
34
  * downstream 403 into an actionable "reconnect Google and grant Gmail" error
35
35
  * at resolution time when the selected connection cannot read Gmail.
36
+ *
37
+ * Managed connections never carry this scope: Vellum's app grants Gmail as
38
+ * `https://mail.google.com/`, which `scopeDifference` in `oauth/scope-utils.ts`
39
+ * accepts as covering it.
36
40
  */
37
41
  export const GMAIL_REQUIRED_SCOPES = [
38
42
  "https://www.googleapis.com/auth/gmail.readonly",
@@ -14,6 +14,12 @@ Add an entry to `PROVIDER_SEED_DATA`. Required fields: `provider`, `authorizeUrl
14
14
 
15
15
  If the provider will support managed mode, set `managedServiceConfigKey` to a slug matching the key you will add to `ServicesSchema` (e.g. `"acme-oauth"`).
16
16
 
17
+ #### Scopes: your-own vs. managed
18
+
19
+ `defaultScopes` here is requested only by the user's own OAuth client. A managed connection authorizes against Vellum's OAuth app, whose defaults live in the platform's `provider_registry.json`. Google shows users warnings or errors for scopes not approved for the app, so the managed start endpoint rejects anything outside the platform registry's `extra_config.allowed_scopes`. Managed Google, for example, grants Gmail only as `https://mail.google.com/`, never `gmail.readonly` / `gmail.modify` / `gmail.send`.
20
+
21
+ So anything that names scopes for a managed connection (a hardcoded constant, a client hook, `requestedScopes` on an `oauth_connect` surface, a `--scopes` example in a skill) must use scopes from that approved list, not the `defaultScopes` names. A required-scope check that runs against managed grants must accept the approved equivalent: `grantedScopeCoversRequiredScope` in `scope-utils.ts` treats `mail.google.com` as covering `gmail.readonly`. Adding a scope to a managed flow means getting it approved in the provider console and adding it to `allowed_scopes` first.
22
+
17
23
  ### 2. _(managed only)_ Add a service schema — `../config/schemas/services.ts`
18
24
 
19
25
  Create a schema and export its type:
@@ -709,6 +709,35 @@ describe("syncAvatarToPlatform", () => {
709
709
  expect(patches).toHaveLength(1);
710
710
  });
711
711
 
712
+ test("an older rendering spec re-uploads the same avatar once", async () => {
713
+ mockResvgAvailable = true;
714
+ syncAvatarToPlatform();
715
+ await settle();
716
+ const current = JSON.parse(readFileSync(syncStatePath, "utf-8"));
717
+ const previous = {
718
+ ...current,
719
+ key: current.key.replace(
720
+ `:${NOTIFICATION_AVATAR_SPEC_VERSION}:`,
721
+ `:${NOTIFICATION_AVATAR_SPEC_VERSION - 1}:`,
722
+ ),
723
+ };
724
+ expect(previous.key).not.toBe(current.key);
725
+ writeFileSync(syncStatePath, JSON.stringify(previous));
726
+
727
+ _resetSyncAvatarStateForTests();
728
+ syncAvatarToPlatform();
729
+ await settle();
730
+ _resetSyncAvatarStateForTests();
731
+ syncAvatarToPlatform();
732
+ await settle();
733
+
734
+ expect(patches).toHaveLength(2);
735
+ expect(patches[1].body.notification_avatar_base64).toBeDefined();
736
+ expect(JSON.parse(readFileSync(syncStatePath, "utf-8")).key).toBe(
737
+ current.key,
738
+ );
739
+ });
740
+
712
741
  test("a restart re-uploads when the destination or raster changed", async () => {
713
742
  syncAvatarToPlatform();
714
743
  await settle();
@@ -157,6 +157,7 @@ export const skillLoadTool = {
157
157
  async execute(
158
158
  input: Record<string, unknown>,
159
159
  context: ToolContext,
160
+ options?: { readOnly?: boolean },
160
161
  ): Promise<ToolExecutionResult> {
161
162
  const selector = input.skill;
162
163
  if (typeof selector !== "string" || selector.trim().length === 0) {
@@ -171,13 +172,17 @@ export const skillLoadTool = {
171
172
  // command execution. Instructions are still returned so the assistant can
172
173
  // load the system-storage-cleanup skill (and any already-installed skill).
173
174
  const cleanupMode = context.diskPressureCleanupModeActive === true;
175
+ const readOnly = cleanupMode || options?.readOnly === true;
176
+ const inlineCommandStub = cleanupMode
177
+ ? INLINE_COMMAND_CLEANUP_STUB
178
+ : "[inline command not executed]";
174
179
 
175
180
  let loaded = loadSkillBySelector(selector);
176
181
 
177
182
  // Auto-install from catalog if the skill isn't found locally
178
183
  if (
179
184
  !loaded.skill &&
180
- !cleanupMode &&
185
+ !readOnly &&
181
186
  (loaded.errorCode === "not_found" || loaded.errorCode === "empty_catalog")
182
187
  ) {
183
188
  try {
@@ -273,9 +278,8 @@ export const skillLoadTool = {
273
278
  break;
274
279
  }
275
280
 
276
- // Under the disk-pressure lock, never auto-install missing includes
277
- // (that writes to the workspace). Leave them advisory ("not loaded").
278
- if (cleanupMode) {
281
+ // Read-only loads leave missing includes advisory ("not loaded").
282
+ if (readOnly) {
279
283
  break;
280
284
  }
281
285
 
@@ -349,17 +353,11 @@ export const skillLoadTool = {
349
353
  const hasInlineCommands =
350
354
  skill.inlineCommandExpansions && skill.inlineCommandExpansions.length > 0;
351
355
 
352
- if (hasInlineCommands && cleanupMode) {
353
- // Under the disk-pressure lock, loading a skill must not execute shell.
354
- // Strip inline command tokens instead of rendering them; the rest of the
355
- // instructions are still returned.
356
- body = body.replace(
357
- INLINE_COMMAND_TOKEN_PATTERN,
358
- INLINE_COMMAND_CLEANUP_STUB,
359
- );
356
+ if (hasInlineCommands && readOnly) {
357
+ body = body.replace(INLINE_COMMAND_TOKEN_PATTERN, inlineCommandStub);
360
358
  log.info(
361
359
  { skillId: skill.id },
362
- "Skipped inline command expansion during disk pressure cleanup mode",
360
+ "Skipped inline command expansion during read-only skill load",
363
361
  );
364
362
  } else if (hasInlineCommands) {
365
363
  if (skill.source === "extra" || skill.source === "plugin") {
@@ -451,12 +449,10 @@ export const skillLoadTool = {
451
449
  childLoaded.skill.inlineCommandExpansions &&
452
450
  childLoaded.skill.inlineCommandExpansions.length > 0;
453
451
 
454
- if (childHasInlineCommands && cleanupMode) {
455
- // No shell execution under the disk-pressure lock — strip the
456
- // child's inline command tokens rather than rendering them.
452
+ if (childHasInlineCommands && readOnly) {
457
453
  childBody = childBody.replace(
458
454
  INLINE_COMMAND_TOKEN_PATTERN,
459
- INLINE_COMMAND_CLEANUP_STUB,
455
+ inlineCommandStub,
460
456
  );
461
457
  } else if (childHasInlineCommands) {
462
458
  if (