@vellumai/assistant 0.12.1-dev.202609141913.5e16607 → 0.12.1-dev.202609142113.d1590bc

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.
Files changed (118) hide show
  1. package/ARCHITECTURE.md +9 -9
  2. package/openapi.yaml +35 -2
  3. package/package.json +2 -1
  4. package/src/__tests__/anthropic-provider.test.ts +0 -63
  5. package/src/__tests__/assistant-event-hub-machine-name.test.ts +6 -7
  6. package/src/__tests__/assistant-event-hub.test.ts +44 -0
  7. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +1 -1
  8. package/src/__tests__/computer-use-tools.test.ts +2 -2
  9. package/src/__tests__/heartbeat-service.test.ts +56 -40
  10. package/src/__tests__/host-cu-proxy.test.ts +539 -0
  11. package/src/__tests__/list-messages-camera-frame.test.ts +121 -0
  12. package/src/__tests__/oauth-commands-routes.test.ts +113 -0
  13. package/src/__tests__/oauth-provider-profiles.test.ts +18 -0
  14. package/src/__tests__/openai-provider.test.ts +1 -0
  15. package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -0
  16. package/src/__tests__/qdrant-manager.test.ts +132 -0
  17. package/src/__tests__/scaffold-managed-skill-tool.test.ts +229 -6
  18. package/src/__tests__/skills.test.ts +9 -4
  19. package/src/__tests__/test-support/computer-use-skill-harness.ts +3 -2
  20. package/src/__tests__/unicode.test.ts +13 -0
  21. package/src/acp/agent-process.ts +2 -1
  22. package/src/agent/loop.ts +1 -0
  23. package/src/api/events/user-message-echo.ts +7 -1
  24. package/src/api/responses/conversation-message.ts +5 -0
  25. package/src/avatar/identity-avatar.ts +7 -2
  26. package/src/config/bundled-skills/computer-use/SKILL.md +20 -2
  27. package/src/config/bundled-skills/computer-use/TOOLS.json +99 -0
  28. package/src/config/bundled-skills/computer-use/tools/computer-use-sequence.ts +12 -0
  29. package/src/config/bundled-skills/media-processing/services/preprocess.ts +2 -1
  30. package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +2 -1
  31. package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +2 -1
  32. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +2 -1
  33. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +2 -1
  34. package/src/config/bundled-skills/skill-management/TOOLS.json +4 -0
  35. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +5 -2
  36. package/src/credential-health/credential-health-service.ts +11 -17
  37. package/src/daemon/conversation-error.ts +3 -2
  38. package/src/daemon/conversation-media-retry.ts +2 -1
  39. package/src/daemon/conversation-runtime-assembly.ts +2 -1
  40. package/src/daemon/conversation-surfaces.ts +5 -1
  41. package/src/daemon/dictation-profile-store.ts +2 -1
  42. package/src/daemon/handlers/shared.ts +2 -1
  43. package/src/daemon/handlers/skills.ts +6 -3
  44. package/src/daemon/host-cu-proxy.ts +186 -19
  45. package/src/daemon/host-proxy-preactivation.ts +2 -1
  46. package/src/daemon/reaction-history-render.ts +2 -1
  47. package/src/heartbeat/heartbeat-service.ts +21 -3
  48. package/src/live-voice/__tests__/live-voice-sight-frame-echo.test.ts +238 -0
  49. package/src/live-voice/live-voice-photo.ts +25 -6
  50. package/src/oauth/AGENTS.md +2 -0
  51. package/src/oauth/byo-connection.test.ts +60 -0
  52. package/src/oauth/scope-utils.ts +24 -0
  53. package/src/oauth/seed-providers.ts +39 -25
  54. package/src/persistence/conversation-queries.ts +2 -2
  55. package/src/persistence/embeddings/qdrant-manager.ts +163 -18
  56. package/src/plugins/defaults/memory/__tests__/memory-retrospective-prompt.test.ts +5 -0
  57. package/src/plugins/defaults/memory/context-search/agent-runner.ts +3 -2
  58. package/src/plugins/defaults/memory/context-search/format.ts +3 -2
  59. package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +6 -3
  60. package/src/plugins/defaults/memory/context-search/sources/workspace.ts +3 -2
  61. package/src/plugins/defaults/memory/graph/capability-seed.ts +2 -1
  62. package/src/plugins/defaults/memory/host-utils.ts +10 -0
  63. package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +1 -1
  64. package/src/plugins/defaults/memory/startup.ts +22 -1
  65. package/src/plugins/defaults/memory/substrate/page-index.ts +6 -1
  66. package/src/plugins/defaults/memory/v1/graph/consolidation.ts +4 -2
  67. package/src/plugins/defaults/memory/v1/graph/extraction.ts +4 -2
  68. package/src/plugins/defaults/memory/v1/graph/retriever.ts +4 -1
  69. package/src/plugins/defaults/memory/v2/reranker.ts +6 -1
  70. package/src/plugins/defaults/memory/v3/__tests__/card.test.ts +24 -0
  71. package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +39 -3
  72. package/src/plugins/defaults/memory/v3/__tests__/sections.test.ts +26 -0
  73. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +30 -0
  74. package/src/plugins/defaults/memory/v3/card.ts +3 -2
  75. package/src/plugins/defaults/memory/v3/pool-select.ts +5 -3
  76. package/src/plugins/defaults/memory/v3/sections.ts +9 -3
  77. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +5 -2
  78. package/src/providers/__tests__/retry-outbound-sanitize.test.ts +176 -0
  79. package/src/providers/anthropic/client.ts +0 -47
  80. package/src/providers/openai/__tests__/chat-completions-output-token-limit.test.ts +141 -0
  81. package/src/providers/openai/chat-completions-provider.ts +20 -1
  82. package/src/providers/openrouter/client.ts +20 -0
  83. package/src/providers/outbound-request-sanitize.ts +96 -0
  84. package/src/providers/retry.ts +24 -3
  85. package/src/providers/unparseable-tool-args.ts +5 -1
  86. package/src/runtime/AGENTS.md +1 -1
  87. package/src/runtime/__tests__/host-observe.test.ts +40 -1
  88. package/src/runtime/agent-wake.ts +2 -1
  89. package/src/runtime/assistant-event-hub.ts +21 -3
  90. package/src/runtime/host-observe.ts +11 -1
  91. package/src/runtime/routes/conversation-routes.ts +16 -3
  92. package/src/runtime/routes/diagnostics-routes.ts +6 -1
  93. package/src/runtime/routes/events-routes.ts +4 -0
  94. package/src/runtime/routes/host-cu-routes.ts +6 -0
  95. package/src/runtime/routes/oauth-commands-routes.ts +36 -0
  96. package/src/runtime/routes/workspace-routes.test.ts +55 -26
  97. package/src/runtime/routes/workspace-routes.ts +46 -63
  98. package/src/runtime/routes/workspace-tree-walk.test.ts +244 -0
  99. package/src/runtime/routes/workspace-tree-walk.ts +240 -0
  100. package/src/runtime/routes/workspace-utils.ts +17 -0
  101. package/src/schedule/run-script.ts +2 -1
  102. package/src/schedule/schedule-store.ts +9 -2
  103. package/src/security/untrusted-content.ts +3 -1
  104. package/src/tools/__tests__/side-effect-abort-guard.test.ts +1 -0
  105. package/src/tools/browser/browser-execution.ts +1 -1
  106. package/src/tools/browser/cdp-client/accessibility-snapshot.ts +9 -3
  107. package/src/tools/browser/cdp-client/cdp-inspect/discovery.ts +2 -1
  108. package/src/tools/browser/cdp-client/extension-cdp-client.ts +3 -2
  109. package/src/tools/browser/network-recorder.ts +2 -1
  110. package/src/tools/computer-use/definitions.ts +117 -0
  111. package/src/tools/filesystem/search.ts +2 -1
  112. package/src/tools/network/web-search.ts +6 -1
  113. package/src/tools/skills/scaffold-managed.ts +61 -3
  114. package/src/types/host-capabilities.ts +5 -3
  115. package/src/util/unicode.ts +15 -1
  116. package/src/watch/watch-timeline.ts +2 -2
  117. package/src/workflows/run-manager.ts +2 -1
  118. package/src/workspace/git-service.ts +1 -1
@@ -36,10 +36,36 @@ mock.module("../runtime/assistant-event-hub.js", () => ({
36
36
  },
37
37
  }));
38
38
 
39
+ // Captured `log.info` payloads, so the step-timings line can be asserted on.
40
+ const infoLogs: { payload: Record<string, unknown>; message: string }[] = [];
41
+
42
+ const realLogger = await import("../util/logger.js");
43
+ mock.module("../util/logger.js", () => ({
44
+ ...realLogger,
45
+ getLogger: () => ({
46
+ info: (payload: unknown, message: unknown) => {
47
+ infoLogs.push({
48
+ payload: (payload ?? {}) as Record<string, unknown>,
49
+ message: String(message),
50
+ });
51
+ },
52
+ warn: () => {},
53
+ error: () => {},
54
+ debug: () => {},
55
+ trace: () => {},
56
+ }),
57
+ }));
58
+
39
59
  // Use the REAL pending-interactions module — the proxy self-registers here.
40
60
  const pendingInteractions = await import("../runtime/pending-interactions.js");
41
61
  const { HostCuProxy } = await import("../daemon/host-cu-proxy.js");
42
62
 
63
+ function stepTimingsLogs(): Record<string, unknown>[] {
64
+ return infoLogs
65
+ .filter((entry) => entry.message === "Host CU step timings")
66
+ .map((entry) => entry.payload);
67
+ }
68
+
43
69
  describe("HostCuProxy", () => {
44
70
  let proxy: InstanceType<typeof HostCuProxy>;
45
71
 
@@ -48,6 +74,7 @@ describe("HostCuProxy", () => {
48
74
  sentOptions.length = 0;
49
75
  mockHasClient = false;
50
76
  mockClients = [];
77
+ infoLogs.length = 0;
51
78
  pendingInteractions.clear();
52
79
  proxy = new HostCuProxy(maxSteps);
53
80
  }
@@ -164,6 +191,124 @@ describe("HostCuProxy", () => {
164
191
  });
165
192
  });
166
193
 
194
+ // -------------------------------------------------------------------------
195
+ // Step timings
196
+ // -------------------------------------------------------------------------
197
+
198
+ describe("step timings", () => {
199
+ test("logs helper timings alongside the proxy round trip", async () => {
200
+ setup();
201
+ proxy.recordAction("computer_use_click", { element_id: 42 });
202
+
203
+ const resultPromise = proxy.request(
204
+ "computer_use_click",
205
+ { element_id: 42 },
206
+ "session-1",
207
+ 1,
208
+ );
209
+ const sent = sentMessages[0] as Record<string, unknown>;
210
+
211
+ proxy.processObservation(sent.requestId as string, {
212
+ axTree: "Button [1]",
213
+ executionResult: "Clicked element 42",
214
+ timings: { total: 420, axWalk: 120, capture: 240 },
215
+ });
216
+
217
+ const result = await resultPromise;
218
+ const logs = stepTimingsLogs();
219
+ expect(logs).toHaveLength(1);
220
+ expect(logs[0]).toMatchObject({
221
+ requestId: sent.requestId,
222
+ toolName: "computer_use_click",
223
+ step: 1,
224
+ helper: { total: 420, axWalk: 120, capture: 240 },
225
+ });
226
+ expect(typeof logs[0].roundTripMs).toBe("number");
227
+
228
+ // The timings are observability only: nothing reaches the model.
229
+ expect(result.content).not.toContain("420");
230
+ expect(result.content).not.toContain("roundTripMs");
231
+ });
232
+
233
+ test("labels each line with its own request, not the latest dispatch", async () => {
234
+ setup();
235
+
236
+ // One model response can dispatch several CU tools, and the agent loop
237
+ // runs them concurrently. Dispatch two, then answer the first one last.
238
+ proxy.recordAction("computer_use_click", { element_id: 42 });
239
+ const firstPromise = proxy.request(
240
+ "computer_use_click",
241
+ { element_id: 42 },
242
+ "session-1",
243
+ proxy.stepCount,
244
+ );
245
+ proxy.recordAction("computer_use_type_text", { text: "hello" });
246
+ const secondPromise = proxy.request(
247
+ "computer_use_type_text",
248
+ { text: "hello" },
249
+ "session-1",
250
+ proxy.stepCount,
251
+ );
252
+
253
+ const first = sentMessages[0] as Record<string, unknown>;
254
+ const second = sentMessages[1] as Record<string, unknown>;
255
+
256
+ proxy.processObservation(second.requestId as string, {
257
+ axTree: "Field [2]",
258
+ timings: { total: 200 },
259
+ });
260
+ proxy.processObservation(first.requestId as string, {
261
+ axTree: "Button [1]",
262
+ timings: { total: 400 },
263
+ });
264
+ await Promise.all([firstPromise, secondPromise]);
265
+
266
+ const logs = stepTimingsLogs();
267
+ const firstLog = logs.find((l) => l.requestId === first.requestId);
268
+ const secondLog = logs.find((l) => l.requestId === second.requestId);
269
+
270
+ // The click landed after the type_text, so reading current proxy state
271
+ // would have filed it under computer_use_type_text at step 2.
272
+ expect(firstLog).toMatchObject({
273
+ toolName: "computer_use_click",
274
+ step: 1,
275
+ helper: { total: 400 },
276
+ });
277
+ expect(secondLog).toMatchObject({
278
+ toolName: "computer_use_type_text",
279
+ step: 2,
280
+ helper: { total: 200 },
281
+ });
282
+ });
283
+
284
+ test("an observation without timings resolves normally", async () => {
285
+ setup();
286
+
287
+ const resultPromise = proxy.request(
288
+ "computer_use_click",
289
+ { element_id: 7 },
290
+ "session-1",
291
+ 1,
292
+ );
293
+ const sent = sentMessages[0] as Record<string, unknown>;
294
+
295
+ proxy.processObservation(sent.requestId as string, {
296
+ axTree: "Button [1]",
297
+ executionResult: "Clicked element 7",
298
+ });
299
+
300
+ const result = await resultPromise;
301
+ expect(result.isError).toBe(false);
302
+ expect(result.content).toContain("Clicked element 7");
303
+ expect(result.content).toContain("<ax-tree>");
304
+
305
+ const logs = stepTimingsLogs();
306
+ expect(logs).toHaveLength(1);
307
+ expect(logs[0]).not.toHaveProperty("helper");
308
+ expect(typeof logs[0].roundTripMs).toBe("number");
309
+ });
310
+ });
311
+
167
312
  // -------------------------------------------------------------------------
168
313
  // Timeout
169
314
  // -------------------------------------------------------------------------
@@ -1652,4 +1797,398 @@ describe("HostCuProxy", () => {
1652
1797
  expect(proxy.previousAXTree).toBeUndefined();
1653
1798
  });
1654
1799
  });
1800
+
1801
+ describe("batched actions", () => {
1802
+ const ACTIONS = [
1803
+ { action: "open_app", app_name: "Google Chrome" },
1804
+ { action: "key", key: "cmd+n" },
1805
+ { action: "type_text", text: "example.com" },
1806
+ { action: "key", key: "enter" },
1807
+ ];
1808
+
1809
+ function connect(capabilities: string[]) {
1810
+ mockClients = [
1811
+ { clientId: "mac-1", actorPrincipalId: "user-1", capabilities },
1812
+ ];
1813
+ }
1814
+ function sequence(targetClientId?: string) {
1815
+ return proxy.request(
1816
+ "computer_use_sequence",
1817
+ { actions: ACTIONS, reasoning: "open a new window on a known URL" },
1818
+ "session-1",
1819
+ 1,
1820
+ "open a new window on a known URL",
1821
+ undefined,
1822
+ targetClientId,
1823
+ "user-1",
1824
+ );
1825
+ }
1826
+
1827
+ test("a client without host_cu_sequence is refused and nothing is sent", async () => {
1828
+ setup();
1829
+ connect(["host_cu", "host_cu_window_capture"]);
1830
+ for (const target of [undefined, "mac-1"]) {
1831
+ const result = await sequence(target);
1832
+ expect(result.isError).toBe(true);
1833
+ expect(result.content).toContain(
1834
+ "Batched actions require a desktop app that supports computer_use_sequence",
1835
+ );
1836
+ expect(result.content).toContain("Nothing was run.");
1837
+ }
1838
+ expect(sentMessages).toHaveLength(0);
1839
+ });
1840
+
1841
+ test("no connected client never falls back to an untargeted broadcast", async () => {
1842
+ setup();
1843
+ expect((await sequence()).isError).toBe(true);
1844
+ expect(sentMessages).toHaveLength(0);
1845
+ });
1846
+
1847
+ test("a client with host_cu_sequence gets one request carrying the actions", async () => {
1848
+ setup();
1849
+ connect(["host_cu", "host_cu_sequence"]);
1850
+ const pending = sequence();
1851
+ expect(sentMessages).toHaveLength(1);
1852
+ const sent = sentMessages[0] as {
1853
+ requestId: string;
1854
+ toolName: string;
1855
+ targetClientId: string;
1856
+ input: Record<string, unknown>;
1857
+ };
1858
+ expect(sent.toolName).toBe("computer_use_sequence");
1859
+ expect(sent.targetClientId).toBe("mac-1");
1860
+ expect(sent.input.actions).toEqual(ACTIONS);
1861
+ proxy.processObservation(sent.requestId, {
1862
+ axTree: "Window: New Tab",
1863
+ executionResult: "Ran 4 actions",
1864
+ });
1865
+ const result = await pending;
1866
+ expect(result.isError).toBe(false);
1867
+ expect(result.content).toContain("Ran 4 actions");
1868
+ });
1869
+ });
1870
+
1871
+ describe("screenshot on request", () => {
1872
+ const TREE = 'Window: "Inbox" (Mail)\n [1] button "Reply" at (10, 10)';
1873
+ const OMITTED = "Screenshot omitted";
1874
+
1875
+ function connect() {
1876
+ mockClients = [
1877
+ {
1878
+ clientId: "mac-1",
1879
+ actorPrincipalId: "user-1",
1880
+ capabilities: [
1881
+ "host_cu",
1882
+ "host_cu_window_capture",
1883
+ "host_cu_annotate",
1884
+ ],
1885
+ },
1886
+ ];
1887
+ }
1888
+
1889
+ /** Dispatch one request and return what was sent, without answering it. */
1890
+ function dispatch(
1891
+ input: Record<string, unknown> = { element_id: 1 },
1892
+ toolName = "computer_use_click",
1893
+ ) {
1894
+ const pending = proxy.request(
1895
+ toolName,
1896
+ input,
1897
+ "session-1",
1898
+ 1,
1899
+ undefined,
1900
+ undefined,
1901
+ "mac-1",
1902
+ "user-1",
1903
+ );
1904
+ const sent = sentMessages.at(-1) as {
1905
+ requestId: string;
1906
+ input: Record<string, unknown>;
1907
+ };
1908
+ return { pending, sent };
1909
+ }
1910
+
1911
+ /** Dispatch one request, answer it with `observation`, and return both ends. */
1912
+ async function step(
1913
+ observation: Record<string, unknown>,
1914
+ input?: Record<string, unknown>,
1915
+ toolName?: string,
1916
+ ) {
1917
+ const { pending, sent } = dispatch(input, toolName);
1918
+ proxy.processObservation(sent.requestId, observation);
1919
+ return { sent, result: await pending };
1920
+ }
1921
+
1922
+ function asksForScreenshot(sent: { input: Record<string, unknown> }) {
1923
+ return !Object.hasOwn(sent.input, "includeScreenshot");
1924
+ }
1925
+
1926
+ test("the first observed step attaches and the next unscoped step does not", async () => {
1927
+ setup();
1928
+ connect();
1929
+ const first = await step({ axTree: TREE, screenshot: "img" });
1930
+ expect(first.sent.input).toEqual({ element_id: 1 });
1931
+ expect(first.result.content).not.toContain(OMITTED);
1932
+
1933
+ const second = await step({ axTree: TREE });
1934
+ expect(second.sent.input).toEqual({
1935
+ element_id: 1,
1936
+ includeScreenshot: false,
1937
+ });
1938
+ expect(second.result.content).toContain(OMITTED);
1939
+ expect(second.result.isError).toBe(false);
1940
+ });
1941
+
1942
+ test("the first look is per desktop, so a newly targeted one attaches", async () => {
1943
+ setup();
1944
+ mockClients = [
1945
+ {
1946
+ clientId: "mac-1",
1947
+ actorPrincipalId: "user-1",
1948
+ capabilities: ["host_cu"],
1949
+ },
1950
+ {
1951
+ clientId: "mac-2",
1952
+ actorPrincipalId: "user-1",
1953
+ capabilities: ["host_cu"],
1954
+ },
1955
+ ];
1956
+ const on = (clientId: string) => {
1957
+ const pending = proxy.request(
1958
+ "computer_use_click",
1959
+ { element_id: 1 },
1960
+ "session-1",
1961
+ 1,
1962
+ undefined,
1963
+ undefined,
1964
+ clientId,
1965
+ "user-1",
1966
+ );
1967
+ const sent = sentMessages.at(-1) as {
1968
+ requestId: string;
1969
+ input: Record<string, unknown>;
1970
+ };
1971
+ return { pending, sent };
1972
+ };
1973
+
1974
+ const firstA = on("mac-1");
1975
+ proxy.processObservation(firstA.sent.requestId, {
1976
+ axTree: TREE,
1977
+ screenshot: "img",
1978
+ });
1979
+ await firstA.pending;
1980
+ const secondA = on("mac-1");
1981
+ expect(asksForScreenshot(secondA.sent)).toBe(false);
1982
+ proxy.processObservation(secondA.sent.requestId, { axTree: TREE });
1983
+ await secondA.pending;
1984
+
1985
+ // A different machine the assistant has never seen gets its own look.
1986
+ const firstB = on("mac-2");
1987
+ expect(asksForScreenshot(firstB.sent)).toBe(true);
1988
+ proxy.processObservation(firstB.sent.requestId, {
1989
+ axTree: TREE,
1990
+ screenshot: "img",
1991
+ });
1992
+ await firstB.pending;
1993
+ const secondB = on("mac-2");
1994
+ expect(asksForScreenshot(secondB.sent)).toBe(false);
1995
+ proxy.processObservation(secondB.sent.requestId, { axTree: TREE });
1996
+ await secondB.pending;
1997
+ });
1998
+
1999
+ test("include_screenshot: true attaches and is not forwarded", async () => {
2000
+ setup();
2001
+ connect();
2002
+ await step({ axTree: TREE, screenshot: "img" });
2003
+ const input = { include_screenshot: true };
2004
+ const { sent, result } = await step(
2005
+ { axTree: TREE, screenshot: "img" },
2006
+ input,
2007
+ "computer_use_observe",
2008
+ );
2009
+ expect(sent.input).toEqual({});
2010
+ expect(input).toEqual({ include_screenshot: true });
2011
+ expect(result.content).not.toContain(OMITTED);
2012
+ });
2013
+
2014
+ test("include_screenshot: false is stripped and the step still skips", async () => {
2015
+ setup();
2016
+ connect();
2017
+ await step({ axTree: TREE, screenshot: "img" });
2018
+ const { sent } = await step(
2019
+ { axTree: TREE },
2020
+ { include_screenshot: false },
2021
+ "computer_use_observe",
2022
+ );
2023
+ expect(sent.input).toEqual({ includeScreenshot: false });
2024
+ });
2025
+
2026
+ test("a scoped observe always attaches", async () => {
2027
+ setup();
2028
+ connect();
2029
+ await step({ axTree: TREE, screenshot: "img" });
2030
+ for (const input of [
2031
+ { capture_window_id: 12 },
2032
+ { captureWindowId: 12 },
2033
+ { captureDisplayId: 1 },
2034
+ ]) {
2035
+ const scoped = await step(
2036
+ { axTree: TREE, screenshot: "img" },
2037
+ input,
2038
+ "computer_use_observe",
2039
+ );
2040
+ expect(scoped.sent.input).toEqual(input);
2041
+ }
2042
+ });
2043
+
2044
+ test("a scoped observation does not count as the first look", async () => {
2045
+ setup();
2046
+ connect();
2047
+ await step(
2048
+ { axTree: TREE, screenshot: "img" },
2049
+ { capture_window_id: 12 },
2050
+ "computer_use_observe",
2051
+ );
2052
+ expect(asksForScreenshot((await step({ axTree: TREE })).sent)).toBe(true);
2053
+ });
2054
+
2055
+ test("reset() makes the next step attach again", async () => {
2056
+ setup();
2057
+ connect();
2058
+ await step({ axTree: TREE, screenshot: "img" });
2059
+ expect(asksForScreenshot((await step({ axTree: TREE })).sent)).toBe(
2060
+ false,
2061
+ );
2062
+ proxy.reset();
2063
+ expect(asksForScreenshot((await step({ axTree: TREE })).sent)).toBe(true);
2064
+ });
2065
+
2066
+ test("the omitted line is absent when a requested capture failed", async () => {
2067
+ setup();
2068
+ connect();
2069
+ const failed = await step({ axTree: TREE });
2070
+ expect(asksForScreenshot(failed.sent)).toBe(true);
2071
+ expect(failed.result.content).not.toContain(OMITTED);
2072
+ });
2073
+
2074
+ test("a first look whose capture failed is asked for again", async () => {
2075
+ setup();
2076
+ connect();
2077
+ const failed = await step({ axTree: TREE });
2078
+ expect(asksForScreenshot(failed.sent)).toBe(true);
2079
+ const retry = await step({ axTree: TREE, screenshot: "img" });
2080
+ expect(asksForScreenshot(retry.sent)).toBe(true);
2081
+ expect(asksForScreenshot((await step({ axTree: TREE })).sent)).toBe(
2082
+ false,
2083
+ );
2084
+ });
2085
+
2086
+ test("an observation that lands after a reset leaves the new run's state alone", async () => {
2087
+ setup();
2088
+ connect();
2089
+ const stale = dispatch();
2090
+ proxy.reset();
2091
+ proxy.processObservation(stale.sent.requestId, {
2092
+ axTree: "stale tree from the finished run",
2093
+ screenshot: "img",
2094
+ });
2095
+ await stale.pending;
2096
+ expect(proxy.previousAXTree).toBeUndefined();
2097
+ expect(proxy.consecutiveUnchangedSteps).toBe(0);
2098
+ });
2099
+
2100
+ test("an observation that lands after a reset does not restore the first look", async () => {
2101
+ setup();
2102
+ connect();
2103
+ const stale = dispatch();
2104
+ proxy.reset();
2105
+ proxy.processObservation(stale.sent.requestId, {
2106
+ axTree: TREE,
2107
+ screenshot: "img",
2108
+ });
2109
+ await stale.pending;
2110
+ expect(asksForScreenshot((await step({ axTree: TREE })).sent)).toBe(true);
2111
+ });
2112
+
2113
+ test("a skipped step that fails still carries the omitted line after the failure", async () => {
2114
+ setup();
2115
+ connect();
2116
+ await step({ axTree: TREE, screenshot: "img" });
2117
+ const failed = await step({
2118
+ axTree: TREE,
2119
+ executionError: "Element not found",
2120
+ });
2121
+ expect(asksForScreenshot(failed.sent)).toBe(false);
2122
+ expect(failed.result.isError).toBe(true);
2123
+ expect(failed.result.content).toStartWith(
2124
+ "Action failed: Element not found",
2125
+ );
2126
+ expect(failed.result.content).toContain(OMITTED);
2127
+ });
2128
+
2129
+ test("a refused skipped step with a tree carries the omitted line", async () => {
2130
+ setup();
2131
+ connect();
2132
+ await step({ axTree: TREE, screenshot: "img" });
2133
+ const refused = await step({
2134
+ axTree: TREE,
2135
+ executionError: "BLOCKED: the user is active",
2136
+ });
2137
+ expect(asksForScreenshot(refused.sent)).toBe(false);
2138
+ expect(refused.result.isError).toBe(true);
2139
+ expect(refused.result.content).toContain(OMITTED);
2140
+ });
2141
+
2142
+ test("a skipped step with no tree gets no omitted line", async () => {
2143
+ setup();
2144
+ connect();
2145
+ await step({ axTree: TREE, screenshot: "img" });
2146
+ const empty = await step({ executionResult: "Clicked" });
2147
+ expect(asksForScreenshot(empty.sent)).toBe(false);
2148
+ expect(empty.result.content).not.toContain(OMITTED);
2149
+ });
2150
+
2151
+ test("a helper that ignores the flag keeps its screenshot and gets no omitted line", async () => {
2152
+ setup();
2153
+ connect();
2154
+ await step({ axTree: TREE, screenshot: "img" });
2155
+ const legacy = await step({ axTree: TREE, screenshot: "img" });
2156
+ expect(asksForScreenshot(legacy.sent)).toBe(false);
2157
+ expect(legacy.result.content).not.toContain(OMITTED);
2158
+ expect(legacy.result.contentBlocks).toHaveLength(1);
2159
+ });
2160
+
2161
+ test("a point_at response leaves the first-look state untouched", async () => {
2162
+ setup();
2163
+ connect();
2164
+ const input = { marks: [], include_screenshot: true };
2165
+ const { pending, sent } = dispatch(input, "computer_use_point_at");
2166
+ expect(sent.input).toBe(input);
2167
+ proxy.processObservation(sent.requestId, { axTree: TREE });
2168
+ expect((await pending).content).not.toContain(OMITTED);
2169
+ expect(asksForScreenshot((await step({ axTree: TREE })).sent)).toBe(true);
2170
+ });
2171
+
2172
+ test("concurrent requests each keep their own dispatch decision", async () => {
2173
+ setup();
2174
+ connect();
2175
+ // Both are dispatched before any observation, so both ask for pixels,
2176
+ // even though the first one's observation lands before the second's.
2177
+ const first = dispatch();
2178
+ const second = dispatch();
2179
+ expect(asksForScreenshot(first.sent)).toBe(true);
2180
+ expect(asksForScreenshot(second.sent)).toBe(true);
2181
+ proxy.processObservation(first.sent.requestId, {
2182
+ axTree: TREE,
2183
+ screenshot: "img",
2184
+ });
2185
+ const third = dispatch();
2186
+ expect(asksForScreenshot(third.sent)).toBe(false);
2187
+ proxy.processObservation(third.sent.requestId, { axTree: TREE });
2188
+ proxy.processObservation(second.sent.requestId, { axTree: TREE });
2189
+ await first.pending;
2190
+ expect((await second.pending).content).not.toContain(OMITTED);
2191
+ expect((await third.pending).content).toContain(OMITTED);
2192
+ });
2193
+ });
1655
2194
  });
@@ -0,0 +1,121 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+
3
+ import { setConfig } from "./helpers/set-config.js";
4
+
5
+ setConfig("memory", { enabled: false });
6
+
7
+ import {
8
+ type ConversationMessage,
9
+ ConversationMessageSchema,
10
+ } from "../api/responses/conversation-message.js";
11
+ import {
12
+ addMessage,
13
+ createConversation,
14
+ } from "../persistence/conversation-crud.js";
15
+ import { getDb } from "../persistence/db-connection.js";
16
+ import { initializeDb } from "../persistence/db-init.js";
17
+ import { handleListMessages } from "../runtime/routes/conversation-routes.js";
18
+
19
+ await initializeDb();
20
+
21
+ describe("handleListMessages camera-frame projection", () => {
22
+ beforeEach(() => {
23
+ const db = getDb();
24
+ db.run("DELETE FROM message_attachments");
25
+ db.run("DELETE FROM attachments");
26
+ db.run("DELETE FROM messages");
27
+ db.run("DELETE FROM conversations");
28
+ });
29
+
30
+ test.each([
31
+ {
32
+ name: "an ambient keep",
33
+ metadata: { scripted: true, sightFrameAttachmentIds: ["att-keep"] },
34
+ cameraFrame: true,
35
+ },
36
+ {
37
+ name: "a spoken turn carrying a parked frame",
38
+ metadata: {
39
+ sightFrameAttachmentIds: ["att-parked"],
40
+ voiceSessionTurn: true,
41
+ },
42
+ cameraFrame: false,
43
+ },
44
+ {
45
+ name: "a shutter photo",
46
+ metadata: { livePhoto: true, voiceSessionTurn: true, scripted: false },
47
+ cameraFrame: false,
48
+ },
49
+ {
50
+ name: "a plain user row with literal camera-frame text",
51
+ metadata: undefined,
52
+ cameraFrame: false,
53
+ },
54
+ {
55
+ name: "a scripted row with no frame tag",
56
+ metadata: { scripted: true },
57
+ cameraFrame: false,
58
+ },
59
+ {
60
+ name: "a scripted row with an empty frame tag",
61
+ metadata: { scripted: true, sightFrameAttachmentIds: [] },
62
+ cameraFrame: false,
63
+ },
64
+ {
65
+ name: "a scripted row with invalid frame ids",
66
+ metadata: { scripted: true, sightFrameAttachmentIds: [""] },
67
+ cameraFrame: false,
68
+ },
69
+ ])("classifies $name from metadata", async ({ metadata, cameraFrame }) => {
70
+ const conv = createConversation();
71
+ const row = await addMessage(
72
+ conv.id,
73
+ "user",
74
+ JSON.stringify([{ type: "text", text: "(camera frame)" }]),
75
+ { metadata },
76
+ );
77
+
78
+ const response = (await handleListMessages({
79
+ queryParams: { conversationId: conv.id },
80
+ })) as { messages: ConversationMessage[] };
81
+
82
+ expect(response.messages).toHaveLength(1);
83
+ const projected = response.messages[0]!;
84
+ expect(projected.id).toBe(row.id);
85
+ expect(() => ConversationMessageSchema.parse(projected)).not.toThrow();
86
+ if (cameraFrame) {
87
+ expect(projected.cameraFrame).toBe(true);
88
+ } else {
89
+ expect(projected).not.toHaveProperty("cameraFrame");
90
+ }
91
+ expect(
92
+ ConversationMessageSchema.safeParse({ ...projected, cameraFrame: false })
93
+ .success,
94
+ ).toBe(false);
95
+ });
96
+
97
+ test.each(["not-json", "null", "", "{}"])(
98
+ "omits cameraFrame for unreadable or empty metadata %j",
99
+ async (metadata) => {
100
+ const conv = createConversation();
101
+ const row = await addMessage(
102
+ conv.id,
103
+ "user",
104
+ JSON.stringify([{ type: "text", text: "(camera frame)" }]),
105
+ );
106
+ getDb()
107
+ .$client.prepare("UPDATE messages SET metadata = ? WHERE id = ?")
108
+ .run(metadata, row.id);
109
+
110
+ const response = (await handleListMessages({
111
+ queryParams: { conversationId: conv.id },
112
+ })) as { messages: ConversationMessage[] };
113
+
114
+ expect(response.messages).toHaveLength(1);
115
+ const projected = response.messages[0]!;
116
+ expect(projected.id).toBe(row.id);
117
+ expect(projected).not.toHaveProperty("cameraFrame");
118
+ expect(() => ConversationMessageSchema.parse(projected)).not.toThrow();
119
+ },
120
+ );
121
+ });