@vellumai/assistant 0.8.10-dev.202606100925.28c1cfb → 0.8.10-dev.202606101324.2fc90b3
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/openapi.yaml +63 -2
- package/package.json +1 -1
- package/src/__tests__/agent-loop-exit-reason.test.ts +9 -81
- package/src/__tests__/agent-loop.test.ts +53 -3
- package/src/__tests__/catalog-install-normalize.test.ts +141 -0
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +219 -427
- package/src/__tests__/conversation-agent-loop.test.ts +73 -504
- package/src/__tests__/conversation-provider-retry-repair.test.ts +11 -336
- package/src/__tests__/conversation-tool-setup-tools-disabled.test.ts +34 -0
- package/src/__tests__/credential-security-invariants.test.ts +1 -2
- package/src/__tests__/{context-overflow-policy.test.ts → overflow-policy.test.ts} +1 -1
- package/src/__tests__/tools-get-route.test.ts +202 -0
- package/src/agent/loop.ts +207 -104
- package/src/cli/commands/tools.ts +106 -0
- package/src/cli/program.ts +2 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +3 -25
- package/src/daemon/conversation-agent-loop.ts +83 -319
- package/src/daemon/conversation-tool-setup.ts +11 -0
- package/src/daemon/conversation.ts +25 -0
- package/src/daemon/external-plugins-bootstrap.ts +3 -8
- package/src/memory/v2/__tests__/consolidation-job.test.ts +27 -5
- package/src/memory/v2/consolidation-job.ts +90 -22
- package/src/plugin-api/types.ts +7 -0
- package/src/plugins/defaults/compaction/compact.ts +27 -1
- package/src/{daemon/context-overflow-policy.ts → plugins/defaults/compaction/overflow-policy.ts} +1 -1
- package/src/plugins/defaults/compaction/window-manager.ts +114 -6
- package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +4 -4
- package/src/runtime/routes/settings-routes.ts +140 -15
- package/src/skills/catalog-install.ts +77 -13
- package/src/__tests__/plugin-external-api.test.ts +0 -68
- package/src/plugins/external-api.ts +0 -114
package/openapi.yaml
CHANGED
|
@@ -24689,13 +24689,74 @@ paths:
|
|
|
24689
24689
|
/v1/tools:
|
|
24690
24690
|
get:
|
|
24691
24691
|
operationId: tools_get
|
|
24692
|
-
summary: List tools
|
|
24693
|
-
description:
|
|
24692
|
+
summary: List registered tools with metadata and schemas
|
|
24693
|
+
description:
|
|
24694
|
+
"Return registered tools. Without `conversationId`, returns every tool in the global registry; `tools`
|
|
24695
|
+
carries per-tool metadata (description, author-asserted risk level, category, and contributing source: core,
|
|
24696
|
+
skill, plugin, or MCP server) and `names`/`schemas` additionally cover skill tools whose manifests are present
|
|
24697
|
+
but not yet loaded, for the permission-simulator catalog. With `conversationId`, scopes the result to the tools
|
|
24698
|
+
available to that conversation as of its most recent turn (including skill/MCP tools registered over its
|
|
24699
|
+
lifecycle); 404 if no such conversation is active."
|
|
24694
24700
|
tags:
|
|
24695
24701
|
- tools
|
|
24696
24702
|
responses:
|
|
24697
24703
|
"200":
|
|
24698
24704
|
description: Successful response
|
|
24705
|
+
content:
|
|
24706
|
+
application/json:
|
|
24707
|
+
schema:
|
|
24708
|
+
type: object
|
|
24709
|
+
properties:
|
|
24710
|
+
names:
|
|
24711
|
+
type: array
|
|
24712
|
+
items:
|
|
24713
|
+
type: string
|
|
24714
|
+
schemas:
|
|
24715
|
+
type: object
|
|
24716
|
+
propertyNames:
|
|
24717
|
+
type: string
|
|
24718
|
+
additionalProperties:
|
|
24719
|
+
type: object
|
|
24720
|
+
propertyNames:
|
|
24721
|
+
type: string
|
|
24722
|
+
additionalProperties: {}
|
|
24723
|
+
tools:
|
|
24724
|
+
type: array
|
|
24725
|
+
items:
|
|
24726
|
+
type: object
|
|
24727
|
+
properties:
|
|
24728
|
+
name:
|
|
24729
|
+
type: string
|
|
24730
|
+
description:
|
|
24731
|
+
type: string
|
|
24732
|
+
riskLevel:
|
|
24733
|
+
type: string
|
|
24734
|
+
category:
|
|
24735
|
+
type: string
|
|
24736
|
+
source:
|
|
24737
|
+
type: string
|
|
24738
|
+
description:
|
|
24739
|
+
'Tool origin: "core" for built-ins, otherwise "<kind>:<id>" (e.g. "plugin:echo", "skill:my-skill",
|
|
24740
|
+
"mcp:server").'
|
|
24741
|
+
required:
|
|
24742
|
+
- name
|
|
24743
|
+
- description
|
|
24744
|
+
- riskLevel
|
|
24745
|
+
- category
|
|
24746
|
+
- source
|
|
24747
|
+
additionalProperties: false
|
|
24748
|
+
required:
|
|
24749
|
+
- names
|
|
24750
|
+
- schemas
|
|
24751
|
+
- tools
|
|
24752
|
+
additionalProperties: false
|
|
24753
|
+
parameters:
|
|
24754
|
+
- name: conversationId
|
|
24755
|
+
in: query
|
|
24756
|
+
required: false
|
|
24757
|
+
schema:
|
|
24758
|
+
type: string
|
|
24759
|
+
description: When set, scope the tool inventory to this conversation's most recent turn instead of the global registry.
|
|
24699
24760
|
/v1/tools/simulate-permission:
|
|
24700
24761
|
post:
|
|
24701
24762
|
operationId: tools_simulatepermission_post
|
package/package.json
CHANGED
|
@@ -365,7 +365,7 @@ describe("AgentLoop exit-reason instrumentation", () => {
|
|
|
365
365
|
});
|
|
366
366
|
|
|
367
367
|
const onCheckpoint = (_info: CheckpointInfo): CheckpointDecision =>
|
|
368
|
-
"
|
|
368
|
+
"handoff";
|
|
369
369
|
|
|
370
370
|
const events: AgentEvent[] = [];
|
|
371
371
|
await loop.run({
|
|
@@ -380,42 +380,9 @@ describe("AgentLoop exit-reason instrumentation", () => {
|
|
|
380
380
|
expect(countExitEvents(events)).toBe(0);
|
|
381
381
|
});
|
|
382
382
|
|
|
383
|
-
test("
|
|
384
|
-
// GIVEN a
|
|
385
|
-
//
|
|
386
|
-
const { provider } = createMockProvider([
|
|
387
|
-
toolUseResponse("t1", "read_file", { path: "/a.txt" }),
|
|
388
|
-
textResponse("never reached"),
|
|
389
|
-
]);
|
|
390
|
-
const toolExecutor = async () => ({ content: "ok", isError: false });
|
|
391
|
-
const loop = new AgentLoop({
|
|
392
|
-
provider: provider,
|
|
393
|
-
systemPrompt: "system",
|
|
394
|
-
conversationId: "test-conversation",
|
|
395
|
-
tools: dummyTools,
|
|
396
|
-
toolExecutor: toolExecutor,
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
// AND an effective context window so small that any real token estimate
|
|
400
|
-
// of the running history exceeds the mid-loop threshold.
|
|
401
|
-
// WHEN the loop checkpoints after the tool results land
|
|
402
|
-
const result = await loop.run({
|
|
403
|
-
messages: [userMessage],
|
|
404
|
-
onEvent: () => {},
|
|
405
|
-
trust: { sourceChannel: "vellum", trustClass: "unknown" },
|
|
406
|
-
resolveContextWindow: () => ({
|
|
407
|
-
maxInputTokens: 10,
|
|
408
|
-
overflowRecovery: { enabled: true, safetyMarginRatio: 0 },
|
|
409
|
-
}),
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
// THEN it yields for budget before issuing the next provider call.
|
|
413
|
-
expect(result.exitReason).toBe("budget");
|
|
414
|
-
});
|
|
415
|
-
|
|
416
|
-
test("does not yield for budget when overflow recovery is disabled", async () => {
|
|
417
|
-
// GIVEN the same tiny context window but overflow recovery disabled — the
|
|
418
|
-
// agent-wake configuration, which must never yield for budget.
|
|
383
|
+
test("runs to a clean exit when overflow recovery is disabled", async () => {
|
|
384
|
+
// GIVEN a tiny context window but overflow recovery disabled — the
|
|
385
|
+
// agent-wake configuration, which must never compact mid-loop.
|
|
419
386
|
const { provider } = createMockProvider([
|
|
420
387
|
toolUseResponse("t1", "read_file", { path: "/a.txt" }),
|
|
421
388
|
textResponse("done"),
|
|
@@ -440,8 +407,8 @@ describe("AgentLoop exit-reason instrumentation", () => {
|
|
|
440
407
|
}),
|
|
441
408
|
});
|
|
442
409
|
|
|
443
|
-
// THEN it
|
|
444
|
-
expect(result.exitReason).
|
|
410
|
+
// THEN it reaches a clean exit without pausing the loop.
|
|
411
|
+
expect(result.exitReason).toBeNull();
|
|
445
412
|
});
|
|
446
413
|
|
|
447
414
|
test("compacts in place and continues when the budget gate trips with a compaction hook", async () => {
|
|
@@ -485,52 +452,13 @@ describe("AgentLoop exit-reason instrumentation", () => {
|
|
|
485
452
|
});
|
|
486
453
|
|
|
487
454
|
// THEN the loop runs the compaction ceremony in place and continues to a
|
|
488
|
-
// clean exit
|
|
489
|
-
//
|
|
455
|
+
// clean exit. The durable commit is signalled via a `compaction_completed`
|
|
456
|
+
// event rather than an injected hook.
|
|
490
457
|
expect(events.some((event) => event.type === "compaction_completed")).toBe(
|
|
491
458
|
true,
|
|
492
459
|
);
|
|
493
460
|
expect(reinjected).toBe(true);
|
|
494
|
-
expect(result.exitReason).
|
|
495
|
-
});
|
|
496
|
-
|
|
497
|
-
test("yields 'budget' when inline compaction reports exhausted", async () => {
|
|
498
|
-
const { provider } = createMockProvider([
|
|
499
|
-
toolUseResponse("t1", "read_file", { path: "/a.txt" }),
|
|
500
|
-
textResponse("never reached"),
|
|
501
|
-
]);
|
|
502
|
-
const toolExecutor = async () => ({ content: "ok", isError: false });
|
|
503
|
-
const loop = new AgentLoop({
|
|
504
|
-
provider: provider,
|
|
505
|
-
systemPrompt: "system",
|
|
506
|
-
conversationId: "test-conversation",
|
|
507
|
-
tools: dummyTools,
|
|
508
|
-
toolExecutor: toolExecutor,
|
|
509
|
-
});
|
|
510
|
-
|
|
511
|
-
postCompactImpl = async () => {
|
|
512
|
-
throw new Error(
|
|
513
|
-
"post-compaction re-injection must not run when exhausted",
|
|
514
|
-
);
|
|
515
|
-
};
|
|
516
|
-
|
|
517
|
-
// WHEN compaction exhausts its retry budget
|
|
518
|
-
const result = await loop.run({
|
|
519
|
-
messages: [userMessage],
|
|
520
|
-
onEvent: () => {},
|
|
521
|
-
resolveContextWindow: () => ({
|
|
522
|
-
maxInputTokens: 10,
|
|
523
|
-
overflowRecovery: { enabled: true, safetyMarginRatio: 0 },
|
|
524
|
-
}),
|
|
525
|
-
compactInPlace: true,
|
|
526
|
-
...fakeCompaction("test-conversation", {
|
|
527
|
-
compacted: false,
|
|
528
|
-
exhausted: true,
|
|
529
|
-
}),
|
|
530
|
-
});
|
|
531
|
-
|
|
532
|
-
// THEN the loop yields for budget so the orchestrator can escalate.
|
|
533
|
-
expect(result.exitReason).toBe("budget");
|
|
461
|
+
expect(result.exitReason).toBeNull();
|
|
534
462
|
});
|
|
535
463
|
|
|
536
464
|
test("emits 'error' when provider throws an unhandled error", async () => {
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
ProviderResponse,
|
|
15
15
|
ToolDefinition,
|
|
16
16
|
} from "../providers/types.js";
|
|
17
|
+
import { ContextOverflowError } from "../providers/types.js";
|
|
17
18
|
import {
|
|
18
19
|
createMockProvider,
|
|
19
20
|
textResponse,
|
|
@@ -311,6 +312,55 @@ describe("AgentLoop", () => {
|
|
|
311
312
|
).toBe("API rate limit exceeded");
|
|
312
313
|
});
|
|
313
314
|
|
|
315
|
+
// 5b. Reactive context-overflow recovery
|
|
316
|
+
//
|
|
317
|
+
// When recovery is enabled the loop calibrates the estimator from the
|
|
318
|
+
// rejection, loops back, and the budget gate forwards the overflow signal
|
|
319
|
+
// into the compaction plugin's reduction ladder. That manager-owned path —
|
|
320
|
+
// single-rung recovery, multi-rung escalation, and the terminal
|
|
321
|
+
// `context_too_large` / `budget_yield_unrecovered` exit on exhaustion — is
|
|
322
|
+
// exercised end-to-end against a manager harness in
|
|
323
|
+
// `conversation-agent-loop-overflow.test.ts`. Loop-level coverage against a
|
|
324
|
+
// manager-store stub lands with the suite migration.
|
|
325
|
+
test.todo(
|
|
326
|
+
"drives the reduction ladder on a context-overflow rejection",
|
|
327
|
+
() => {},
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
test("surfaces a context-overflow error when recovery is unavailable", async () => {
|
|
331
|
+
/**
|
|
332
|
+
* Overflow recovery is gated on the budget gate being active. When it is
|
|
333
|
+
* disabled (e.g. agent wakes pass `overflowRecovery.enabled = false`, or no
|
|
334
|
+
* context window resolves) there is no ladder to drive, so a context
|
|
335
|
+
* overflow surfaces as an error on the first call rather than looping.
|
|
336
|
+
*/
|
|
337
|
+
|
|
338
|
+
// GIVEN a provider that rejects every call as too large and a loop with no
|
|
339
|
+
// overflow-recovery budget gate
|
|
340
|
+
const { provider, calls } = createMockProvider([
|
|
341
|
+
new ContextOverflowError("prompt is too long", "mock", {
|
|
342
|
+
actualTokens: 250_000,
|
|
343
|
+
}),
|
|
344
|
+
]);
|
|
345
|
+
const loop = new AgentLoop({
|
|
346
|
+
provider,
|
|
347
|
+
systemPrompt: "system",
|
|
348
|
+
conversationId: "test-conversation",
|
|
349
|
+
});
|
|
350
|
+
const events: AgentEvent[] = [];
|
|
351
|
+
|
|
352
|
+
// WHEN the loop runs
|
|
353
|
+
await loop.run({
|
|
354
|
+
messages: [userMessage],
|
|
355
|
+
onEvent: collectEvents(events),
|
|
356
|
+
trust: { sourceChannel: "vellum", trustClass: "unknown" },
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// THEN it surfaced the error on the first call without retrying
|
|
360
|
+
expect(calls).toHaveLength(1);
|
|
361
|
+
expect(events.filter((e) => e.type === "error")).toHaveLength(1);
|
|
362
|
+
});
|
|
363
|
+
|
|
314
364
|
// 6. Abort signal — verify the loop respects AbortSignal
|
|
315
365
|
test("stops when abort signal is triggered before provider call", async () => {
|
|
316
366
|
const controller = new AbortController();
|
|
@@ -1036,7 +1086,7 @@ describe("AgentLoop", () => {
|
|
|
1036
1086
|
toolExecutor: toolExecutor,
|
|
1037
1087
|
});
|
|
1038
1088
|
|
|
1039
|
-
const onCheckpoint = (): CheckpointDecision => "
|
|
1089
|
+
const onCheckpoint = (): CheckpointDecision => "handoff";
|
|
1040
1090
|
|
|
1041
1091
|
const { history } = await loop.run({
|
|
1042
1092
|
messages: [userMessage],
|
|
@@ -1235,7 +1285,7 @@ describe("AgentLoop", () => {
|
|
|
1235
1285
|
const onCheckpoint = (checkpoint: CheckpointInfo): CheckpointDecision => {
|
|
1236
1286
|
checkpoints.push(checkpoint);
|
|
1237
1287
|
// Yield on turn 3 (0-indexed)
|
|
1238
|
-
return checkpoint.turnIndex === 3 ? "
|
|
1288
|
+
return checkpoint.turnIndex === 3 ? "handoff" : "continue";
|
|
1239
1289
|
};
|
|
1240
1290
|
|
|
1241
1291
|
const events: AgentEvent[] = [];
|
|
@@ -1308,7 +1358,7 @@ describe("AgentLoop", () => {
|
|
|
1308
1358
|
|
|
1309
1359
|
const onCheckpoint = (checkpoint: CheckpointInfo): CheckpointDecision => {
|
|
1310
1360
|
// Yield on the second turn (turnIndex 1)
|
|
1311
|
-
return checkpoint.turnIndex === 1 ? "
|
|
1361
|
+
return checkpoint.turnIndex === 1 ? "handoff" : "continue";
|
|
1312
1362
|
};
|
|
1313
1363
|
|
|
1314
1364
|
const { history } = await loop.run({
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for catalog entry normalization in catalog-install.ts.
|
|
3
|
+
*
|
|
4
|
+
* The platform `/v1/skills/` API flattens skill metadata into top-level
|
|
5
|
+
* fields (`category`, `display_name`, `icon`) rather than the nested
|
|
6
|
+
* `metadata.vellum.*` shape used by the local `catalog.json`. Both
|
|
7
|
+
* `fetchCatalog` (remote) and `readLocalCatalog` (local file) must produce a
|
|
8
|
+
* canonical `CatalogSkill` whose `metadata.vellum.category` is populated, so
|
|
9
|
+
* downstream category assignment doesn't fall back to "system" for every skill.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
16
|
+
|
|
17
|
+
mock.module("../util/logger.js", () => ({
|
|
18
|
+
getLogger: () =>
|
|
19
|
+
new Proxy({} as Record<string, unknown>, {
|
|
20
|
+
get: () => () => {},
|
|
21
|
+
}),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
mock.module("../config/env.js", () => ({
|
|
25
|
+
getPlatformBaseUrl: () => "https://platform.example.com",
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
import { fetchCatalog, readLocalCatalog } from "../skills/catalog-install.js";
|
|
29
|
+
|
|
30
|
+
const originalFetch = globalThis.fetch;
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
globalThis.fetch = originalFetch;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build a `fetch` stub that responds with `payload` serialized as JSON.
|
|
38
|
+
*
|
|
39
|
+
* `Object.assign` attaches the `preconnect` static that Bun's `fetch` type
|
|
40
|
+
* requires, so the stub satisfies `typeof fetch` without an `as unknown` cast.
|
|
41
|
+
*/
|
|
42
|
+
function stubFetchJson(payload: unknown): typeof fetch {
|
|
43
|
+
const respond = async () =>
|
|
44
|
+
new Response(JSON.stringify(payload), {
|
|
45
|
+
status: 200,
|
|
46
|
+
headers: { "content-type": "application/json" },
|
|
47
|
+
});
|
|
48
|
+
return Object.assign(respond, { preconnect: () => {} });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("fetchCatalog normalization", () => {
|
|
52
|
+
test("re-nests the platform's flattened category under metadata.vellum", async () => {
|
|
53
|
+
globalThis.fetch = stubFetchJson({
|
|
54
|
+
skills: [
|
|
55
|
+
{
|
|
56
|
+
id: "amazon",
|
|
57
|
+
name: "amazon",
|
|
58
|
+
display_name: "Amazon",
|
|
59
|
+
description: "Shop on Amazon",
|
|
60
|
+
icon: "🛒",
|
|
61
|
+
category: "commerce",
|
|
62
|
+
updated_at: "2026-04-19T19:26:17Z",
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const catalog = await fetchCatalog();
|
|
68
|
+
|
|
69
|
+
expect(catalog).toHaveLength(1);
|
|
70
|
+
const [skill] = catalog;
|
|
71
|
+
expect(skill.id).toBe("amazon");
|
|
72
|
+
expect(skill.metadata?.vellum?.category).toBe("commerce");
|
|
73
|
+
expect(skill.metadata?.vellum?.["display-name"]).toBe("Amazon");
|
|
74
|
+
expect(skill.icon).toBe("🛒");
|
|
75
|
+
expect(skill.metadata?.icon).toBe("🛒");
|
|
76
|
+
expect(skill.updatedAt).toBe("2026-04-19T19:26:17Z");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("leaves category undefined when the API omits it", async () => {
|
|
80
|
+
globalThis.fetch = stubFetchJson({
|
|
81
|
+
skills: [{ id: "no-cat", name: "no-cat", description: "d" }],
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const catalog = await fetchCatalog();
|
|
85
|
+
|
|
86
|
+
expect(catalog).toHaveLength(1);
|
|
87
|
+
expect(catalog[0].metadata?.vellum?.category).toBeUndefined();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("drops malformed entries without throwing", async () => {
|
|
91
|
+
globalThis.fetch = stubFetchJson({
|
|
92
|
+
skills: [
|
|
93
|
+
null,
|
|
94
|
+
undefined,
|
|
95
|
+
"not-an-object",
|
|
96
|
+
{ name: "missing-id", description: "d" },
|
|
97
|
+
{ id: 42 },
|
|
98
|
+
{ id: "valid", name: "valid", description: "d", category: "email" },
|
|
99
|
+
],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const catalog = await fetchCatalog();
|
|
103
|
+
|
|
104
|
+
expect(catalog).toHaveLength(1);
|
|
105
|
+
expect(catalog[0].id).toBe("valid");
|
|
106
|
+
expect(catalog[0].metadata?.vellum?.category).toBe("email");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("readLocalCatalog normalization", () => {
|
|
111
|
+
test("reads the nested metadata.vellum.category from catalog.json", () => {
|
|
112
|
+
const dir = mkdtempSync(join(tmpdir(), "vellum-catalog-"));
|
|
113
|
+
try {
|
|
114
|
+
writeFileSync(
|
|
115
|
+
join(dir, "catalog.json"),
|
|
116
|
+
JSON.stringify({
|
|
117
|
+
version: 1,
|
|
118
|
+
skills: [
|
|
119
|
+
{
|
|
120
|
+
id: "tasks",
|
|
121
|
+
name: "tasks",
|
|
122
|
+
description: "Task management",
|
|
123
|
+
metadata: {
|
|
124
|
+
icon: "✅",
|
|
125
|
+
vellum: { "display-name": "Tasks", category: "productivity" },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const catalog = readLocalCatalog(dir);
|
|
133
|
+
|
|
134
|
+
expect(catalog).toHaveLength(1);
|
|
135
|
+
expect(catalog[0].metadata?.vellum?.category).toBe("productivity");
|
|
136
|
+
expect(catalog[0].metadata?.vellum?.["display-name"]).toBe("Tasks");
|
|
137
|
+
} finally {
|
|
138
|
+
rmSync(dir, { recursive: true, force: true });
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|