@vellumai/assistant 0.10.9-staging.1 → 0.10.9-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/AGENTS.md +4 -0
- package/knip.json +1 -4
- package/openapi.yaml +28 -18
- package/package.json +1 -1
- package/src/__tests__/always-loaded-tools-guard.test.ts +4 -6
- package/src/__tests__/app-routes-csp.test.ts +13 -4
- package/src/__tests__/conversation-tool-setup-tools-disabled.test.ts +4 -8
- package/src/__tests__/disk-pressure-tools.test.ts +4 -4
- package/src/__tests__/file-list-tool.test.ts +22 -12
- package/src/__tests__/list-all-apps.test.ts +204 -0
- package/src/__tests__/messaging-send-tool.test.ts +67 -0
- package/src/__tests__/mtime-cache.test.ts +42 -0
- package/src/__tests__/oauth-provider-profiles.test.ts +3 -2
- package/src/__tests__/outlook-messaging-provider.test.ts +71 -0
- package/src/__tests__/plugin-app-serve-routes.test.ts +163 -0
- package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -0
- package/src/__tests__/scaffold-managed-skill-tool.test.ts +47 -0
- package/src/__tests__/subagent-notify-parent.test.ts +4 -3
- package/src/__tests__/subagent-tool-filtering.test.ts +21 -21
- package/src/__tests__/subagent-tool-gate-mode.test.ts +6 -6
- package/src/apps/app-store.ts +302 -2
- package/src/cli/AGENTS.md +17 -0
- package/src/cli/commands/__tests__/gateway.test.ts +55 -0
- package/src/cli/commands/apps.help.ts +17 -11
- package/src/cli/commands/apps.ts +5 -15
- package/src/cli/commands/gateway.help.ts +30 -0
- package/src/cli/commands/gateway.ts +40 -1
- package/src/cli/commands/platform/__tests__/status.test.ts +0 -90
- package/src/cli/commands/platform/index.help.ts +3 -2
- package/src/cli/commands/platform/index.ts +0 -9
- package/src/cli/commands/routes.help.ts +13 -7
- package/src/cli/commands/routes.ts +5 -3
- package/src/cli/commands/status.help.ts +24 -0
- package/src/cli/commands/status.ts +29 -0
- package/src/cli/lib/__tests__/global-json-option.test.ts +97 -0
- package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +1 -0
- package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +31 -0
- package/src/cli/lib/bundled-marketplace.json +187 -173
- package/src/cli/lib/global-json-option.ts +41 -0
- package/src/cli/lib/plugin-fingerprint.ts +18 -7
- package/src/cli/program.ts +5 -0
- package/src/config/bundled-skills/messaging/TOOLS.json +2 -2
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +27 -19
- package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +3 -3
- package/src/config/feature-flag-registry.json +1 -9
- package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +3 -6
- package/src/daemon/__tests__/conversation-tool-setup-plugin-scope.test.ts +3 -6
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +8 -5
- package/src/daemon/conversation-tool-setup.ts +5 -90
- package/src/daemon/conversation.ts +2 -2
- package/src/messaging/provider-types.ts +9 -0
- package/src/messaging/providers/outlook/adapter.ts +17 -2
- package/src/messaging/providers/outlook/client.ts +11 -2
- package/src/messaging/providers/outlook/types.ts +9 -0
- package/src/monitoring/__tests__/plugin-source-watch.test.ts +37 -0
- package/src/monitoring/plugin-source-watch.ts +53 -0
- package/src/oauth/seed-providers.ts +6 -0
- package/src/persistence/embeddings/__tests__/messages-lexical-index.test.ts +3 -0
- package/src/persistence/embeddings/messages-lexical-index.ts +11 -0
- package/src/plugin-api/conversation-turn.ts +234 -0
- package/src/plugin-api/index.ts +12 -0
- package/src/plugins/__tests__/source-fingerprint.test.ts +41 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +48 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +46 -5
- package/src/plugins/defaults/memory/graph/tool-handlers.ts +2 -2
- package/src/plugins/defaults/memory/graph-topology/__tests__/build-memory-graph.test.ts +81 -2
- package/src/plugins/defaults/memory/graph-topology/build-memory-graph.ts +80 -33
- package/src/plugins/defaults/memory/memory-retrospective-job.ts +40 -1
- package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +28 -2
- package/src/plugins/mtime-cache.ts +26 -13
- package/src/plugins/plugin-tree-walk.ts +30 -0
- package/src/plugins/source-fingerprint.ts +9 -2
- package/src/plugins/surface-import.ts +28 -0
- package/src/providers/speech-to-text/__tests__/vellum-managed-realtime.test.ts +1 -1
- package/src/providers/speech-to-text/__tests__/vellum-speech-relay-connection.test.ts +4 -1
- package/src/providers/speech-to-text/vellum-speech-relay-connection.ts +5 -0
- package/src/runtime/routes/__tests__/gateway-status-routes.test.ts +84 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +47 -3
- package/src/runtime/routes/__tests__/user-routes-cli.test.ts +154 -0
- package/src/runtime/routes/app-management-routes.ts +96 -32
- package/src/runtime/routes/app-routes.ts +33 -17
- package/src/runtime/routes/gateway-status-routes.ts +69 -0
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/platform-routes.ts +4 -14
- package/src/runtime/routes/plugins-routes.ts +58 -1
- package/src/runtime/routes/settings-routes.ts +8 -6
- package/src/runtime/routes/user-route-dispatcher.ts +6 -86
- package/src/runtime/routes/user-route-resolution.ts +141 -0
- package/src/runtime/routes/user-routes-cli.ts +56 -35
- package/src/telemetry/AGENTS.md +15 -0
- package/src/telemetry/__tests__/telemetry-event-fixtures.ts +258 -0
- package/src/telemetry/telemetry-wire-source.json +1 -1
- package/src/telemetry/telemetry-wire-validation.test.ts +157 -0
- package/src/telemetry/telemetry-wire-validation.ts +125 -0
- package/src/telemetry/telemetry-wire.generated.ts +60 -9
- package/src/telemetry/types.test.ts +38 -0
- package/src/telemetry/types.ts +183 -91
- package/src/telemetry/usage-telemetry-reporter.ts +5 -0
- package/src/telemetry/watchdog-direct-emit.ts +6 -0
- package/src/tools/skills/scaffold-managed.ts +43 -11
|
@@ -679,6 +679,48 @@ describe("plugin runtime activation", () => {
|
|
|
679
679
|
expect(readFileSync(initMarker, "utf8").trim().split("\n")).toHaveLength(1);
|
|
680
680
|
});
|
|
681
681
|
|
|
682
|
+
test("a runtime install is reconciled even when the sentinel mtime never moves (coarse-mtime filesystems)", async () => {
|
|
683
|
+
await populateCacheAtBoot(); // empty plugins dir
|
|
684
|
+
|
|
685
|
+
// Pin every sentinel write back to the SAME timestamp, simulating a
|
|
686
|
+
// filesystem whose mtime granularity can't distinguish two rewrites (the
|
|
687
|
+
// virtiofs / 9p / network mounts the watcher polls precisely because their
|
|
688
|
+
// timestamps are unreliable). Only the sentinel's size + inode move — and
|
|
689
|
+
// the reconcile gate keys on those, not mtime alone.
|
|
690
|
+
const FIXED = new Date("2020-01-01T00:00:00.000Z");
|
|
691
|
+
const pinMtime = (): void =>
|
|
692
|
+
utimesSync(getSourceVersionsPath(), FIXED, FIXED);
|
|
693
|
+
|
|
694
|
+
// First install → publish → dispatch. Pin the mtime so the SECOND publish
|
|
695
|
+
// below shares it exactly.
|
|
696
|
+
const dirA = freshPluginDir("coarse-a");
|
|
697
|
+
writePackageJson(dirA, { ...SIMPLE_PKG, name: "coarse-a" });
|
|
698
|
+
writeTool(dirA, "coarse-a-tool", TOOL_SRC("coarse-a-tool"));
|
|
699
|
+
expect(publishSourceChanges()).toBe(true);
|
|
700
|
+
pinMtime();
|
|
701
|
+
await getUserHooksFor("user-prompt-submit");
|
|
702
|
+
await loadPluginTools();
|
|
703
|
+
expect(
|
|
704
|
+
getAllToolDefinitions().some((t) => t.name === "coarse-a-tool"),
|
|
705
|
+
).toBe(true);
|
|
706
|
+
|
|
707
|
+
// Second install → publish → pin the SAME mtime as the first. An mtime-only
|
|
708
|
+
// gate would see no change and never reconcile (the plugin would go live
|
|
709
|
+
// only at the next daemon restart); the composite signature still moves —
|
|
710
|
+
// the atomic rename swaps in a fresh inode and the sentinel grew — so the
|
|
711
|
+
// plugin goes live on the very next dispatch.
|
|
712
|
+
const dirB = freshPluginDir("coarse-b");
|
|
713
|
+
writePackageJson(dirB, { ...SIMPLE_PKG, name: "coarse-b" });
|
|
714
|
+
writeTool(dirB, "coarse-b-tool", TOOL_SRC("coarse-b-tool"));
|
|
715
|
+
expect(publishSourceChanges()).toBe(true);
|
|
716
|
+
pinMtime();
|
|
717
|
+
await getUserHooksFor("user-prompt-submit");
|
|
718
|
+
await loadPluginTools();
|
|
719
|
+
expect(
|
|
720
|
+
getAllToolDefinitions().some((t) => t.name === "coarse-b-tool"),
|
|
721
|
+
).toBe(true);
|
|
722
|
+
});
|
|
723
|
+
|
|
682
724
|
test("activation is idempotent — republishing without changes does not re-run init", async () => {
|
|
683
725
|
const dir = freshPluginDir("idem-plugin");
|
|
684
726
|
writePackageJson(dir, { ...SIMPLE_PKG, name: "idem-plugin" });
|
|
@@ -23,7 +23,7 @@ describe("oauth provider profiles (DB-seeded)", () => {
|
|
|
23
23
|
);
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
test("google provider row contains bearer injection templates for
|
|
26
|
+
test("google provider row contains bearer injection templates for 4 Google API hosts", () => {
|
|
27
27
|
const provider = getProvider("google");
|
|
28
28
|
|
|
29
29
|
expect(provider).toBeDefined();
|
|
@@ -36,7 +36,7 @@ describe("oauth provider profiles (DB-seeded)", () => {
|
|
|
36
36
|
valuePrefix: string;
|
|
37
37
|
}>;
|
|
38
38
|
|
|
39
|
-
expect(templates).toHaveLength(
|
|
39
|
+
expect(templates).toHaveLength(4);
|
|
40
40
|
|
|
41
41
|
const byHost = new Map(templates.map((t) => [t.hostPattern, t]));
|
|
42
42
|
|
|
@@ -44,6 +44,7 @@ describe("oauth provider profiles (DB-seeded)", () => {
|
|
|
44
44
|
"gmail.googleapis.com",
|
|
45
45
|
"www.googleapis.com",
|
|
46
46
|
"people.googleapis.com",
|
|
47
|
+
"docs.googleapis.com",
|
|
47
48
|
]) {
|
|
48
49
|
const tpl = byHost.get(host);
|
|
49
50
|
expect(tpl).toBeDefined();
|
|
@@ -473,6 +473,7 @@ describe("Outlook messaging provider", () => {
|
|
|
473
473
|
conn,
|
|
474
474
|
"original-msg-id",
|
|
475
475
|
"Reply text",
|
|
476
|
+
undefined,
|
|
476
477
|
);
|
|
477
478
|
expect(mockSendMessage).not.toHaveBeenCalled();
|
|
478
479
|
expect(result).toMatchObject({
|
|
@@ -481,6 +482,76 @@ describe("Outlook messaging provider", () => {
|
|
|
481
482
|
});
|
|
482
483
|
});
|
|
483
484
|
|
|
485
|
+
test("attaches files to a new message as Graph file attachments", async () => {
|
|
486
|
+
const conn = createMockConnection();
|
|
487
|
+
await outlookMessagingProvider.sendMessage(
|
|
488
|
+
conn,
|
|
489
|
+
"recipient@example.com",
|
|
490
|
+
"See attached",
|
|
491
|
+
{
|
|
492
|
+
subject: "Docs",
|
|
493
|
+
attachments: [
|
|
494
|
+
{
|
|
495
|
+
filename: "report.pdf",
|
|
496
|
+
mimeType: "application/pdf",
|
|
497
|
+
data: Buffer.from("pdf-bytes"),
|
|
498
|
+
},
|
|
499
|
+
],
|
|
500
|
+
},
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
expect(mockSendMessage).toHaveBeenCalledWith(conn, {
|
|
504
|
+
message: {
|
|
505
|
+
subject: "Docs",
|
|
506
|
+
body: { contentType: "text", content: "See attached" },
|
|
507
|
+
toRecipients: [
|
|
508
|
+
{ emailAddress: { address: "recipient@example.com" } },
|
|
509
|
+
],
|
|
510
|
+
attachments: [
|
|
511
|
+
{
|
|
512
|
+
"@odata.type": "#microsoft.graph.fileAttachment",
|
|
513
|
+
name: "report.pdf",
|
|
514
|
+
contentType: "application/pdf",
|
|
515
|
+
contentBytes: Buffer.from("pdf-bytes").toString("base64"),
|
|
516
|
+
},
|
|
517
|
+
],
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test("forwards attachments to replyToMessage when replying", async () => {
|
|
523
|
+
const conn = createMockConnection();
|
|
524
|
+
await outlookMessagingProvider.sendMessage(
|
|
525
|
+
conn,
|
|
526
|
+
"conv-id",
|
|
527
|
+
"Reply body",
|
|
528
|
+
{
|
|
529
|
+
inReplyTo: "original-msg-id",
|
|
530
|
+
attachments: [
|
|
531
|
+
{
|
|
532
|
+
filename: "report.pdf",
|
|
533
|
+
mimeType: "application/pdf",
|
|
534
|
+
data: Buffer.from("pdf-bytes"),
|
|
535
|
+
},
|
|
536
|
+
],
|
|
537
|
+
},
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
expect(mockReplyToMessage).toHaveBeenCalledWith(
|
|
541
|
+
conn,
|
|
542
|
+
"original-msg-id",
|
|
543
|
+
"Reply body",
|
|
544
|
+
[
|
|
545
|
+
{
|
|
546
|
+
"@odata.type": "#microsoft.graph.fileAttachment",
|
|
547
|
+
name: "report.pdf",
|
|
548
|
+
contentType: "application/pdf",
|
|
549
|
+
contentBytes: Buffer.from("pdf-bytes").toString("base64"),
|
|
550
|
+
},
|
|
551
|
+
],
|
|
552
|
+
);
|
|
553
|
+
});
|
|
554
|
+
|
|
484
555
|
test("throws when connection is undefined", async () => {
|
|
485
556
|
await expect(
|
|
486
557
|
outlookMessagingProvider.sendMessage(
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
5
|
+
|
|
6
|
+
import { ROUTES as APP_MGMT_ROUTES } from "../runtime/routes/app-management-routes.js";
|
|
7
|
+
import { ROUTES as APP_ROUTES } from "../runtime/routes/app-routes.js";
|
|
8
|
+
import { BadRequestError, NotFoundError } from "../runtime/routes/errors.js";
|
|
9
|
+
import type {
|
|
10
|
+
ResponseHeaderArgs,
|
|
11
|
+
RouteDefinition,
|
|
12
|
+
RouteHandlerArgs,
|
|
13
|
+
} from "../runtime/routes/types.js";
|
|
14
|
+
import { getWorkspacePluginsDir } from "../util/platform.js";
|
|
15
|
+
|
|
16
|
+
let workspaceDir: string;
|
|
17
|
+
|
|
18
|
+
function findRoute(routes: RouteDefinition[], operationId: string) {
|
|
19
|
+
const route = routes.find((r) => r.operationId === operationId);
|
|
20
|
+
if (!route) {
|
|
21
|
+
throw new Error(`Route not found: ${operationId}`);
|
|
22
|
+
}
|
|
23
|
+
return route;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Install a plugin with a package.json and one bundled app directory. */
|
|
27
|
+
function installPluginApp(
|
|
28
|
+
plugin: string,
|
|
29
|
+
app: string,
|
|
30
|
+
files: Record<string, string>,
|
|
31
|
+
): void {
|
|
32
|
+
const appDir = join(getWorkspacePluginsDir(), plugin, "apps", app);
|
|
33
|
+
mkdirSync(appDir, { recursive: true });
|
|
34
|
+
writeFileSync(
|
|
35
|
+
join(getWorkspacePluginsDir(), plugin, "package.json"),
|
|
36
|
+
JSON.stringify({ name: plugin, version: "1.0.0" }),
|
|
37
|
+
);
|
|
38
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
39
|
+
const full = join(appDir, rel);
|
|
40
|
+
mkdirSync(join(full, ".."), { recursive: true });
|
|
41
|
+
writeFileSync(full, content);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
workspaceDir = join(
|
|
47
|
+
tmpdir(),
|
|
48
|
+
`vellum-plugin-serve-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
49
|
+
);
|
|
50
|
+
process.env.VELLUM_WORKSPACE_DIR = workspaceDir;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
rmSync(workspaceDir, { recursive: true, force: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("plugin app serve routes", () => {
|
|
58
|
+
test("pages_serve renders a single-file plugin app's index.html", () => {
|
|
59
|
+
installPluginApp("acme", "dash", {
|
|
60
|
+
"index.html": "<div id='root'>Plugin Home</div>",
|
|
61
|
+
});
|
|
62
|
+
const handler = findRoute(APP_ROUTES, "pages_serve").handler;
|
|
63
|
+
const html = handler({
|
|
64
|
+
pathParams: { appId: "plugins~acme~dash" },
|
|
65
|
+
} as RouteHandlerArgs) as string;
|
|
66
|
+
|
|
67
|
+
expect(html).toContain("Plugin Home");
|
|
68
|
+
expect(html).toContain("<!DOCTYPE html>");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("pages_serve 404s for an unknown / non-plugin id", () => {
|
|
72
|
+
const handler = findRoute(APP_ROUTES, "pages_serve").handler;
|
|
73
|
+
expect(() =>
|
|
74
|
+
handler({
|
|
75
|
+
pathParams: { appId: "plugins~acme~missing" },
|
|
76
|
+
} as RouteHandlerArgs),
|
|
77
|
+
).toThrow(NotFoundError);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("servePageHeaders CSP: single-file allows inline scripts, multifile does not", () => {
|
|
81
|
+
installPluginApp("acme", "single", {
|
|
82
|
+
"index.html": "<div>x</div>",
|
|
83
|
+
});
|
|
84
|
+
installPluginApp("acme", "multi", {
|
|
85
|
+
"dist/index.html": "<script src='main.js'></script>",
|
|
86
|
+
});
|
|
87
|
+
const route = findRoute(APP_ROUTES, "pages_serve");
|
|
88
|
+
const headersFn = route.responseHeaders as (
|
|
89
|
+
args: ResponseHeaderArgs,
|
|
90
|
+
) => Record<string, string>;
|
|
91
|
+
|
|
92
|
+
const single = headersFn({ pathParams: { appId: "plugins~acme~single" } });
|
|
93
|
+
const multi = headersFn({ pathParams: { appId: "plugins~acme~multi" } });
|
|
94
|
+
|
|
95
|
+
expect(single["Content-Security-Policy"]).toContain(
|
|
96
|
+
"script-src 'self' 'unsafe-inline'",
|
|
97
|
+
);
|
|
98
|
+
expect(multi["Content-Security-Policy"]).toContain("script-src 'self';");
|
|
99
|
+
expect(multi["Content-Security-Policy"]).not.toContain(
|
|
100
|
+
"script-src 'self' 'unsafe-inline'",
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("apps_asset serves a bundled asset from a plugin app dir", () => {
|
|
105
|
+
installPluginApp("acme", "dash", {
|
|
106
|
+
"index.html": "<div>x</div>",
|
|
107
|
+
"logo.svg": "<svg>logo</svg>",
|
|
108
|
+
});
|
|
109
|
+
const handler = findRoute(APP_ROUTES, "apps_asset").handler;
|
|
110
|
+
const bytes = handler({
|
|
111
|
+
pathParams: { appId: "plugins~acme~dash", path: "logo.svg" },
|
|
112
|
+
} as RouteHandlerArgs) as Uint8Array;
|
|
113
|
+
|
|
114
|
+
expect(Buffer.from(bytes).toString("utf-8")).toBe("<svg>logo</svg>");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("apps_asset rejects traversal out of a plugin app dir", () => {
|
|
118
|
+
installPluginApp("acme", "dash", { "index.html": "<div>x</div>" });
|
|
119
|
+
const handler = findRoute(APP_ROUTES, "apps_asset").handler;
|
|
120
|
+
expect(() =>
|
|
121
|
+
handler({
|
|
122
|
+
pathParams: { appId: "plugins~acme~dash", path: "../../package.json" },
|
|
123
|
+
} as RouteHandlerArgs),
|
|
124
|
+
).toThrow(BadRequestError);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("apps_dist_file serves a compiled asset from a plugin app dist/", () => {
|
|
128
|
+
installPluginApp("acme", "multi", {
|
|
129
|
+
"dist/index.html": "<html></html>",
|
|
130
|
+
"dist/main.js": "console.log(1)",
|
|
131
|
+
});
|
|
132
|
+
const handler = findRoute(APP_ROUTES, "apps_dist_file").handler;
|
|
133
|
+
const bytes = handler({
|
|
134
|
+
pathParams: { appId: "plugins~acme~multi", filename: "main.js" },
|
|
135
|
+
} as RouteHandlerArgs) as Uint8Array;
|
|
136
|
+
|
|
137
|
+
expect(Buffer.from(bytes).toString("utf-8")).toBe("console.log(1)");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("plugin apps are read-only over the management surface", () => {
|
|
142
|
+
test("apps_delete rejects a plugin app id", () => {
|
|
143
|
+
installPluginApp("acme", "dash", { "index.html": "<div>x</div>" });
|
|
144
|
+
const handler = findRoute(APP_MGMT_ROUTES, "apps_delete").handler;
|
|
145
|
+
expect(() =>
|
|
146
|
+
handler({
|
|
147
|
+
pathParams: { id: "plugins~acme~dash" },
|
|
148
|
+
headers: {},
|
|
149
|
+
} as RouteHandlerArgs),
|
|
150
|
+
).toThrow(BadRequestError);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("apps_data_mutate rejects a plugin app id", () => {
|
|
154
|
+
installPluginApp("acme", "dash", { "index.html": "<div>x</div>" });
|
|
155
|
+
const handler = findRoute(APP_MGMT_ROUTES, "apps_data_mutate").handler;
|
|
156
|
+
expect(() =>
|
|
157
|
+
handler({
|
|
158
|
+
pathParams: { id: "plugins~acme~dash" },
|
|
159
|
+
body: { method: "create", data: {} },
|
|
160
|
+
} as RouteHandlerArgs),
|
|
161
|
+
).toThrow(BadRequestError);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -191,6 +191,7 @@ const BASELINE: Record<string, readonly string[]> = {
|
|
|
191
191
|
"../../../runtime/capabilities.js",
|
|
192
192
|
"../../../runtime/services/auto-analysis-guard.js",
|
|
193
193
|
"../../../runtime/sync/resource-sync-events.js",
|
|
194
|
+
"../../../telemetry/watchdog-events-store.js",
|
|
194
195
|
"../../../tools/registry.js",
|
|
195
196
|
"../../../tools/types.js",
|
|
196
197
|
"../../../types.js",
|
|
@@ -13,6 +13,21 @@ import type { SkillSource } from "../config/skills.js";
|
|
|
13
13
|
const TEST_DIR = process.env.VELLUM_WORKSPACE_DIR!;
|
|
14
14
|
const mockRefreshSkillCapabilityMemories = mock(() => {});
|
|
15
15
|
|
|
16
|
+
const watchdogEvents: Array<{
|
|
17
|
+
checkName: string;
|
|
18
|
+
value?: number | null;
|
|
19
|
+
detail?: Record<string, unknown> | null;
|
|
20
|
+
}> = [];
|
|
21
|
+
mock.module("../telemetry/watchdog-events-store.js", () => ({
|
|
22
|
+
recordWatchdogEvent: (record: {
|
|
23
|
+
checkName: string;
|
|
24
|
+
value?: number | null;
|
|
25
|
+
detail?: Record<string, unknown> | null;
|
|
26
|
+
}) => {
|
|
27
|
+
watchdogEvents.push(record);
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
|
|
16
31
|
mock.module("../daemon/skill-memory-refresh.js", () => ({
|
|
17
32
|
refreshSkillCapabilityMemories: mockRefreshSkillCapabilityMemories,
|
|
18
33
|
}));
|
|
@@ -88,6 +103,7 @@ beforeEach(() => {
|
|
|
88
103
|
mockRefreshSkillCapabilityMemories.mockClear();
|
|
89
104
|
skillCardJobUpserts = [];
|
|
90
105
|
skillCardUpsertThrows = false;
|
|
106
|
+
watchdogEvents.length = 0;
|
|
91
107
|
});
|
|
92
108
|
|
|
93
109
|
afterEach(() => {
|
|
@@ -146,6 +162,16 @@ describe("scaffold_managed_skill tool", () => {
|
|
|
146
162
|
expect(skill).toBeDefined();
|
|
147
163
|
expect(skill!.name).toBe("Test Skill");
|
|
148
164
|
expect(mockRefreshSkillCapabilityMemories).toHaveBeenCalledTimes(1);
|
|
165
|
+
|
|
166
|
+
// A genuine create emits the central authoring counter, attributed to
|
|
167
|
+
// the user for a non-retrospective origin.
|
|
168
|
+
expect(watchdogEvents).toEqual([
|
|
169
|
+
{
|
|
170
|
+
checkName: "skill_authored",
|
|
171
|
+
value: 1,
|
|
172
|
+
detail: { authored_by: "user" },
|
|
173
|
+
},
|
|
174
|
+
]);
|
|
149
175
|
});
|
|
150
176
|
|
|
151
177
|
test("accepts legacy add_to_index input without returning index metadata", async () => {
|
|
@@ -202,6 +228,12 @@ describe("scaffold_managed_skill tool", () => {
|
|
|
202
228
|
makeContext(),
|
|
203
229
|
);
|
|
204
230
|
expect(result3.isError).toBe(false);
|
|
231
|
+
|
|
232
|
+
// Only the original create counts — the overwrite refined an existing
|
|
233
|
+
// skill and must not emit a second authoring event.
|
|
234
|
+
expect(
|
|
235
|
+
watchdogEvents.filter((e) => e.checkName === "skill_authored"),
|
|
236
|
+
).toHaveLength(1);
|
|
205
237
|
});
|
|
206
238
|
|
|
207
239
|
test("rejects missing required fields", async () => {
|
|
@@ -691,6 +723,15 @@ describe("scaffold_managed_skill tool", () => {
|
|
|
691
723
|
|
|
692
724
|
expect(result.isError).toBe(false);
|
|
693
725
|
expect(installMetaFor("retro-skill")?.author).toBe("assistant");
|
|
726
|
+
// The central authoring counter attributes the create to the
|
|
727
|
+
// retrospective.
|
|
728
|
+
expect(watchdogEvents).toEqual([
|
|
729
|
+
{
|
|
730
|
+
checkName: "skill_authored",
|
|
731
|
+
value: 1,
|
|
732
|
+
detail: { authored_by: "retrospective" },
|
|
733
|
+
},
|
|
734
|
+
]);
|
|
694
735
|
});
|
|
695
736
|
|
|
696
737
|
test('tags author "user" for a normal (non-retrospective) scaffold', async () => {
|
|
@@ -851,6 +892,12 @@ describe("scaffold_managed_skill tool", () => {
|
|
|
851
892
|
),
|
|
852
893
|
),
|
|
853
894
|
).toBe(true);
|
|
895
|
+
|
|
896
|
+
// Only the V1 create counts toward authoring — the V2 refinement of an
|
|
897
|
+
// existing skill emits no second event.
|
|
898
|
+
expect(
|
|
899
|
+
watchdogEvents.filter((e) => e.checkName === "skill_authored"),
|
|
900
|
+
).toHaveLength(1);
|
|
854
901
|
});
|
|
855
902
|
|
|
856
903
|
// ── Conversation lineage (retrospective-authored skills) ───────────────────
|
|
@@ -79,6 +79,7 @@ mock.module("../runtime/assistant-event-hub.js", () => ({
|
|
|
79
79
|
broadcastMessage: () => {},
|
|
80
80
|
}));
|
|
81
81
|
|
|
82
|
+
import type { Conversation } from "../daemon/conversation.js";
|
|
82
83
|
import { isToolActiveForContext } from "../daemon/conversation-tool-setup.js";
|
|
83
84
|
import type { SubagentRecord } from "../persistence/subagent-store.js";
|
|
84
85
|
import { notifyParentFromChild } from "../subagent/notify.js";
|
|
@@ -171,7 +172,7 @@ describe("notify_parent tool definition", () => {
|
|
|
171
172
|
skillProjectionState: new Map(),
|
|
172
173
|
skillProjectionCache: new Map(),
|
|
173
174
|
toolsDisabledDepth: 0,
|
|
174
|
-
} as unknown as
|
|
175
|
+
} as unknown as Conversation;
|
|
175
176
|
expect(isToolActiveForContext("notify_parent", ctx)).toBe(false);
|
|
176
177
|
});
|
|
177
178
|
|
|
@@ -181,7 +182,7 @@ describe("notify_parent tool definition", () => {
|
|
|
181
182
|
skillProjectionState: new Map(),
|
|
182
183
|
skillProjectionCache: new Map(),
|
|
183
184
|
toolsDisabledDepth: 0,
|
|
184
|
-
} as unknown as
|
|
185
|
+
} as unknown as Conversation;
|
|
185
186
|
expect(isToolActiveForContext("notify_parent", ctx)).toBe(false);
|
|
186
187
|
});
|
|
187
188
|
|
|
@@ -192,7 +193,7 @@ describe("notify_parent tool definition", () => {
|
|
|
192
193
|
skillProjectionState: new Map(),
|
|
193
194
|
skillProjectionCache: new Map(),
|
|
194
195
|
toolsDisabledDepth: 0,
|
|
195
|
-
} as unknown as
|
|
196
|
+
} as unknown as Conversation;
|
|
196
197
|
expect(isToolActiveForContext("notify_parent", ctx)).toBe(true);
|
|
197
198
|
});
|
|
198
199
|
});
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
+
import type { Conversation } from "../daemon/conversation.js";
|
|
3
4
|
import {
|
|
4
5
|
isToolActiveForContext,
|
|
5
|
-
type SkillProjectionContext,
|
|
6
6
|
SUBAGENT_ONLY_TOOL_NAMES,
|
|
7
7
|
} from "../daemon/conversation-tool-setup.js";
|
|
8
8
|
|
|
@@ -18,48 +18,48 @@ describe("subagent-only tool filtering", () => {
|
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
test("hides subagent-only tools from main conversations (isSubagent=false)", () => {
|
|
21
|
-
const ctx
|
|
21
|
+
const ctx = {
|
|
22
22
|
skillProjectionState: new Map(),
|
|
23
23
|
skillProjectionCache: {},
|
|
24
24
|
toolsDisabledDepth: 0,
|
|
25
25
|
hasNoClient: false,
|
|
26
26
|
isSubagent: false,
|
|
27
|
-
};
|
|
27
|
+
} as unknown as Conversation;
|
|
28
28
|
|
|
29
29
|
expect(isToolActiveForContext(TEST_TOOL_NAME, ctx)).toBe(false);
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
test("hides subagent-only tools when isSubagent is undefined", () => {
|
|
33
|
-
const ctx
|
|
33
|
+
const ctx = {
|
|
34
34
|
skillProjectionState: new Map(),
|
|
35
35
|
skillProjectionCache: {},
|
|
36
36
|
toolsDisabledDepth: 0,
|
|
37
37
|
hasNoClient: false,
|
|
38
|
-
};
|
|
38
|
+
} as unknown as Conversation;
|
|
39
39
|
|
|
40
40
|
expect(isToolActiveForContext(TEST_TOOL_NAME, ctx)).toBe(false);
|
|
41
41
|
});
|
|
42
42
|
|
|
43
43
|
test("shows subagent-only tools to subagent conversations (isSubagent=true)", () => {
|
|
44
|
-
const ctx
|
|
44
|
+
const ctx = {
|
|
45
45
|
skillProjectionState: new Map(),
|
|
46
46
|
skillProjectionCache: {},
|
|
47
47
|
toolsDisabledDepth: 0,
|
|
48
48
|
hasNoClient: true,
|
|
49
49
|
isSubagent: true,
|
|
50
|
-
};
|
|
50
|
+
} as unknown as Conversation;
|
|
51
51
|
|
|
52
52
|
expect(isToolActiveForContext(TEST_TOOL_NAME, ctx)).toBe(true);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
test("does not affect regular tools when isSubagent is false", () => {
|
|
56
|
-
const ctx
|
|
56
|
+
const ctx = {
|
|
57
57
|
skillProjectionState: new Map(),
|
|
58
58
|
skillProjectionCache: {},
|
|
59
59
|
toolsDisabledDepth: 0,
|
|
60
60
|
hasNoClient: false,
|
|
61
61
|
isSubagent: false,
|
|
62
|
-
};
|
|
62
|
+
} as unknown as Conversation;
|
|
63
63
|
|
|
64
64
|
// A regular tool not in SUBAGENT_ONLY_TOOL_NAMES should still be active
|
|
65
65
|
expect(isToolActiveForContext("bash", ctx)).toBe(true);
|
|
@@ -68,14 +68,14 @@ describe("subagent-only tool filtering", () => {
|
|
|
68
68
|
test("respects subagentAllowedTools — tools outside the allowlist are inactive", () => {
|
|
69
69
|
// Mirrors `createResolveToolsCallback`'s post-filter so callers see the
|
|
70
70
|
// same final tool set the LLM receives.
|
|
71
|
-
const ctx
|
|
71
|
+
const ctx = {
|
|
72
72
|
skillProjectionState: new Map(),
|
|
73
73
|
skillProjectionCache: {},
|
|
74
74
|
toolsDisabledDepth: 0,
|
|
75
75
|
hasNoClient: false,
|
|
76
76
|
isSubagent: true,
|
|
77
77
|
subagentAllowedTools: new Set(["bash"]),
|
|
78
|
-
};
|
|
78
|
+
} as unknown as Conversation;
|
|
79
79
|
|
|
80
80
|
expect(isToolActiveForContext("bash", ctx)).toBe(true);
|
|
81
81
|
expect(isToolActiveForContext("ask_question", ctx)).toBe(false);
|
|
@@ -86,28 +86,28 @@ describe("subagent-only tool filtering", () => {
|
|
|
86
86
|
// `subagentToolGateMode: "execution"` keeps the full tool surface on the
|
|
87
87
|
// wire for provider prompt-cache parity; the executor callback rejects
|
|
88
88
|
// non-allowlisted calls instead.
|
|
89
|
-
const ctx
|
|
89
|
+
const ctx = {
|
|
90
90
|
skillProjectionState: new Map(),
|
|
91
91
|
skillProjectionCache: {},
|
|
92
92
|
toolsDisabledDepth: 0,
|
|
93
93
|
hasNoClient: false,
|
|
94
94
|
subagentAllowedTools: new Set(["remember"]),
|
|
95
95
|
subagentToolGateMode: "execution",
|
|
96
|
-
};
|
|
96
|
+
} as unknown as Conversation;
|
|
97
97
|
|
|
98
98
|
expect(isToolActiveForContext("remember", ctx)).toBe(true);
|
|
99
99
|
expect(isToolActiveForContext("bash", ctx)).toBe(true);
|
|
100
100
|
});
|
|
101
101
|
|
|
102
102
|
test("explicit wire gate mode filters exactly like the absent default", () => {
|
|
103
|
-
const ctx
|
|
103
|
+
const ctx = {
|
|
104
104
|
skillProjectionState: new Map(),
|
|
105
105
|
skillProjectionCache: {},
|
|
106
106
|
toolsDisabledDepth: 0,
|
|
107
107
|
hasNoClient: false,
|
|
108
108
|
subagentAllowedTools: new Set(["remember"]),
|
|
109
109
|
subagentToolGateMode: "wire",
|
|
110
|
-
};
|
|
110
|
+
} as unknown as Conversation;
|
|
111
111
|
|
|
112
112
|
expect(isToolActiveForContext("remember", ctx)).toBe(true);
|
|
113
113
|
expect(isToolActiveForContext("bash", ctx)).toBe(false);
|
|
@@ -116,14 +116,14 @@ describe("subagent-only tool filtering", () => {
|
|
|
116
116
|
test("execution gate mode still applies non-allowlist filters (toolsDisabledDepth)", () => {
|
|
117
117
|
// The execution gate only bypasses the subagent allowlist's wire filter;
|
|
118
118
|
// every other visibility rule still applies.
|
|
119
|
-
const ctx
|
|
119
|
+
const ctx = {
|
|
120
120
|
skillProjectionState: new Map(),
|
|
121
121
|
skillProjectionCache: {},
|
|
122
122
|
toolsDisabledDepth: 1,
|
|
123
123
|
hasNoClient: false,
|
|
124
124
|
subagentAllowedTools: new Set(["remember"]),
|
|
125
125
|
subagentToolGateMode: "execution",
|
|
126
|
-
};
|
|
126
|
+
} as unknown as Conversation;
|
|
127
127
|
|
|
128
128
|
expect(isToolActiveForContext("remember", ctx)).toBe(false);
|
|
129
129
|
expect(isToolActiveForContext("bash", ctx)).toBe(false);
|
|
@@ -132,12 +132,12 @@ describe("subagent-only tool filtering", () => {
|
|
|
132
132
|
test("returns false for every tool when toolsDisabledDepth > 0", () => {
|
|
133
133
|
// `createResolveToolsCallback` returns an empty tool list when tools are
|
|
134
134
|
// disabled; mirror it here so this helper reports the same final tool set.
|
|
135
|
-
const ctx
|
|
135
|
+
const ctx = {
|
|
136
136
|
skillProjectionState: new Map(),
|
|
137
137
|
skillProjectionCache: {},
|
|
138
138
|
toolsDisabledDepth: 1,
|
|
139
139
|
hasNoClient: false,
|
|
140
|
-
};
|
|
140
|
+
} as unknown as Conversation;
|
|
141
141
|
|
|
142
142
|
expect(isToolActiveForContext("bash", ctx)).toBe(false);
|
|
143
143
|
expect(isToolActiveForContext("ask_question", ctx)).toBe(false);
|
|
@@ -146,13 +146,13 @@ describe("subagent-only tool filtering", () => {
|
|
|
146
146
|
test("under disk-pressure cleanup mode, only cleanup-safe tools are active", () => {
|
|
147
147
|
// `createResolveToolsCallback` restricts the turn to cleanup-safe tools
|
|
148
148
|
// (`file_remove`, `bash`, etc.); ensure the helper agrees.
|
|
149
|
-
const ctx
|
|
149
|
+
const ctx = {
|
|
150
150
|
skillProjectionState: new Map(),
|
|
151
151
|
skillProjectionCache: {},
|
|
152
152
|
toolsDisabledDepth: 0,
|
|
153
153
|
hasNoClient: false,
|
|
154
154
|
diskPressureCleanupModeActive: true,
|
|
155
|
-
};
|
|
155
|
+
} as unknown as Conversation;
|
|
156
156
|
|
|
157
157
|
// `bash` is in DISK_PRESSURE_CLEANUP_TOOL_NAMES; `ask_question` is not.
|
|
158
158
|
expect(isToolActiveForContext("bash", ctx)).toBe(true);
|
|
@@ -72,10 +72,10 @@ mock.module("../daemon/conversation-skill-tools.js", () => ({
|
|
|
72
72
|
// Imports after mocks are in place
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
74
|
|
|
75
|
+
import type { Conversation } from "../daemon/conversation.js";
|
|
75
76
|
import {
|
|
76
77
|
createResolveToolsCallback,
|
|
77
78
|
createToolExecutor,
|
|
78
|
-
type SkillProjectionContext,
|
|
79
79
|
type ToolSetupContext,
|
|
80
80
|
} from "../daemon/conversation-tool-setup.js";
|
|
81
81
|
import {
|
|
@@ -95,14 +95,14 @@ function makeToolDef(name: string): ToolDefinition {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
function makeProjectionCtx(
|
|
98
|
-
overrides: Partial<
|
|
99
|
-
):
|
|
98
|
+
overrides: Partial<Conversation> = {},
|
|
99
|
+
): Conversation {
|
|
100
100
|
return {
|
|
101
101
|
skillProjectionState: new Map(),
|
|
102
102
|
skillProjectionCache: {} as SkillProjectionCache,
|
|
103
103
|
toolsDisabledDepth: 0,
|
|
104
104
|
...overrides,
|
|
105
|
-
};
|
|
105
|
+
} as unknown as Conversation;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
function makeSetupCtx(
|
|
@@ -237,8 +237,8 @@ describe("createResolveToolsCallback — toolContextPin", () => {
|
|
|
237
237
|
|
|
238
238
|
/** Execution-gate ctx shaped like a clientless fork-retrospective wake. */
|
|
239
239
|
function clientlessExecutionCtx(
|
|
240
|
-
overrides: Partial<
|
|
241
|
-
):
|
|
240
|
+
overrides: Partial<Conversation> = {},
|
|
241
|
+
): Conversation {
|
|
242
242
|
return makeProjectionCtx({
|
|
243
243
|
hasNoClient: true,
|
|
244
244
|
subagentAllowedTools: new Set(["remember"]),
|