@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.
- package/node_modules/@vellumai/slack-text/src/index.ts +13 -8
- package/node_modules/@vellumai/slack-text/src/label-resolution-entities.test.ts +95 -0
- package/openapi.yaml +35 -1
- package/package.json +1 -1
- package/src/__tests__/always-loaded-tools-guard.test.ts +5 -5
- package/src/__tests__/conversation-runtime-assembly.test.ts +28 -0
- package/src/__tests__/conversation-surfaces-point-at-budget.test.ts +1 -0
- package/src/__tests__/conversation-surfaces-point-at-capability.test.ts +1 -0
- package/src/__tests__/credential-routes.test.ts +38 -0
- package/src/__tests__/credential-security-invariants.test.ts +1 -1
- package/src/__tests__/cu-unified-flow.test.ts +6 -2
- package/src/__tests__/host-cu-proxy.test.ts +69 -12
- package/src/__tests__/oauth-commands-routes.test.ts +55 -0
- package/src/__tests__/oauth-provider-serializer.test.ts +22 -0
- package/src/__tests__/oauth-providers-routes.test.ts +1 -0
- package/src/__tests__/secret-routes-acp-guard.test.ts +59 -1
- package/src/__tests__/subagent-tool-gate-mode.test.ts +51 -0
- package/src/__tests__/ui-channel-variants.test.ts +1 -56
- package/src/acp/__tests__/acp-claude-oauth.test.ts +257 -6
- package/src/acp/__tests__/acp-credentials.test.ts +13 -0
- package/src/acp/__tests__/claude-token-refresh.test.ts +257 -0
- package/src/acp/__tests__/prepare-agent-env.test.ts +60 -1
- package/src/acp/acp-claude-oauth.ts +328 -14
- package/src/acp/acp-credentials.ts +19 -0
- package/src/acp/claude-token-refresh.ts +150 -0
- package/src/acp/prepare-agent-env.ts +21 -6
- package/src/calls/__tests__/voice-control-protocol.test.ts +62 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +19 -0
- package/src/calls/voice-control-protocol.ts +102 -0
- package/src/calls/voice-session-bridge.ts +33 -26
- package/src/calls/voice-triage-escalate.ts +3 -3
- package/src/cli/commands/oauth/status.ts +5 -0
- package/src/config/bundled-skills/computer-use/SKILL.md +13 -4
- package/src/config/bundled-skills/computer-use/TOOLS.json +1 -1
- package/src/config/feature-flag-registry.json +9 -1
- package/src/config/loader.ts +1 -0
- package/src/config/schemas/services.ts +10 -0
- package/src/daemon/conversation-runtime-assembly.ts +18 -6
- package/src/daemon/conversation-surfaces.ts +6 -1
- package/src/daemon/conversation-tool-setup.ts +8 -16
- package/src/daemon/host-cu-proxy.ts +43 -5
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +121 -0
- package/src/live-voice/__tests__/live-voice-events.test.ts +8 -7
- package/src/live-voice/__tests__/live-voice-progress.test.ts +79 -0
- package/src/live-voice/__tests__/protocol.test.ts +39 -0
- package/src/live-voice/__tests__/session-controls.test.ts +79 -0
- package/src/live-voice/live-voice-session.ts +85 -7
- package/src/live-voice/protocol.ts +60 -0
- package/src/live-voice/session-controls.ts +111 -0
- package/src/oauth/__tests__/seed-providers-managed.test.ts +95 -0
- package/src/oauth/connection-resolver.test.ts +27 -0
- package/src/oauth/connection-resolver.ts +25 -1
- package/src/oauth/provider-serializer.ts +9 -0
- package/src/oauth/seed-providers.ts +110 -2
- package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +16 -5
- package/src/runtime/routes/__tests__/apps-refresh-route.test.ts +74 -4
- package/src/runtime/routes/acp-claude-auth-routes.ts +11 -3
- package/src/runtime/routes/app-management-routes.ts +17 -2
- package/src/runtime/routes/credential-routes.ts +1 -4
- package/src/runtime/routes/oauth-commands-routes.ts +14 -0
- package/src/runtime/routes/oauth-providers.ts +7 -0
- package/src/tools/computer-use/definitions.ts +1 -1
- package/src/tools/ui-surface/channel-variants.ts +10 -59
- 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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 ["<Example>", "R&D", "&lt;&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 === "<Example>"
|
|
49
|
+
? "Example"
|
|
50
|
+
: label === "R&D"
|
|
51
|
+
? "R&D"
|
|
52
|
+
: "<>";
|
|
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}|<>>`, `<${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}|<>>`;
|
|
75
|
+
const labels = await build([text], async () => "<Example>");
|
|
76
|
+
assert.equal(
|
|
77
|
+
renderSlackTextForModel(text, { [mapKey]: labels }),
|
|
78
|
+
`${prefix}<Example>`,
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test(`${prefix} preserves unknown fallback after a failed required lookup`, async () => {
|
|
83
|
+
let attempts = 0;
|
|
84
|
+
const text = `<${prefix}${id}|<>>`;
|
|
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.
|
|
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
|
@@ -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
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
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",
|
|
@@ -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
|
|
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
|
-
//
|
|
489
|
-
|
|
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
|
|
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
|
-
/**
|
|
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> = {
|
|
1892
|
-
toolName = "
|
|
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
|
|
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({
|
|
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
|
-
"
|
|
1959
|
-
{
|
|
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
|