@vellumai/assistant 0.12.4-dev.202609241715.2cfa60b → 0.12.4-dev.202609241859.ddab752
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/docs/desktop-browser-cli.md +3 -2
- package/package.json +1 -1
- package/src/__tests__/host-cu-proxy.test.ts +79 -0
- package/src/__tests__/host-cu-routes-targeted.test.ts +14 -5
- package/src/config/bundled-skills/computer-use/SKILL.md +20 -0
- package/src/config/bundled-skills/computer-use/TOOLS.json +11 -11
- package/src/daemon/computer-use-observation.test.ts +81 -0
- package/src/daemon/computer-use-observation.ts +23 -0
- package/src/daemon/host-cu-proxy.ts +34 -3
- package/src/desktop/desktop-computer-use.test.ts +14 -4
- package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +83 -0
- package/src/live-voice/live-voice-session.ts +6 -5
- package/src/runtime/routes/host-cu-routes.ts +3 -1
|
@@ -53,8 +53,9 @@ Non-platform assistants keep their connected-computer behavior.
|
|
|
53
53
|
Explicit `target: "connected-computer"` or `target_client_id` selects a connected
|
|
54
54
|
computer on every client. The selected target never falls back to another
|
|
55
55
|
computer when unavailable. The virtual desktop uses a fixed 1440x810 display.
|
|
56
|
-
Observations return full-screen screenshots and mouse actions use
|
|
57
|
-
|
|
56
|
+
Observations return full-screen screenshots and mouse actions use screen
|
|
57
|
+
points. Screenshot metadata uses the transport-optimized image dimensions and
|
|
58
|
+
provides per-axis conversion from image pixels to those screen points.
|
|
58
59
|
Viewer resizing scales the canvas locally without changing the desktop layout.
|
|
59
60
|
|
|
60
61
|
Observe first and pass the returned `observation_id` with each native action.
|
package/package.json
CHANGED
|
@@ -1186,6 +1186,85 @@ describe("HostCuProxy", () => {
|
|
|
1186
1186
|
|
|
1187
1187
|
expect(result.content).toContain("2560x1440 px");
|
|
1188
1188
|
expect(result.content).toContain("1280x720 pt");
|
|
1189
|
+
expect(result.content).toContain("x = round(image_x * 0.500000)");
|
|
1190
|
+
expect(result.content).toContain("y = round(image_y * 0.500000)");
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
test("derives separate screen-point scales for a downscaled screenshot", () => {
|
|
1194
|
+
setup();
|
|
1195
|
+
|
|
1196
|
+
const result = proxy.formatObservation({
|
|
1197
|
+
screenshot: "base64data",
|
|
1198
|
+
screenshotWidthPx: 835,
|
|
1199
|
+
screenshotHeightPx: 540,
|
|
1200
|
+
screenWidthPt: 1728,
|
|
1201
|
+
screenHeightPt: 1117,
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
const xScale = Number(result.content.match(/image_x \* ([\d.]+)/)?.[1]);
|
|
1205
|
+
const yScale = Number(result.content.match(/image_y \* ([\d.]+)/)?.[1]);
|
|
1206
|
+
expect(Math.round(675 * xScale)).toBe(1397);
|
|
1207
|
+
expect(Math.round(330 * yScale)).toBe(683);
|
|
1208
|
+
expect(result.content).toContain("screen points, not screenshot pixels");
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
test.each([
|
|
1212
|
+
{ capture_window_id: 12 },
|
|
1213
|
+
{ captureWindowId: 12 },
|
|
1214
|
+
{ captureDisplayId: 2 },
|
|
1215
|
+
])(
|
|
1216
|
+
"does not scale a scoped screenshot by desktop dimensions: %j",
|
|
1217
|
+
(input) => {
|
|
1218
|
+
setup();
|
|
1219
|
+
|
|
1220
|
+
const result = proxy.formatObservation(
|
|
1221
|
+
{
|
|
1222
|
+
screenshot: "base64data",
|
|
1223
|
+
screenshotWidthPx: 800,
|
|
1224
|
+
screenshotHeightPx: 600,
|
|
1225
|
+
screenWidthPt: 1600,
|
|
1226
|
+
screenHeightPt: 1200,
|
|
1227
|
+
},
|
|
1228
|
+
undefined,
|
|
1229
|
+
false,
|
|
1230
|
+
{ toolName: "computer_use_observe", input },
|
|
1231
|
+
);
|
|
1232
|
+
|
|
1233
|
+
expect(result.content).toContain("screenshot is capture-relative");
|
|
1234
|
+
expect(result.content).not.toContain("round(image_");
|
|
1235
|
+
},
|
|
1236
|
+
);
|
|
1237
|
+
|
|
1238
|
+
test.each([undefined, 0, -1, Number.NaN, Number.POSITIVE_INFINITY])(
|
|
1239
|
+
"does not invent a mapping for an unavailable or invalid dimension: %s",
|
|
1240
|
+
(screenWidthPt) => {
|
|
1241
|
+
setup();
|
|
1242
|
+
|
|
1243
|
+
const result = proxy.formatObservation({
|
|
1244
|
+
screenshot: "base64data",
|
|
1245
|
+
screenshotWidthPx: 800,
|
|
1246
|
+
screenshotHeightPx: 600,
|
|
1247
|
+
screenWidthPt,
|
|
1248
|
+
screenHeightPt: 1200,
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
expect(result.content).toContain("coordinate mapping is unavailable");
|
|
1252
|
+
expect(result.content).not.toContain("round(image_");
|
|
1253
|
+
},
|
|
1254
|
+
);
|
|
1255
|
+
|
|
1256
|
+
test("omits coordinate guidance when no screenshot accompanies dimensions", () => {
|
|
1257
|
+
setup();
|
|
1258
|
+
|
|
1259
|
+
const result = proxy.formatObservation({
|
|
1260
|
+
axTree: "Button [1]",
|
|
1261
|
+
screenshotWidthPx: 800,
|
|
1262
|
+
screenshotHeightPx: 600,
|
|
1263
|
+
screenWidthPt: 1600,
|
|
1264
|
+
screenHeightPt: 1200,
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
expect(result.content).not.toContain("coordinate");
|
|
1189
1268
|
});
|
|
1190
1269
|
|
|
1191
1270
|
test("escapes </ax-tree> in AX tree content", () => {
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
19
19
|
|
|
20
|
+
import sharp from "sharp";
|
|
21
|
+
|
|
20
22
|
// ── Module mocks ─────────────────────────────────────────────────────────────
|
|
21
23
|
// Must be registered before the host-cu-routes module is loaded.
|
|
22
24
|
|
|
@@ -487,7 +489,14 @@ describe("handleHostCuResult: request body validation", () => {
|
|
|
487
489
|
expect(pendingStore.has(requestId)).toBe(true);
|
|
488
490
|
});
|
|
489
491
|
|
|
490
|
-
test("forwards
|
|
492
|
+
test("forwards observation metadata with dimensions from the screenshot bytes", async () => {
|
|
493
|
+
const screenshot = (
|
|
494
|
+
await sharp({
|
|
495
|
+
create: { width: 835, height: 540, channels: 3, background: "white" },
|
|
496
|
+
})
|
|
497
|
+
.jpeg()
|
|
498
|
+
.toBuffer()
|
|
499
|
+
).toString("base64");
|
|
491
500
|
const requestId = "req-cu-full-observation";
|
|
492
501
|
registerPending(requestId);
|
|
493
502
|
|
|
@@ -496,7 +505,7 @@ describe("handleHostCuResult: request body validation", () => {
|
|
|
496
505
|
requestId,
|
|
497
506
|
axTree: "Button [1]",
|
|
498
507
|
axDiff: "+Button [1]",
|
|
499
|
-
screenshot
|
|
508
|
+
screenshot,
|
|
500
509
|
screenshotWidthPx: 1600,
|
|
501
510
|
screenshotHeightPx: 1000,
|
|
502
511
|
screenWidthPt: 800,
|
|
@@ -512,9 +521,9 @@ describe("handleHostCuResult: request body validation", () => {
|
|
|
512
521
|
expect(cuResolveSpy[0].payload).toMatchObject({
|
|
513
522
|
axTree: "Button [1]",
|
|
514
523
|
axDiff: "+Button [1]",
|
|
515
|
-
screenshot
|
|
516
|
-
screenshotWidthPx:
|
|
517
|
-
screenshotHeightPx:
|
|
524
|
+
screenshot,
|
|
525
|
+
screenshotWidthPx: 835,
|
|
526
|
+
screenshotHeightPx: 540,
|
|
518
527
|
screenWidthPt: 800,
|
|
519
528
|
screenHeightPt: 500,
|
|
520
529
|
executionResult: "Clicked",
|
|
@@ -37,6 +37,26 @@ The tree is walked to a limited depth to keep steps fast, and says when it was
|
|
|
37
37
|
cut off. If the element you need is not in it, call `computer_use_observe` with
|
|
38
38
|
`full_tree: true`.
|
|
39
39
|
|
|
40
|
+
## Coordinates and live editing
|
|
41
|
+
|
|
42
|
+
Prefer element IDs from the latest observation. Click, scroll and drag
|
|
43
|
+
coordinates are **screen points**, not screenshot pixels. For a desktop
|
|
44
|
+
screenshot, use the per-axis conversion in its observation metadata. If you
|
|
45
|
+
crop or resize the image, map back to the original screenshot first. Observe
|
|
46
|
+
again after the layout changes; an older image is not a current target.
|
|
47
|
+
|
|
48
|
+
During live editing, use the app's zoom and track-height controls, then inspect
|
|
49
|
+
the fresh screenshot. Avoid installing image libraries or repeatedly cropping
|
|
50
|
+
and measuring screenshot pixels just to locate a UI control or waveform.
|
|
51
|
+
Reserve scripted image or media analysis for tasks that actually require it.
|
|
52
|
+
If an action does not produce the expected visible result, check focus and the
|
|
53
|
+
coordinate mapping before repeating it.
|
|
54
|
+
|
|
55
|
+
Waveform pixels do not reveal spoken words. If an edit depends on audio you
|
|
56
|
+
cannot hear or a boundary you cannot see, ask for a timestamp, a user-positioned
|
|
57
|
+
playhead, or the source media. Do not guess the cut point or claim an edit
|
|
58
|
+
succeeded without observing its result.
|
|
59
|
+
|
|
40
60
|
## Scripting apps (macOS)
|
|
41
61
|
|
|
42
62
|
Reach for `computer_use_run_applescript` first when an app can be driven by
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
"full_tree": {
|
|
23
23
|
"type": "boolean",
|
|
24
|
-
"description": "On connected personal computers, walk the accessibility tree to full depth. The virtual desktop uses a bounded tree; use screenshot
|
|
24
|
+
"description": "On connected personal computers, walk the accessibility tree to full depth. The virtual desktop uses a bounded tree; use coordinates converted from the screenshot metadata when an element is not listed."
|
|
25
25
|
},
|
|
26
26
|
"target_client_id": {
|
|
27
27
|
"type": "string",
|
|
@@ -57,11 +57,11 @@
|
|
|
57
57
|
},
|
|
58
58
|
"x": {
|
|
59
59
|
"type": "integer",
|
|
60
|
-
"description": "X coordinate
|
|
60
|
+
"description": "X in screen points, not screenshot pixels. Apply the latest observation's coordinate mapping (fallback when no element_id)."
|
|
61
61
|
},
|
|
62
62
|
"y": {
|
|
63
63
|
"type": "integer",
|
|
64
|
-
"description": "Y coordinate
|
|
64
|
+
"description": "Y in screen points, not screenshot pixels. Apply the latest observation's coordinate mapping (fallback when no element_id)."
|
|
65
65
|
},
|
|
66
66
|
"reasoning": {
|
|
67
67
|
"type": "string",
|
|
@@ -170,11 +170,11 @@
|
|
|
170
170
|
},
|
|
171
171
|
"x": {
|
|
172
172
|
"type": "integer",
|
|
173
|
-
"description": "X coordinate
|
|
173
|
+
"description": "X in screen points, not screenshot pixels. Apply the latest observation's coordinate mapping (fallback when no element_id)."
|
|
174
174
|
},
|
|
175
175
|
"y": {
|
|
176
176
|
"type": "integer",
|
|
177
|
-
"description": "Y coordinate
|
|
177
|
+
"description": "Y in screen points, not screenshot pixels. Apply the latest observation's coordinate mapping (fallback when no element_id)."
|
|
178
178
|
},
|
|
179
179
|
"direction": {
|
|
180
180
|
"type": "string",
|
|
@@ -222,11 +222,11 @@
|
|
|
222
222
|
},
|
|
223
223
|
"x": {
|
|
224
224
|
"type": "integer",
|
|
225
|
-
"description": "Source X coordinate (fallback when no element_id)"
|
|
225
|
+
"description": "Source X in screen points, not screenshot pixels. Apply the latest observation's coordinate mapping (fallback when no element_id)."
|
|
226
226
|
},
|
|
227
227
|
"y": {
|
|
228
228
|
"type": "integer",
|
|
229
|
-
"description": "Source Y coordinate (fallback when no element_id)"
|
|
229
|
+
"description": "Source Y in screen points, not screenshot pixels. Apply the latest observation's coordinate mapping (fallback when no element_id)."
|
|
230
230
|
},
|
|
231
231
|
"to_element_id": {
|
|
232
232
|
"type": "integer",
|
|
@@ -234,11 +234,11 @@
|
|
|
234
234
|
},
|
|
235
235
|
"to_x": {
|
|
236
236
|
"type": "integer",
|
|
237
|
-
"description": "Destination X coordinate (fallback when no to_element_id)"
|
|
237
|
+
"description": "Destination X in screen points, not screenshot pixels. Apply the latest observation's coordinate mapping (fallback when no to_element_id)."
|
|
238
238
|
},
|
|
239
239
|
"to_y": {
|
|
240
240
|
"type": "integer",
|
|
241
|
-
"description": "Destination Y coordinate (fallback when no to_element_id)"
|
|
241
|
+
"description": "Destination Y in screen points, not screenshot pixels. Apply the latest observation's coordinate mapping (fallback when no to_element_id)."
|
|
242
242
|
},
|
|
243
243
|
"reasoning": {
|
|
244
244
|
"type": "string",
|
|
@@ -408,11 +408,11 @@
|
|
|
408
408
|
},
|
|
409
409
|
"x": {
|
|
410
410
|
"type": "integer",
|
|
411
|
-
"description": "click, double_click, right_click, scroll: X coordinate
|
|
411
|
+
"description": "click, double_click, right_click, scroll: X in screen points, not screenshot pixels. Apply the latest observation's coordinate mapping (fallback when no element_id)."
|
|
412
412
|
},
|
|
413
413
|
"y": {
|
|
414
414
|
"type": "integer",
|
|
415
|
-
"description": "click, double_click, right_click, scroll: Y coordinate
|
|
415
|
+
"description": "click, double_click, right_click, scroll: Y in screen points, not screenshot pixels. Apply the latest observation's coordinate mapping (fallback when no element_id)."
|
|
416
416
|
},
|
|
417
417
|
"direction": {
|
|
418
418
|
"type": "string",
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import sharp from "sharp";
|
|
4
|
+
|
|
5
|
+
import { parseImageDimensions } from "../context/image-dimensions.js";
|
|
6
|
+
import { prepareComputerUseObservation } from "./computer-use-observation.js";
|
|
7
|
+
import { HostCuProxy } from "./host-cu-proxy.js";
|
|
8
|
+
|
|
9
|
+
async function screenshot(width: number, height: number): Promise<string> {
|
|
10
|
+
const bytes = await sharp({
|
|
11
|
+
create: { width, height, channels: 3, background: "white" },
|
|
12
|
+
})
|
|
13
|
+
.jpeg()
|
|
14
|
+
.toBuffer();
|
|
15
|
+
return bytes.toString("base64");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("computer-use observation transport", () => {
|
|
19
|
+
test("derives metadata from emitted screenshot bytes", async () => {
|
|
20
|
+
const image = await screenshot(835, 540);
|
|
21
|
+
const observation = await prepareComputerUseObservation({
|
|
22
|
+
screenshot: image,
|
|
23
|
+
screenshotWidthPx: 1728,
|
|
24
|
+
screenshotHeightPx: 1117,
|
|
25
|
+
screenWidthPt: 1728,
|
|
26
|
+
screenHeightPt: 1117,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
expect(observation.screenshot).toBe(image);
|
|
30
|
+
expect(observation.screenshotWidthPx).toBe(835);
|
|
31
|
+
expect(observation.screenshotHeightPx).toBe(540);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("formats the optimized dimensions on the virtual desktop path", async () => {
|
|
35
|
+
const image = await screenshot(3200, 1800);
|
|
36
|
+
const proxy = new HostCuProxy();
|
|
37
|
+
const result = await proxy.executeLocal(
|
|
38
|
+
"computer_use_observe",
|
|
39
|
+
{},
|
|
40
|
+
async () => ({
|
|
41
|
+
screenshot: image,
|
|
42
|
+
screenshotWidthPx: 3200,
|
|
43
|
+
screenshotHeightPx: 1800,
|
|
44
|
+
screenWidthPt: 1600,
|
|
45
|
+
screenHeightPt: 900,
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const block = result.contentBlocks?.[0];
|
|
50
|
+
expect(block?.type).toBe("image");
|
|
51
|
+
if (block?.type !== "image" || block.source.type !== "base64") {
|
|
52
|
+
throw new Error("Expected screenshot bytes");
|
|
53
|
+
}
|
|
54
|
+
const dimensions = parseImageDimensions(block.source);
|
|
55
|
+
expect(dimensions).toEqual({ width: 1568, height: 882 });
|
|
56
|
+
const repeated = await prepareComputerUseObservation({
|
|
57
|
+
screenshot: block.source.data,
|
|
58
|
+
});
|
|
59
|
+
expect(repeated.screenshot).toBe(block.source.data);
|
|
60
|
+
expect(result.content).toContain("1568x882 px");
|
|
61
|
+
expect(result.content).toContain("x = round(image_x * 1.020408)");
|
|
62
|
+
expect(result.content).toContain("y = round(image_y * 1.020408)");
|
|
63
|
+
proxy.dispose();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("does not trust dimension metadata when image bytes cannot be read", async () => {
|
|
67
|
+
const observation = await prepareComputerUseObservation({
|
|
68
|
+
screenshot: "invalid",
|
|
69
|
+
screenshotWidthPx: 800,
|
|
70
|
+
screenshotHeightPx: 600,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(observation.screenshotWidthPx).toBeUndefined();
|
|
74
|
+
expect(observation.screenshotHeightPx).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("leaves observations without a screenshot alone", async () => {
|
|
78
|
+
const observation = { axTree: "[1] AXButton Save" };
|
|
79
|
+
expect(await prepareComputerUseObservation(observation)).toBe(observation);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { optimizeImageForTransport } from "../agent/image-optimize.js";
|
|
2
|
+
import { parseImageDimensions } from "../context/image-dimensions.js";
|
|
3
|
+
import type { CuObservationResult } from "./host-cu-proxy.js";
|
|
4
|
+
|
|
5
|
+
/** Keep coordinate metadata aligned with the image sent to the model. */
|
|
6
|
+
export async function prepareComputerUseObservation(
|
|
7
|
+
observation: CuObservationResult,
|
|
8
|
+
): Promise<CuObservationResult> {
|
|
9
|
+
if (!observation.screenshot) {
|
|
10
|
+
return observation;
|
|
11
|
+
}
|
|
12
|
+
const image = await optimizeImageForTransport(
|
|
13
|
+
observation.screenshot,
|
|
14
|
+
"image/jpeg",
|
|
15
|
+
);
|
|
16
|
+
const dimensions = parseImageDimensions(image.data, image.mediaType);
|
|
17
|
+
return {
|
|
18
|
+
...observation,
|
|
19
|
+
screenshot: image.data,
|
|
20
|
+
screenshotWidthPx: dimensions?.width,
|
|
21
|
+
screenshotHeightPx: dimensions?.height,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -28,6 +28,7 @@ import { POINT_AT_PROXY_TOOL } from "../tools/computer-use/skill-proxy-bridge.js
|
|
|
28
28
|
import type { ToolExecutionResult } from "../tools/types.js";
|
|
29
29
|
import { AssistantError, ErrorCode } from "../util/errors.js";
|
|
30
30
|
import { getLogger } from "../util/logger.js";
|
|
31
|
+
import { prepareComputerUseObservation } from "./computer-use-observation.js";
|
|
31
32
|
import { resolveHostCuTarget } from "./host-cu-target.js";
|
|
32
33
|
import { bestEffortModeSessionTracking } from "./mode-session-tracking.js";
|
|
33
34
|
|
|
@@ -615,7 +616,7 @@ export class HostCuProxy {
|
|
|
615
616
|
this.recordAction(toolName, input, reasoning);
|
|
616
617
|
this._previousAXTree = undefined;
|
|
617
618
|
this._consecutiveUnchangedSteps = 0;
|
|
618
|
-
const observation = await execute();
|
|
619
|
+
const observation = await prepareComputerUseObservation(await execute());
|
|
619
620
|
return this.formatObservation(observation, undefined, false, {
|
|
620
621
|
toolName,
|
|
621
622
|
input,
|
|
@@ -791,7 +792,7 @@ export class HostCuProxy {
|
|
|
791
792
|
);
|
|
792
793
|
}
|
|
793
794
|
|
|
794
|
-
const screenshotMeta = this.formatScreenshotMetadata(obs);
|
|
795
|
+
const screenshotMeta = this.formatScreenshotMetadata(obs, action);
|
|
795
796
|
if (screenshotMeta.length > 0) {
|
|
796
797
|
parts.push("");
|
|
797
798
|
parts.push(...screenshotMeta);
|
|
@@ -898,7 +899,10 @@ export class HostCuProxy {
|
|
|
898
899
|
}
|
|
899
900
|
}
|
|
900
901
|
|
|
901
|
-
private formatScreenshotMetadata(
|
|
902
|
+
private formatScreenshotMetadata(
|
|
903
|
+
obs: CuObservationResult,
|
|
904
|
+
action: ActionIdentity | undefined,
|
|
905
|
+
): string[] {
|
|
902
906
|
if (!obs.screenshot) {
|
|
903
907
|
return [];
|
|
904
908
|
}
|
|
@@ -914,6 +918,33 @@ export class HostCuProxy {
|
|
|
914
918
|
`Screen metadata: ${obs.screenWidthPt}x${obs.screenHeightPt} pt`,
|
|
915
919
|
);
|
|
916
920
|
}
|
|
921
|
+
if (action && hasCaptureTarget(action.input)) {
|
|
922
|
+
lines.push(
|
|
923
|
+
"This screenshot is capture-relative. Full-screen dimensions do not map its pixels to action coordinates. Use accessibility element IDs, or request an unscoped desktop observation before using coordinates.",
|
|
924
|
+
);
|
|
925
|
+
} else if (
|
|
926
|
+
[
|
|
927
|
+
obs.screenshotWidthPx,
|
|
928
|
+
obs.screenshotHeightPx,
|
|
929
|
+
obs.screenWidthPt,
|
|
930
|
+
obs.screenHeightPt,
|
|
931
|
+
].every(
|
|
932
|
+
(dimension) =>
|
|
933
|
+
typeof dimension === "number" &&
|
|
934
|
+
Number.isFinite(dimension) &&
|
|
935
|
+
dimension > 0,
|
|
936
|
+
)
|
|
937
|
+
) {
|
|
938
|
+
const scaleX = obs.screenWidthPt! / obs.screenshotWidthPx!;
|
|
939
|
+
const scaleY = obs.screenHeightPt! / obs.screenshotHeightPx!;
|
|
940
|
+
lines.push(
|
|
941
|
+
`Action coordinates are screen points, not screenshot pixels. Convert this image's pixels: x = round(image_x * ${scaleX.toFixed(6)}), y = round(image_y * ${scaleY.toFixed(6)}). Prefer accessibility element IDs when available.`,
|
|
942
|
+
);
|
|
943
|
+
} else {
|
|
944
|
+
lines.push(
|
|
945
|
+
"Screenshot-to-screen coordinate mapping is unavailable. Use accessibility element IDs or obtain an observation with both screenshot and screen dimensions before using coordinates.",
|
|
946
|
+
);
|
|
947
|
+
}
|
|
917
948
|
return lines;
|
|
918
949
|
}
|
|
919
950
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, expect, mock, spyOn, test } from "bun:test";
|
|
2
2
|
|
|
3
|
+
import sharp from "sharp";
|
|
4
|
+
|
|
3
5
|
import { run as click } from "../config/bundled-skills/computer-use/tools/computer-use-click.js";
|
|
4
6
|
import * as configLoader from "../config/loader.js";
|
|
5
7
|
import { HostCuProxy } from "../daemon/host-cu-proxy.js";
|
|
@@ -14,12 +16,20 @@ import {
|
|
|
14
16
|
} from "./desktop-computer-use.js";
|
|
15
17
|
import * as sessionManager from "./desktop-session-manager.js";
|
|
16
18
|
|
|
19
|
+
const SCREENSHOT = (
|
|
20
|
+
await sharp({
|
|
21
|
+
create: { width: 1440, height: 810, channels: 3, background: "white" },
|
|
22
|
+
})
|
|
23
|
+
.jpeg()
|
|
24
|
+
.toBuffer()
|
|
25
|
+
).toString("base64");
|
|
26
|
+
|
|
17
27
|
function driver(userGuidance?: string) {
|
|
18
28
|
return {
|
|
19
29
|
input: mock(async (_args: string[], _signal?: AbortSignal) => {}),
|
|
20
30
|
capture: mock(async (_signal: AbortSignal) => ({
|
|
21
31
|
userGuidance,
|
|
22
|
-
screenshot:
|
|
32
|
+
screenshot: SCREENSHOT,
|
|
23
33
|
screenshotWidthPx: 1440,
|
|
24
34
|
screenshotHeightPx: 810,
|
|
25
35
|
screenWidthPt: 1440,
|
|
@@ -52,7 +62,7 @@ describe("virtual desktop computer use", () => {
|
|
|
52
62
|
signal(),
|
|
53
63
|
backend,
|
|
54
64
|
);
|
|
55
|
-
expect(result.screenshot).toBe(
|
|
65
|
+
expect(result.screenshot).toBe(SCREENSHOT);
|
|
56
66
|
}
|
|
57
67
|
expect(backend.input).not.toHaveBeenCalled();
|
|
58
68
|
expect(backend.capture).toHaveBeenCalledTimes(2);
|
|
@@ -161,7 +171,7 @@ describe("virtual desktop computer use", () => {
|
|
|
161
171
|
);
|
|
162
172
|
expect(backend.input).toHaveBeenCalledTimes(1);
|
|
163
173
|
expect(result.executionError).toBe("Input failed");
|
|
164
|
-
expect(result.screenshot).toBe(
|
|
174
|
+
expect(result.screenshot).toBe(SCREENSHOT);
|
|
165
175
|
});
|
|
166
176
|
|
|
167
177
|
test("done releases only its conversation without starting the desktop", async () => {
|
|
@@ -316,7 +326,7 @@ describe("virtual desktop computer use", () => {
|
|
|
316
326
|
source: {
|
|
317
327
|
type: "base64",
|
|
318
328
|
media_type: "image/jpeg",
|
|
319
|
-
data:
|
|
329
|
+
data: SCREENSHOT,
|
|
320
330
|
},
|
|
321
331
|
},
|
|
322
332
|
]);
|
|
@@ -1771,6 +1771,89 @@ describe("LiveVoiceSession Flux end-of-turn during the STT dial", () => {
|
|
|
1771
1771
|
await session.close("client_end");
|
|
1772
1772
|
}, 10_000);
|
|
1773
1773
|
|
|
1774
|
+
test("rearms an empty fallback cycle while the previous reply is active", async () => {
|
|
1775
|
+
const abort = mock();
|
|
1776
|
+
const { frames, session, transcribers, turnCalls } = createHarness({
|
|
1777
|
+
fluxConfig: FLUX_ON,
|
|
1778
|
+
startVoiceTurn: async (turnOptions) => {
|
|
1779
|
+
turnOptions.callbacks?.assistant_text_delta?.(
|
|
1780
|
+
makeTextDelta("Working on the edit."),
|
|
1781
|
+
);
|
|
1782
|
+
return { turnId: "editing-turn", abort };
|
|
1783
|
+
},
|
|
1784
|
+
});
|
|
1785
|
+
|
|
1786
|
+
try {
|
|
1787
|
+
await session.start();
|
|
1788
|
+
await waitFor(() => transcribers.length === 1);
|
|
1789
|
+
const transcriber = transcribers[0]!;
|
|
1790
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
1791
|
+
transcriber.startOfTurn(0);
|
|
1792
|
+
transcriber.endOfTurn("edit the clip", 0);
|
|
1793
|
+
await waitFor(() => turnCalls.length === 1);
|
|
1794
|
+
|
|
1795
|
+
const speechStarts = countFrames(frames, "speech_started");
|
|
1796
|
+
await session.handleBinaryAudio(SUSTAINED_LOUD_CHUNK);
|
|
1797
|
+
// No provider speech start or transcript arrives for this local onset.
|
|
1798
|
+
await waitFor(() => transcriber.stopped);
|
|
1799
|
+
await waitFor(
|
|
1800
|
+
() => transcribers.length >= 2,
|
|
1801
|
+
"An empty fallback cycle blocked the replacement stream behind the reply",
|
|
1802
|
+
);
|
|
1803
|
+
expect(countFrames(frames, "utterance_discarded")).toBe(1);
|
|
1804
|
+
expect(countFrames(frames, "speech_started")).toBe(speechStarts);
|
|
1805
|
+
expect(countFrames(frames, "turn_cancelled")).toBe(0);
|
|
1806
|
+
expect(abort).not.toHaveBeenCalled();
|
|
1807
|
+
expect(turnCalls).toHaveLength(1);
|
|
1808
|
+
|
|
1809
|
+
const replacement = transcribers.at(-1)!;
|
|
1810
|
+
await session.handleBinaryAudio(SUSTAINED_LOUD_CHUNK);
|
|
1811
|
+
expect(replacement.received.length).toBeGreaterThan(0);
|
|
1812
|
+
replacement.startOfTurn(0);
|
|
1813
|
+
await waitFor(() => countFrames(frames, "turn_cancelled") === 1);
|
|
1814
|
+
expect(countFrames(frames, "speech_started")).toBe(speechStarts + 1);
|
|
1815
|
+
expect(abort).toHaveBeenCalledTimes(1);
|
|
1816
|
+
|
|
1817
|
+
replacement.endOfTurn("stop editing", 0);
|
|
1818
|
+
await waitFor(() => turnCalls.length === 2);
|
|
1819
|
+
expect(turnCalls[1]?.content).toBe("stop editing");
|
|
1820
|
+
} finally {
|
|
1821
|
+
await session.close("client_end");
|
|
1822
|
+
}
|
|
1823
|
+
}, 10_000);
|
|
1824
|
+
|
|
1825
|
+
test("keeps a nonempty fallback transcript queued behind the previous reply", async () => {
|
|
1826
|
+
const abort = mock();
|
|
1827
|
+
const { frames, session, transcribers, turnCalls } = createHarness({
|
|
1828
|
+
fluxConfig: FLUX_ON,
|
|
1829
|
+
startVoiceTurn: async () => ({ turnId: "editing-turn", abort }),
|
|
1830
|
+
});
|
|
1831
|
+
|
|
1832
|
+
try {
|
|
1833
|
+
await session.start();
|
|
1834
|
+
await waitFor(() => transcribers.length === 1);
|
|
1835
|
+
const transcriber = transcribers[0]!;
|
|
1836
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
1837
|
+
transcriber.startOfTurn(0);
|
|
1838
|
+
transcriber.endOfTurn("edit the clip", 0);
|
|
1839
|
+
await waitFor(() => turnCalls.length === 1);
|
|
1840
|
+
|
|
1841
|
+
transcriber.pendingFlushText = "show me the result";
|
|
1842
|
+
await session.handleBinaryAudio(SUSTAINED_LOUD_CHUNK);
|
|
1843
|
+
await waitFor(() => transcriber.stopped);
|
|
1844
|
+
await flushAsyncCallbacks();
|
|
1845
|
+
expect(turnCalls).toHaveLength(1);
|
|
1846
|
+
expect(countFrames(frames, "utterance_discarded")).toBe(0);
|
|
1847
|
+
expect(abort).not.toHaveBeenCalled();
|
|
1848
|
+
|
|
1849
|
+
turnCalls[0]?.callbacks?.message_complete?.(makeMessageComplete());
|
|
1850
|
+
await waitFor(() => turnCalls.length === 2);
|
|
1851
|
+
expect(turnCalls[1]?.content).toBe("show me the result");
|
|
1852
|
+
} finally {
|
|
1853
|
+
await session.close("client_end");
|
|
1854
|
+
}
|
|
1855
|
+
}, 10_000);
|
|
1856
|
+
|
|
1774
1857
|
test("routes a closing stream's flush into the turn it released", async () => {
|
|
1775
1858
|
const { session, transcribers, turnCalls } = createHarness({
|
|
1776
1859
|
fluxConfig: FLUX_ON,
|
|
@@ -5945,11 +5945,6 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5945
5945
|
) {
|
|
5946
5946
|
return;
|
|
5947
5947
|
}
|
|
5948
|
-
// One assistant turn at a time: a server_vad utterance that closes while
|
|
5949
|
-
// the previous turn is still speaking waits; rearmAfterTurn retries it.
|
|
5950
|
-
if (this.activeAssistantTurn) {
|
|
5951
|
-
return;
|
|
5952
|
-
}
|
|
5953
5948
|
if (utterance.phase !== "transcriber_closed") {
|
|
5954
5949
|
return;
|
|
5955
5950
|
}
|
|
@@ -5972,6 +5967,12 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5972
5967
|
return;
|
|
5973
5968
|
}
|
|
5974
5969
|
|
|
5970
|
+
// Empty cycles must rearm input even during a reply so provider speech
|
|
5971
|
+
// starts can still interrupt it. Only a real transcript waits its turn.
|
|
5972
|
+
if (this.activeAssistantTurn) {
|
|
5973
|
+
return;
|
|
5974
|
+
}
|
|
5975
|
+
|
|
5975
5976
|
await this.launchAssistantTurn(utterance, content);
|
|
5976
5977
|
}
|
|
5977
5978
|
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
|
|
9
|
+
import { prepareComputerUseObservation } from "../../daemon/computer-use-observation.js";
|
|
9
10
|
import { findConversation } from "../../daemon/conversation-registry.js";
|
|
10
11
|
import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
|
|
11
12
|
import { SAME_ACTOR_FORBIDDEN_DESCRIPTION } from "../auth/same-actor.js";
|
|
@@ -112,7 +113,7 @@ async function handleHostCuResult({ body, headers }: RouteHandlerArgs) {
|
|
|
112
113
|
throw new NotFoundError("No host CU proxy for conversation");
|
|
113
114
|
}
|
|
114
115
|
|
|
115
|
-
|
|
116
|
+
const observation = await prepareComputerUseObservation({
|
|
116
117
|
axTree,
|
|
117
118
|
axDiff,
|
|
118
119
|
screenshot,
|
|
@@ -126,6 +127,7 @@ async function handleHostCuResult({ body, headers }: RouteHandlerArgs) {
|
|
|
126
127
|
userGuidance,
|
|
127
128
|
timings,
|
|
128
129
|
});
|
|
130
|
+
conversation.hostCuProxy.processObservation(requestId, observation);
|
|
129
131
|
|
|
130
132
|
return { accepted: true };
|
|
131
133
|
}
|