@vellumai/assistant 0.12.2-staging.1 → 0.12.2-staging.2

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 (64) hide show
  1. package/node_modules/@vellumai/slack-text/src/index.ts +13 -8
  2. package/node_modules/@vellumai/slack-text/src/label-resolution-entities.test.ts +95 -0
  3. package/openapi.yaml +35 -1
  4. package/package.json +1 -1
  5. package/src/__tests__/always-loaded-tools-guard.test.ts +5 -5
  6. package/src/__tests__/conversation-runtime-assembly.test.ts +28 -0
  7. package/src/__tests__/conversation-surfaces-point-at-budget.test.ts +1 -0
  8. package/src/__tests__/conversation-surfaces-point-at-capability.test.ts +1 -0
  9. package/src/__tests__/credential-routes.test.ts +38 -0
  10. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  11. package/src/__tests__/cu-unified-flow.test.ts +6 -2
  12. package/src/__tests__/host-cu-proxy.test.ts +69 -12
  13. package/src/__tests__/oauth-commands-routes.test.ts +55 -0
  14. package/src/__tests__/oauth-provider-serializer.test.ts +22 -0
  15. package/src/__tests__/oauth-providers-routes.test.ts +1 -0
  16. package/src/__tests__/secret-routes-acp-guard.test.ts +59 -1
  17. package/src/__tests__/subagent-tool-gate-mode.test.ts +51 -0
  18. package/src/__tests__/ui-channel-variants.test.ts +1 -56
  19. package/src/acp/__tests__/acp-claude-oauth.test.ts +257 -6
  20. package/src/acp/__tests__/acp-credentials.test.ts +13 -0
  21. package/src/acp/__tests__/claude-token-refresh.test.ts +257 -0
  22. package/src/acp/__tests__/prepare-agent-env.test.ts +60 -1
  23. package/src/acp/acp-claude-oauth.ts +328 -14
  24. package/src/acp/acp-credentials.ts +19 -0
  25. package/src/acp/claude-token-refresh.ts +150 -0
  26. package/src/acp/prepare-agent-env.ts +21 -6
  27. package/src/calls/__tests__/voice-control-protocol.test.ts +62 -0
  28. package/src/calls/__tests__/voice-session-bridge.test.ts +19 -0
  29. package/src/calls/voice-control-protocol.ts +102 -0
  30. package/src/calls/voice-session-bridge.ts +33 -26
  31. package/src/calls/voice-triage-escalate.ts +3 -3
  32. package/src/cli/commands/oauth/status.ts +5 -0
  33. package/src/config/bundled-skills/computer-use/SKILL.md +13 -4
  34. package/src/config/bundled-skills/computer-use/TOOLS.json +1 -1
  35. package/src/config/feature-flag-registry.json +9 -1
  36. package/src/config/loader.ts +1 -0
  37. package/src/config/schemas/services.ts +10 -0
  38. package/src/daemon/conversation-runtime-assembly.ts +18 -6
  39. package/src/daemon/conversation-surfaces.ts +6 -1
  40. package/src/daemon/conversation-tool-setup.ts +8 -16
  41. package/src/daemon/host-cu-proxy.ts +43 -5
  42. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +121 -0
  43. package/src/live-voice/__tests__/live-voice-events.test.ts +8 -7
  44. package/src/live-voice/__tests__/live-voice-progress.test.ts +79 -0
  45. package/src/live-voice/__tests__/protocol.test.ts +39 -0
  46. package/src/live-voice/__tests__/session-controls.test.ts +79 -0
  47. package/src/live-voice/live-voice-session.ts +85 -7
  48. package/src/live-voice/protocol.ts +60 -0
  49. package/src/live-voice/session-controls.ts +111 -0
  50. package/src/oauth/__tests__/seed-providers-managed.test.ts +95 -0
  51. package/src/oauth/connection-resolver.test.ts +27 -0
  52. package/src/oauth/connection-resolver.ts +25 -1
  53. package/src/oauth/provider-serializer.ts +9 -0
  54. package/src/oauth/seed-providers.ts +110 -2
  55. package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +16 -5
  56. package/src/runtime/routes/__tests__/apps-refresh-route.test.ts +74 -4
  57. package/src/runtime/routes/acp-claude-auth-routes.ts +11 -3
  58. package/src/runtime/routes/app-management-routes.ts +17 -2
  59. package/src/runtime/routes/credential-routes.ts +1 -4
  60. package/src/runtime/routes/oauth-commands-routes.ts +14 -0
  61. package/src/runtime/routes/oauth-providers.ts +7 -0
  62. package/src/tools/computer-use/definitions.ts +1 -1
  63. package/src/tools/ui-surface/channel-variants.ts +10 -59
  64. package/src/watch/watch-retro.ts +6 -8
@@ -113,7 +113,7 @@ export async function buildSlackUserLabelMap(
113
113
  // that label directly; only queue a lookup when the label is missing,
114
114
  // empty, or ID-shaped. Mirrors the channel builder below.
115
115
  const [, embeddedLabel] = splitSlackLabel(match[0].slice(1, -1));
116
- const sanitizedEmbeddedLabel = sanitizeOptionalLabel(embeddedLabel);
116
+ const sanitizedEmbeddedLabel = sanitizeEmbeddedSlackLabel(embeddedLabel);
117
117
  if (sanitizedEmbeddedLabel && sanitizedEmbeddedLabel !== id) continue;
118
118
  if (seen.has(id)) continue;
119
119
  seen.add(id);
@@ -151,7 +151,7 @@ export async function buildSlackChannelLabelMap(
151
151
  for (const match of text.matchAll(SLACK_CHANNEL_REFERENCE_RE)) {
152
152
  const id = match[1];
153
153
  const [, embeddedLabel] = splitSlackLabel(match[0].slice(1, -1));
154
- const sanitizedEmbeddedLabel = sanitizeOptionalLabel(embeddedLabel);
154
+ const sanitizedEmbeddedLabel = sanitizeEmbeddedSlackLabel(embeddedLabel);
155
155
  if (sanitizedEmbeddedLabel && sanitizedEmbeddedLabel !== id) continue;
156
156
  if (!seen.has(id)) {
157
157
  seen.add(id);
@@ -192,9 +192,7 @@ function renderUserMention(
192
192
  // prefer it over a lookup, mirroring renderChannelReference. The embedded
193
193
  // label is Slack-sourced (part of the token), so entities decode before
194
194
  // sanitization; the caller-resolved label below is not.
195
- const embeddedLabel = sanitizeOptionalLabel(
196
- label === undefined ? undefined : decodeSlackHtmlEntities(label),
197
- );
195
+ const embeddedLabel = sanitizeEmbeddedSlackLabel(label);
198
196
  if (embeddedLabel && embeddedLabel !== id) {
199
197
  return `@${embeddedLabel}`;
200
198
  }
@@ -219,9 +217,7 @@ function renderChannelReference(
219
217
  );
220
218
  // The embedded label is Slack-sourced (part of the token), so entities
221
219
  // decode before sanitization; the caller-resolved label below is not.
222
- const embeddedLabel = sanitizeOptionalLabel(
223
- label === undefined ? undefined : decodeSlackHtmlEntities(label),
224
- );
220
+ const embeddedLabel = sanitizeEmbeddedSlackLabel(label);
225
221
  if (embeddedLabel && embeddedLabel !== channelId) {
226
222
  return `#${embeddedLabel}`;
227
223
  }
@@ -320,6 +316,15 @@ export function sanitizeSlackLabel(
320
316
  return sanitized || undefined;
321
317
  }
322
318
 
319
+ // Slack-sourced labels are decoded before validation in both lookup and render.
320
+ function sanitizeEmbeddedSlackLabel(
321
+ label: string | undefined,
322
+ ): string | undefined {
323
+ return sanitizeOptionalLabel(
324
+ label === undefined ? undefined : decodeSlackHtmlEntities(label),
325
+ );
326
+ }
327
+
323
328
  function sanitizeOptionalLabel(label: string | undefined): string | undefined {
324
329
  return sanitizeSlackLabel(label);
325
330
  }
@@ -0,0 +1,95 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "bun:test";
3
+ import {
4
+ buildSlackChannelLabelMap,
5
+ buildSlackUserLabelMap,
6
+ renderSlackTextForModel,
7
+ } from "./index.js";
8
+
9
+ describe("Slack embedded-label lookup parity", () => {
10
+ const cases = [
11
+ { id: "U123", prefix: "@", build: buildSlackUserLabelMap, mapKey: "userLabels" },
12
+ {
13
+ id: "C123",
14
+ prefix: "#",
15
+ build: buildSlackChannelLabelMap,
16
+ mapKey: "channelLabels",
17
+ },
18
+ ] as const;
19
+
20
+ for (const { id, prefix, build, mapKey } of cases) {
21
+ for (const label of ["<>", `<${id}>`, "< @# >"]) {
22
+ test(`${prefix} resolves label ${label} after Slack entity decoding`, async () => {
23
+ const text = `<${prefix}${id}|${label}>`;
24
+ const requested: string[] = [];
25
+ const labels = await build([text], async (value) => {
26
+ requested.push(value);
27
+ return "Example Name";
28
+ });
29
+ assert.deepEqual(requested, [id]);
30
+ assert.equal(
31
+ renderSlackTextForModel(text, { [mapKey]: labels }),
32
+ `${prefix}Example Name`,
33
+ );
34
+ });
35
+ }
36
+
37
+ for (const label of ["&lt;Example&gt;", "R&amp;D", "&amp;lt;&amp;gt;"]) {
38
+ test(`${prefix} keeps usable embedded label ${label} without lookup`, async () => {
39
+ const requested: string[] = [];
40
+ const text = `<${prefix}${id}|${label}>`;
41
+ const labels = await build([text], async (value) => {
42
+ requested.push(value);
43
+ return "Wrong Label";
44
+ });
45
+ assert.deepEqual(requested, []);
46
+ assert.deepEqual(labels, {});
47
+ const expected =
48
+ label === "&lt;Example&gt;"
49
+ ? "Example"
50
+ : label === "R&amp;D"
51
+ ? "R&D"
52
+ : "&lt;&gt;";
53
+ assert.equal(
54
+ renderSlackTextForModel(text, { [mapKey]: labels }),
55
+ `${prefix}${expected}`,
56
+ );
57
+ });
58
+ }
59
+
60
+ test(`${prefix} deduplicates required lookups across encoded and bare mentions`, async () => {
61
+ const requested: string[] = [];
62
+ const labels = await build(
63
+ [`<${prefix}${id}|&lt;&gt;>`, `<${prefix}${id}>`],
64
+ async (value) => {
65
+ requested.push(value);
66
+ return "Example";
67
+ },
68
+ );
69
+ assert.deepEqual(requested, [id]);
70
+ assert.deepEqual(labels, { [id]: "Example" });
71
+ });
72
+
73
+ test(`${prefix} does not decode caller-resolved entity text`, async () => {
74
+ const text = `<${prefix}${id}|&lt;&gt;>`;
75
+ const labels = await build([text], async () => "&lt;Example&gt;");
76
+ assert.equal(
77
+ renderSlackTextForModel(text, { [mapKey]: labels }),
78
+ `${prefix}&lt;Example&gt;`,
79
+ );
80
+ });
81
+
82
+ test(`${prefix} preserves unknown fallback after a failed required lookup`, async () => {
83
+ let attempts = 0;
84
+ const text = `<${prefix}${id}|&lt;&gt;>`;
85
+ const labels = await build([text], async () => {
86
+ attempts++;
87
+ throw new Error("unavailable");
88
+ });
89
+ assert.equal(attempts, 1);
90
+ assert.deepEqual(labels, {});
91
+ const expected = prefix === "@" ? "@unknown-user" : "#unknown-channel";
92
+ assert.equal(renderSlackTextForModel(text, { [mapKey]: labels }), expected);
93
+ });
94
+ }
95
+ });
package/openapi.yaml CHANGED
@@ -3,7 +3,7 @@
3
3
  openapi: 3.1.0
4
4
  info:
5
5
  title: Vellum Assistant API
6
- version: 0.12.2
6
+ version: 0.12.1
7
7
  description: Auto-generated OpenAPI specification for the Vellum Assistant runtime HTTP server.
8
8
  servers:
9
9
  - url: http://127.0.0.1:7821
@@ -24481,6 +24481,22 @@ paths:
24481
24481
  anyOf:
24482
24482
  - type: string
24483
24483
  - type: "null"
24484
+ tenant_host:
24485
+ anyOf:
24486
+ - type: object
24487
+ properties:
24488
+ pattern:
24489
+ type: string
24490
+ label:
24491
+ type: string
24492
+ placeholder:
24493
+ type: string
24494
+ required:
24495
+ - pattern
24496
+ - label
24497
+ - placeholder
24498
+ additionalProperties: false
24499
+ - type: "null"
24484
24500
  acts_as:
24485
24501
  type: string
24486
24502
  enum:
@@ -24497,6 +24513,7 @@ paths:
24497
24513
  - supports_managed_mode
24498
24514
  - managed_service_is_paid
24499
24515
  - feature_flag
24516
+ - tenant_host
24500
24517
  - acts_as
24501
24518
  additionalProperties: false
24502
24519
  - type: "null"
@@ -25015,6 +25032,22 @@ paths:
25015
25032
  anyOf:
25016
25033
  - type: string
25017
25034
  - type: "null"
25035
+ tenant_host:
25036
+ anyOf:
25037
+ - type: object
25038
+ properties:
25039
+ pattern:
25040
+ type: string
25041
+ label:
25042
+ type: string
25043
+ placeholder:
25044
+ type: string
25045
+ required:
25046
+ - pattern
25047
+ - label
25048
+ - placeholder
25049
+ additionalProperties: false
25050
+ - type: "null"
25018
25051
  acts_as:
25019
25052
  type: string
25020
25053
  enum:
@@ -25031,6 +25064,7 @@ paths:
25031
25064
  - supports_managed_mode
25032
25065
  - managed_service_is_paid
25033
25066
  - feature_flag
25067
+ - tenant_host
25034
25068
  - acts_as
25035
25069
  additionalProperties: false
25036
25070
  required:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.2-staging.1",
3
+ "version": "0.12.2-staging.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -44,11 +44,11 @@ describe("always-loaded tool count", () => {
44
44
  );
45
45
  const activeNames = activeTools.map((t) => t.name).sort();
46
46
 
47
- // `watch_retro_report` survives this baseline for the same reason the
48
- // ui_* tools do: a watch retrospective runs clientless, so it can only
49
- // report through a tool that survives this baseline. The ui_* tools are
50
- // here because background UI surfaces persist and return instead of
51
- // awaiting action, so they no longer need a connected client.
47
+ // `watch_retro_report` survives this baseline because a watch
48
+ // retrospective runs clientless and can only report through a tool that
49
+ // stays on this baseline. The ui_* tools stay here because background
50
+ // surfaces persist and return instead of awaiting action, so a connected
51
+ // client is not required for the definitions to be present.
52
52
  // Host tool definitions stay on the wire for background turns: schemas
53
53
  // are cache-stable, and the shared execution gate rejects host tools on
54
54
  // explicitly non-interactive turns before any proxy dispatch.
@@ -119,6 +119,7 @@ import {
119
119
  applyRuntimeInjections,
120
120
  assembleSlackActiveThreadFocusBlock,
121
121
  assembleSlackChronologicalMessages,
122
+ buildChannelCapabilityBlock,
122
123
  buildSubagentStatusBlock,
123
124
  getSlackCompactionWatermarkForPrefix,
124
125
  getSlackWatermarkAdvanceForRowPrefix,
@@ -586,6 +587,33 @@ describe("injectChannelCapabilityContext", () => {
586
587
  expect(text).not.toContain("Do NOT use ui_show, ui_update, or app_create");
587
588
  });
588
589
 
590
+ test("guides non-interactive turns to persist UI for a later capable client", () => {
591
+ const slack: ChannelCapabilities = {
592
+ channel: "slack",
593
+ dashboardCapable: false,
594
+ supportsDynamicUi: false,
595
+ supportsVoiceInput: false,
596
+ };
597
+ const phone: ChannelCapabilities = {
598
+ channel: "phone",
599
+ dashboardCapable: false,
600
+ supportsDynamicUi: false,
601
+ supportsVoiceInput: false,
602
+ };
603
+
604
+ for (const caps of [slack, phone]) {
605
+ const text = buildChannelCapabilityBlock(caps, undefined, true)!;
606
+ expect(text).toContain(
607
+ "ui_show, ui_update, and ui_dismiss persist conversation content",
608
+ );
609
+ expect(text).not.toContain("Only use ui_show/ui_update");
610
+ expect(text).not.toContain("Do NOT use ui_show, ui_update, or app_create");
611
+ expect(text).not.toContain(
612
+ "Present information as well-formatted text instead of dynamic UI.",
613
+ );
614
+ }
615
+ });
616
+
589
617
  test("keeps blanket ui_show/ui_update prohibition for other non-dynamic channels", () => {
590
618
  const caps: ChannelCapabilities = {
591
619
  channel: "phone",
@@ -24,6 +24,7 @@ function proxyDouble() {
24
24
  recordAction,
25
25
  request,
26
26
  reset: () => {},
27
+ endTask: () => {},
27
28
  stepCount: 0,
28
29
  },
29
30
  };
@@ -30,6 +30,7 @@ function proxyDouble() {
30
30
  recordAction: () => {},
31
31
  request,
32
32
  reset: () => {},
33
+ endTask: () => {},
33
34
  stepCount: 0,
34
35
  },
35
36
  };
@@ -532,6 +532,28 @@ describe("credentials routes", () => {
532
532
  );
533
533
  });
534
534
 
535
+ test("stores a pasted Claude token without touching leftover refresh material", async () => {
536
+ secureStore.set("acp:claude_oauth_refresh_token", "stale-refresh");
537
+ secureStore.set("acp:claude_oauth_expires_at", "111");
538
+
539
+ await setRoute!.handler({
540
+ body: {
541
+ service: "acp",
542
+ field: "claude_oauth_token",
543
+ value: "sk-ant-oat01-pasted-token",
544
+ },
545
+ });
546
+
547
+ expect(secureStore.get("acp:claude_oauth_token")).toBe(
548
+ "sk-ant-oat01-pasted-token",
549
+ );
550
+ expect(secureStore.get("acp:claude_oauth_refresh_token")).toBe(
551
+ "stale-refresh",
552
+ );
553
+ expect(secureStore.get("acp:claude_oauth_expires_at")).toBe("111");
554
+ });
555
+
556
+
535
557
  test("a successful set scrubs the normalized value from transcripts exactly once", async () => {
536
558
  /**
537
559
  * The pasted plaintext may already sit in recent transcripts, so a
@@ -1301,5 +1323,21 @@ describe("credentials routes", () => {
1301
1323
  // THEN it rejects with a BadRequestError
1302
1324
  await expect(call).rejects.toBeInstanceOf(BadRequestError);
1303
1325
  });
1326
+
1327
+ test("deletes only the requested Claude access-token field", async () => {
1328
+ secureStore.set("acp:claude_oauth_token", "sk-ant-oat01-connected");
1329
+ secureStore.set("acp:claude_oauth_refresh_token", "refresh-leftover");
1330
+ secureStore.set("acp:claude_oauth_expires_at", "111");
1331
+
1332
+ await deleteRoute!.handler({
1333
+ body: { service: "acp", field: "claude_oauth_token" },
1334
+ });
1335
+
1336
+ expect(secureStore.has("acp:claude_oauth_token")).toBe(false);
1337
+ expect(secureStore.get("acp:claude_oauth_refresh_token")).toBe(
1338
+ "refresh-leftover",
1339
+ );
1340
+ expect(secureStore.get("acp:claude_oauth_expires_at")).toBe("111");
1341
+ });
1304
1342
  });
1305
1343
  });
@@ -190,7 +190,7 @@ describe("Invariant 2: no generic plaintext secret read API", () => {
190
190
  "oauth/credential-token-resolver.ts", // centralized access-token key resolution for OAuth and manual-token providers
191
191
  "oauth/connection-resolver.ts", // resolve OAuthConnection from oauth-store (access_token lookup)
192
192
  "runtime/routes/secret-routes.ts", // HTTP secret management routes (set/delete secrets)
193
- "acp/acp-claude-oauth.ts", // Connect Claude OAuth token vault-store helper (stores sk-ant-oat token via setSecureKeyAsync)
193
+ "acp/acp-claude-oauth.ts", // Connect Claude OAuth token vault-store helper (stores access token plus broker-owned refresh/expiry companions via setSecureKeyAsync)
194
194
  "runtime/routes/acp-claude-auth-routes.ts", // Connect Claude Code daemon OAuth flow (stores OAuth token in vault)
195
195
  "runtime/routes/migration-routes.ts", // migration import credential restore
196
196
  "daemon/conversation-messaging.ts", // credential storage during session messaging
@@ -485,8 +485,12 @@ describe("surfaceProxyResolver — CU tool routing", () => {
485
485
  // Proxy state is clean after done
486
486
  expect(proxy.stepCount).toBe(0);
487
487
  expect(proxy.actionHistory).toHaveLength(0);
488
- // Only 2 messages sent to client (screenshot + click; done is terminal)
489
- expect(sentMessages).toHaveLength(2);
488
+ // screenshot + click, then done tells the client the task ended so the
489
+ // helper returns the pointer; done itself never reaches the client
490
+ expect(sentMessages).toHaveLength(3);
491
+ expect((sentMessages[2] as Record<string, unknown>).type).toBe(
492
+ "host_cu_cancel",
493
+ );
490
494
  });
491
495
  });
492
496
 
@@ -1232,6 +1232,38 @@ describe("HostCuProxy", () => {
1232
1232
  });
1233
1233
  });
1234
1234
 
1235
+ describe("endTask", () => {
1236
+ test("tells the desktop the task drove that it is over, once, then resets", async () => {
1237
+ setup();
1238
+
1239
+ const resultPromise = proxy.request(
1240
+ "computer_use_click",
1241
+ { element_id: 1 },
1242
+ "session-1",
1243
+ 1,
1244
+ );
1245
+ const requestId = (sentMessages[0] as Record<string, unknown>)
1246
+ .requestId as string;
1247
+ proxy.processObservation(requestId, { axTree: "Button [1]" });
1248
+ await resultPromise;
1249
+
1250
+ proxy.endTask("session-1");
1251
+ const cancels = sentMessages.filter(
1252
+ (m) => (m as Record<string, unknown>).type === "host_cu_cancel",
1253
+ ) as Array<Record<string, unknown>>;
1254
+ expect(cancels).toHaveLength(1);
1255
+ expect(cancels[0].conversationId).toBe("session-1");
1256
+ expect(cancels[0].requestId).not.toBe(requestId);
1257
+
1258
+ proxy.endTask("session-1");
1259
+ expect(
1260
+ sentMessages.filter(
1261
+ (m) => (m as Record<string, unknown>).type === "host_cu_cancel",
1262
+ ),
1263
+ ).toHaveLength(1);
1264
+ });
1265
+ });
1266
+
1235
1267
  describe("late resolve after abort", () => {
1236
1268
  test("resolve is a no-op after abort (entry already deleted)", async () => {
1237
1269
  setup();
@@ -1868,7 +1900,7 @@ describe("HostCuProxy", () => {
1868
1900
  });
1869
1901
  });
1870
1902
 
1871
- describe("screenshot on request", () => {
1903
+ describe("screenshot policy", () => {
1872
1904
  const TREE = 'Window: "Inbox" (Mail)\n [1] button "Reply" at (10, 10)';
1873
1905
  const OMITTED = "Screenshot omitted";
1874
1906
 
@@ -1881,15 +1913,20 @@ describe("HostCuProxy", () => {
1881
1913
  "host_cu",
1882
1914
  "host_cu_window_capture",
1883
1915
  "host_cu_annotate",
1916
+ "host_cu_sequence",
1884
1917
  ],
1885
1918
  },
1886
1919
  ];
1887
1920
  }
1888
1921
 
1889
- /** Dispatch one request and return what was sent, without answering it. */
1922
+ /**
1923
+ * Dispatch one request and return what was sent, without answering it. A
1924
+ * plain observation is the only step whose pixels depend on policy, so it
1925
+ * is the default.
1926
+ */
1890
1927
  function dispatch(
1891
- input: Record<string, unknown> = { element_id: 1 },
1892
- toolName = "computer_use_click",
1928
+ input: Record<string, unknown> = {},
1929
+ toolName = "computer_use_observe",
1893
1930
  ) {
1894
1931
  const pending = proxy.request(
1895
1932
  toolName,
@@ -1923,22 +1960,42 @@ describe("HostCuProxy", () => {
1923
1960
  return !Object.hasOwn(sent.input, "includeScreenshot");
1924
1961
  }
1925
1962
 
1926
- test("the first observed step attaches and the next unscoped step does not", async () => {
1963
+ test("the first observation attaches and the next plain one does not", async () => {
1927
1964
  setup();
1928
1965
  connect();
1929
1966
  const first = await step({ axTree: TREE, screenshot: "img" });
1930
- expect(first.sent.input).toEqual({ element_id: 1 });
1967
+ expect(first.sent.input).toEqual({});
1931
1968
  expect(first.result.content).not.toContain(OMITTED);
1932
1969
 
1933
1970
  const second = await step({ axTree: TREE });
1934
- expect(second.sent.input).toEqual({
1935
- element_id: 1,
1936
- includeScreenshot: false,
1937
- });
1971
+ expect(second.sent.input).toEqual({ includeScreenshot: false });
1938
1972
  expect(second.result.content).toContain(OMITTED);
1939
1973
  expect(second.result.isError).toBe(false);
1940
1974
  });
1941
1975
 
1976
+ test("every action attaches after the first look", async () => {
1977
+ setup();
1978
+ connect();
1979
+ await step({ axTree: TREE, screenshot: "img" });
1980
+ for (const [toolName, input] of [
1981
+ ["computer_use_click", { element_id: 1 }],
1982
+ ["computer_use_type_text", { text: "hi" }],
1983
+ ["computer_use_key", { key: "enter" }],
1984
+ ["computer_use_scroll", { direction: "down" }],
1985
+ ["computer_use_wait", { duration: 1 }],
1986
+ ["computer_use_sequence", { actions: [] }],
1987
+ ] as const) {
1988
+ const action = await step(
1989
+ { axTree: TREE, screenshot: "img" },
1990
+ { ...input, include_screenshot: false },
1991
+ toolName,
1992
+ );
1993
+ expect(action.sent.input).toEqual(input);
1994
+ expect(action.result.content).not.toContain(OMITTED);
1995
+ expect(action.result.contentBlocks).toHaveLength(1);
1996
+ }
1997
+ });
1998
+
1942
1999
  test("the first look is per desktop, so a newly targeted one attaches", async () => {
1943
2000
  setup();
1944
2001
  mockClients = [
@@ -1955,8 +2012,8 @@ describe("HostCuProxy", () => {
1955
2012
  ];
1956
2013
  const on = (clientId: string) => {
1957
2014
  const pending = proxy.request(
1958
- "computer_use_click",
1959
- { element_id: 1 },
2015
+ "computer_use_observe",
2016
+ {},
1960
2017
  "session-1",
1961
2018
  1,
1962
2019
  undefined,
@@ -1165,6 +1165,33 @@ describe("POST oauth/managed-connect/start", () => {
1165
1165
  expect(result.connect_url).toBe("https://app.vellum.ai/connect/abc");
1166
1166
  });
1167
1167
 
1168
+ test("forwards tenant_host to the platform only when one is supplied", async () => {
1169
+ // Shopify's endpoints live on the merchant's host, which the platform
1170
+ // substitutes into its templates; other providers must not see the key.
1171
+ const bodies: Array<Record<string, unknown>> = [];
1172
+ mockFetchImpl = async (_path, init) => {
1173
+ bodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
1174
+ return {
1175
+ ok: true,
1176
+ status: 200,
1177
+ json: async () => ({
1178
+ connect_url: "https://app.vellum.ai/connect/abc",
1179
+ }),
1180
+ text: async () => "",
1181
+ };
1182
+ };
1183
+ await getRoute("POST", "oauth/managed-connect/start").handler(
1184
+ makeArgs({
1185
+ body: { provider: "shopify", tenant_host: " my-store.myshopify.com " },
1186
+ }),
1187
+ );
1188
+ await getRoute("POST", "oauth/managed-connect/start").handler(
1189
+ makeArgs({ body: { provider: "google", tenant_host: " " } }),
1190
+ );
1191
+ expect(bodies[0]?.tenant_host).toBe("my-store.myshopify.com");
1192
+ expect(bodies[1]).not.toHaveProperty("tenant_host");
1193
+ });
1194
+
1168
1195
  test("raises InternalError when platform returns 401", async () => {
1169
1196
  mockFetchImpl = async () => ({
1170
1197
  ok: false,
@@ -1228,6 +1255,7 @@ describe("GET oauth/managed-connect/poll", () => {
1228
1255
  id: string;
1229
1256
  account_label: string | null;
1230
1257
  scopes_granted: string[];
1258
+ provider_params: Record<string, string>;
1231
1259
  }>;
1232
1260
  };
1233
1261
  expect(result.ok).toBe(true);
@@ -1236,6 +1264,7 @@ describe("GET oauth/managed-connect/poll", () => {
1236
1264
  id: "conn-1",
1237
1265
  account_label: "alice@example.com",
1238
1266
  scopes_granted: ["email"],
1267
+ provider_params: {},
1239
1268
  },
1240
1269
  ]);
1241
1270
  });
@@ -1248,4 +1277,30 @@ describe("GET oauth/managed-connect/poll", () => {
1248
1277
  ),
1249
1278
  ).rejects.toBeInstanceOf(BadRequestError);
1250
1279
  });
1280
+
1281
+ test("passes through the provider params a connection is scoped by", async () => {
1282
+ // QuickBooks pins the company (realm) the user picked to the connection;
1283
+ // a caller addressing /companyinfo/<realmId> needs it back.
1284
+ mockFetchImpl = async () => ({
1285
+ ok: true,
1286
+ status: 200,
1287
+ json: async () => [
1288
+ {
1289
+ id: "conn-qb",
1290
+ account_label: "Acme Widgets",
1291
+ scopes_granted: ["com.intuit.quickbooks.accounting"],
1292
+ provider_params: { realm_id: "9130357849012345" },
1293
+ },
1294
+ ],
1295
+ text: async () => "",
1296
+ });
1297
+ const result = (await getRoute("GET", "oauth/managed-connect/poll").handler(
1298
+ makeArgs({ queryParams: { provider: "quickbooks" } }),
1299
+ )) as {
1300
+ connections: Array<{ provider_params: Record<string, string> }>;
1301
+ };
1302
+ expect(result.connections[0]?.provider_params).toEqual({
1303
+ realm_id: "9130357849012345",
1304
+ });
1305
+ });
1251
1306
  });
@@ -291,10 +291,32 @@ describe("serializeProviderSummary", () => {
291
291
  supports_managed_mode: true,
292
292
  managed_service_is_paid: false,
293
293
  feature_flag: null,
294
+ tenant_host: null,
294
295
  acts_as: "user",
295
296
  });
296
297
  });
297
298
 
299
+ test("exposes the tenant host a per-tenant provider needs at connect", () => {
300
+ // Shopify's OAuth endpoints live on the merchant's own host, so a client
301
+ // has to collect it before the managed flow can start. The summary is the
302
+ // only provider metadata the web client reads, so it carries the ask.
303
+ const shopify = serializeProviderSummary(makeRow({ provider: "shopify" }))!;
304
+ expect(shopify.tenant_host).toEqual({
305
+ pattern: "^[a-z0-9][a-z0-9-]*\\.myshopify\\.com$",
306
+ label: "Shop domain",
307
+ placeholder: "your-store.myshopify.com",
308
+ });
309
+ expect(
310
+ new RegExp(shopify.tenant_host!.pattern).test("my-store.myshopify.com"),
311
+ ).toBe(true);
312
+ expect(new RegExp(shopify.tenant_host!.pattern).test("evil.com")).toBe(
313
+ false,
314
+ );
315
+ expect(
316
+ serializeProviderSummary(makeRow({ provider: "github" }))!.tenant_host,
317
+ ).toBeNull();
318
+ });
319
+
298
320
  test("names which sense of connected a provider represents", () => {
299
321
  // The provider key does not say, and the naming actively misleads:
300
322
  // `slack` is the user integration while its bot is `slack_channel`, but
@@ -178,6 +178,7 @@ describe("GET /v1/oauth/providers", () => {
178
178
  "supports_managed_mode",
179
179
  "managed_service_is_paid",
180
180
  "feature_flag",
181
+ "tenant_host",
181
182
  "acts_as",
182
183
  ];
183
184