@vellumai/assistant 0.12.3-dev.202609222214.c4d457d → 0.12.3-dev.202609222313.c4916bb
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/node_modules/@vellumai/avatar-manifest/src/__tests__/notification-avatar.test.ts +48 -4
- package/node_modules/@vellumai/avatar-manifest/src/notification-avatar.ts +12 -8
- package/package.json +1 -1
- package/src/__tests__/skill-load-inline-command.test.ts +35 -0
- package/src/avatar/notification-avatar.test.ts +42 -34
- package/src/avatar/notification-avatar.ts +1 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +63 -1
- package/src/calls/voice-screen-annotation.ts +44 -0
- package/src/calls/voice-session-bridge.ts +22 -0
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +27 -0
- package/src/live-voice/__tests__/protocol.test.ts +22 -0
- package/src/live-voice/live-voice-session.ts +7 -6
- package/src/live-voice/protocol.ts +16 -5
- package/src/platform/sync-avatar.test.ts +29 -0
- package/src/tools/skills/load.ts +13 -17
|
@@ -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(
|
|
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
|
|
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 =
|
|
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/
|
|
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
|
-
*
|
|
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
|
@@ -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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
"
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
[
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
2088
|
-
*
|
|
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
|
|
280
|
-
*
|
|
281
|
-
*
|
|
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
|
: {}),
|
|
@@ -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();
|
package/src/tools/skills/load.ts
CHANGED
|
@@ -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
|
-
!
|
|
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
|
-
//
|
|
277
|
-
|
|
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 &&
|
|
353
|
-
|
|
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
|
|
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 &&
|
|
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
|
-
|
|
455
|
+
inlineCommandStub,
|
|
460
456
|
);
|
|
461
457
|
} else if (childHasInlineCommands) {
|
|
462
458
|
if (
|