@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.
@@ -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 the same
57
- screen coordinates.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.4-dev.202609241715.2cfa60b",
3
+ "version": "0.12.4-dev.202609241859.ddab752",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 a full observation payload unchanged", async () => {
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: "base64png",
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: "base64png",
516
- screenshotWidthPx: 1600,
517
- screenshotHeightPx: 1000,
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 coordinates when an element is not listed."
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 on screen (fallback when no element_id)"
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 on screen (fallback when no element_id)"
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 on screen (fallback when no element_id)"
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 on screen (fallback when no element_id)"
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 on screen (fallback when no element_id)"
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 on screen (fallback when no element_id)"
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(obs: CuObservationResult): string[] {
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: "anBlZw==",
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("anBlZw==");
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("anBlZw==");
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: "anBlZw==",
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
- conversation.hostCuProxy.processObservation(requestId, {
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
  }