@vellumai/assistant 0.10.9-staging.1 → 0.10.9

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 (100) hide show
  1. package/AGENTS.md +4 -0
  2. package/knip.json +1 -4
  3. package/openapi.yaml +27 -17
  4. package/package.json +1 -1
  5. package/src/__tests__/always-loaded-tools-guard.test.ts +4 -6
  6. package/src/__tests__/app-routes-csp.test.ts +13 -4
  7. package/src/__tests__/conversation-tool-setup-tools-disabled.test.ts +4 -8
  8. package/src/__tests__/disk-pressure-tools.test.ts +4 -4
  9. package/src/__tests__/file-list-tool.test.ts +22 -12
  10. package/src/__tests__/list-all-apps.test.ts +204 -0
  11. package/src/__tests__/messaging-send-tool.test.ts +67 -0
  12. package/src/__tests__/mtime-cache.test.ts +42 -0
  13. package/src/__tests__/oauth-provider-profiles.test.ts +3 -2
  14. package/src/__tests__/outlook-messaging-provider.test.ts +71 -0
  15. package/src/__tests__/plugin-app-serve-routes.test.ts +163 -0
  16. package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -0
  17. package/src/__tests__/scaffold-managed-skill-tool.test.ts +47 -0
  18. package/src/__tests__/subagent-notify-parent.test.ts +4 -3
  19. package/src/__tests__/subagent-tool-filtering.test.ts +21 -21
  20. package/src/__tests__/subagent-tool-gate-mode.test.ts +6 -6
  21. package/src/apps/app-store.ts +302 -2
  22. package/src/cli/AGENTS.md +17 -0
  23. package/src/cli/commands/__tests__/gateway.test.ts +55 -0
  24. package/src/cli/commands/apps.help.ts +17 -11
  25. package/src/cli/commands/apps.ts +5 -15
  26. package/src/cli/commands/gateway.help.ts +30 -0
  27. package/src/cli/commands/gateway.ts +40 -1
  28. package/src/cli/commands/platform/__tests__/status.test.ts +0 -90
  29. package/src/cli/commands/platform/index.help.ts +3 -2
  30. package/src/cli/commands/platform/index.ts +0 -9
  31. package/src/cli/commands/routes.help.ts +13 -7
  32. package/src/cli/commands/routes.ts +5 -3
  33. package/src/cli/commands/status.help.ts +24 -0
  34. package/src/cli/commands/status.ts +29 -0
  35. package/src/cli/lib/__tests__/global-json-option.test.ts +97 -0
  36. package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +1 -0
  37. package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +31 -0
  38. package/src/cli/lib/bundled-marketplace.json +187 -173
  39. package/src/cli/lib/global-json-option.ts +41 -0
  40. package/src/cli/lib/plugin-fingerprint.ts +18 -7
  41. package/src/cli/program.ts +5 -0
  42. package/src/config/bundled-skills/messaging/TOOLS.json +2 -2
  43. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +27 -19
  44. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +3 -3
  45. package/src/config/feature-flag-registry.json +1 -9
  46. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +3 -6
  47. package/src/daemon/__tests__/conversation-tool-setup-plugin-scope.test.ts +3 -6
  48. package/src/daemon/__tests__/conversation-tool-setup.test.ts +8 -5
  49. package/src/daemon/conversation-tool-setup.ts +5 -90
  50. package/src/daemon/conversation.ts +2 -2
  51. package/src/messaging/provider-types.ts +9 -0
  52. package/src/messaging/providers/outlook/adapter.ts +17 -2
  53. package/src/messaging/providers/outlook/client.ts +11 -2
  54. package/src/messaging/providers/outlook/types.ts +9 -0
  55. package/src/monitoring/__tests__/plugin-source-watch.test.ts +37 -0
  56. package/src/monitoring/plugin-source-watch.ts +53 -0
  57. package/src/oauth/seed-providers.ts +6 -0
  58. package/src/persistence/embeddings/__tests__/messages-lexical-index.test.ts +3 -0
  59. package/src/persistence/embeddings/messages-lexical-index.ts +11 -0
  60. package/src/plugin-api/conversation-turn.ts +234 -0
  61. package/src/plugin-api/index.ts +12 -0
  62. package/src/plugins/__tests__/source-fingerprint.test.ts +41 -0
  63. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +48 -0
  64. package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +46 -5
  65. package/src/plugins/defaults/memory/graph/tool-handlers.ts +2 -2
  66. package/src/plugins/defaults/memory/graph-topology/__tests__/build-memory-graph.test.ts +81 -2
  67. package/src/plugins/defaults/memory/graph-topology/build-memory-graph.ts +80 -33
  68. package/src/plugins/defaults/memory/memory-retrospective-job.ts +40 -1
  69. package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +28 -2
  70. package/src/plugins/mtime-cache.ts +26 -13
  71. package/src/plugins/plugin-tree-walk.ts +30 -0
  72. package/src/plugins/source-fingerprint.ts +9 -2
  73. package/src/plugins/surface-import.ts +28 -0
  74. package/src/providers/speech-to-text/__tests__/vellum-managed-realtime.test.ts +1 -1
  75. package/src/providers/speech-to-text/__tests__/vellum-speech-relay-connection.test.ts +4 -1
  76. package/src/providers/speech-to-text/vellum-speech-relay-connection.ts +5 -0
  77. package/src/runtime/routes/__tests__/gateway-status-routes.test.ts +84 -0
  78. package/src/runtime/routes/__tests__/plugins-routes.test.ts +47 -3
  79. package/src/runtime/routes/__tests__/user-routes-cli.test.ts +154 -0
  80. package/src/runtime/routes/app-management-routes.ts +96 -32
  81. package/src/runtime/routes/app-routes.ts +33 -17
  82. package/src/runtime/routes/gateway-status-routes.ts +69 -0
  83. package/src/runtime/routes/index.ts +2 -0
  84. package/src/runtime/routes/platform-routes.ts +4 -14
  85. package/src/runtime/routes/plugins-routes.ts +58 -1
  86. package/src/runtime/routes/settings-routes.ts +8 -6
  87. package/src/runtime/routes/user-route-dispatcher.ts +6 -86
  88. package/src/runtime/routes/user-route-resolution.ts +141 -0
  89. package/src/runtime/routes/user-routes-cli.ts +56 -35
  90. package/src/telemetry/AGENTS.md +15 -0
  91. package/src/telemetry/__tests__/telemetry-event-fixtures.ts +258 -0
  92. package/src/telemetry/telemetry-wire-source.json +1 -1
  93. package/src/telemetry/telemetry-wire-validation.test.ts +157 -0
  94. package/src/telemetry/telemetry-wire-validation.ts +125 -0
  95. package/src/telemetry/telemetry-wire.generated.ts +60 -9
  96. package/src/telemetry/types.test.ts +38 -0
  97. package/src/telemetry/types.ts +183 -91
  98. package/src/telemetry/usage-telemetry-reporter.ts +5 -0
  99. package/src/telemetry/watchdog-direct-emit.ts +6 -0
  100. package/src/tools/skills/scaffold-managed.ts +43 -11
package/AGENTS.md CHANGED
@@ -73,6 +73,10 @@ Some routes are IPC-only (defined in `src/ipc/routes/`, not in the shared array)
73
73
 
74
74
  The module-level dependency-injection pattern (`registerFooDeps()`) used by some IPC routes is a known antipattern. New IPC-only routes should avoid it.
75
75
 
76
+ ## Telemetry wire contract
77
+
78
+ Telemetry event types are defined by a platform-generated wire contract (`src/telemetry/telemetry-wire.generated.ts`) that `src/telemetry/types.ts` layers over, with pre-flush validation against it. Adding a new event type starts platform-side, not here. The mechanics, the drift guards, and the cross-repo ordering are documented next to the code they govern: see [`src/telemetry/AGENTS.md`](src/telemetry/AGENTS.md).
79
+
76
80
  ## Code comments
77
81
 
78
82
  When writing or updating comments, **do not reference code that has been removed.** Comments should describe the current state of the codebase, not narrate its history. Avoid phrases like "no longer does X", "previously used Y", or "was removed in PR Z" — future readers should not need to understand past implementations to understand the current code.
package/knip.json CHANGED
@@ -9,10 +9,7 @@
9
9
  "src/config/bundled-skills/**/tools/**/*.ts!"
10
10
  ],
11
11
  "project": ["src/**/*.ts!", "src/**/*.tsx!", "scripts/**/*.ts"],
12
- "ignore": [
13
- "src/config/preloaded-apps/**",
14
- "src/telemetry/telemetry-wire.generated.ts"
15
- ],
12
+ "ignore": ["src/config/preloaded-apps/**"],
16
13
  "ignoreDependencies": [
17
14
  "@microsoft/api-extractor",
18
15
  "@vellumai/ces-client",
package/openapi.yaml CHANGED
@@ -606,6 +606,8 @@ paths:
606
606
  type: string
607
607
  contentId:
608
608
  type: string
609
+ origin:
610
+ type: string
609
611
  required:
610
612
  - id
611
613
  - name
@@ -613,6 +615,7 @@ paths:
613
615
  - updatedAt
614
616
  - version
615
617
  - contentId
618
+ - origin
616
619
  additionalProperties: false
617
620
  required:
618
621
  - apps
@@ -11554,6 +11557,27 @@ paths:
11554
11557
  - lines
11555
11558
  - truncated
11556
11559
  additionalProperties: false
11560
+ /v1/gateway/status:
11561
+ get:
11562
+ operationId: gateway_status_get
11563
+ summary: Get gateway status
11564
+ description:
11565
+ Reports the gateway's public tunnel status. `tunnel` holds the active public URL when a tunnel is connected
11566
+ and is omitted otherwise. Errors with 503 when the gateway is not running. The tunnel only matters for routing
11567
+ inbound Twilio webhooks and live voice/audio WebSockets.
11568
+ tags:
11569
+ - gateway
11570
+ responses:
11571
+ "200":
11572
+ description: Successful response
11573
+ content:
11574
+ application/json:
11575
+ schema:
11576
+ type: object
11577
+ properties:
11578
+ tunnel:
11579
+ type: string
11580
+ additionalProperties: false
11557
11581
  /v1/groups:
11558
11582
  get:
11559
11583
  operationId: groups_get
@@ -21925,7 +21949,9 @@ paths:
21925
21949
  get:
21926
21950
  operationId: platform_status_get
21927
21951
  summary: Get platform deployment context and connection status
21928
- description: Aggregates platform context, credentials, assistant ID, webhook secret, and Velay tunnel status.
21952
+ description:
21953
+ Aggregates platform context, credentials, assistant ID, and webhook secret. Velay tunnel status is reported
21954
+ separately by gateway_status.
21929
21955
  tags:
21930
21956
  - platform
21931
21957
  responses:
@@ -21960,21 +21986,6 @@ paths:
21960
21986
  anyOf:
21961
21987
  - type: string
21962
21988
  - type: "null"
21963
- velayTunnel:
21964
- anyOf:
21965
- - type: object
21966
- properties:
21967
- connected:
21968
- type: boolean
21969
- publicUrl:
21970
- anyOf:
21971
- - type: string
21972
- - type: "null"
21973
- required:
21974
- - connected
21975
- - publicUrl
21976
- additionalProperties: false
21977
- - type: "null"
21978
21989
  required:
21979
21990
  - isPlatform
21980
21991
  - baseUrl
@@ -21985,7 +21996,6 @@ paths:
21985
21996
  - available
21986
21997
  - organizationId
21987
21998
  - userId
21988
- - velayTunnel
21989
21999
  additionalProperties: false
21990
22000
  /v1/playground/seed-conversation:
21991
22001
  post:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.9-staging.1",
3
+ "version": "0.10.9",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -13,10 +13,8 @@
13
13
 
14
14
  import { afterAll, describe, expect, test } from "bun:test";
15
15
 
16
- import {
17
- isToolActiveForContext,
18
- type SkillProjectionContext,
19
- } from "../daemon/conversation-tool-setup.js";
16
+ import type { Conversation } from "../daemon/conversation.js";
17
+ import { isToolActiveForContext } from "../daemon/conversation-tool-setup.js";
20
18
  import {
21
19
  __resetRegistryForTesting,
22
20
  getAllToolDefinitions,
@@ -33,13 +31,13 @@ describe("always-loaded tool count", () => {
33
31
  const allDefs = getAllToolDefinitions();
34
32
 
35
33
  // Minimal context: no client, no capabilities
36
- const minimalContext: SkillProjectionContext = {
34
+ const minimalContext = {
37
35
  skillProjectionState: new Map(),
38
36
  skillProjectionCache: {},
39
37
  toolsDisabledDepth: 0,
40
38
  hasNoClient: true,
41
39
  channelCapabilities: undefined,
42
- };
40
+ } as unknown as Conversation;
43
41
 
44
42
  const activeTools = allDefs.filter((def) =>
45
43
  isToolActiveForContext(def.name, minimalContext),
@@ -27,10 +27,19 @@ const apps = new Map<string, typeof legacyApp | typeof multifileApp>([
27
27
  ]);
28
28
 
29
29
  mock.module("../apps/app-store.js", () => ({
30
- getApp: (id: string) => apps.get(id) ?? null,
31
- getAppsDir: () => "/fake/apps",
32
- getAppDirPath: (appId: string) => `/fake/apps/${appId}`,
33
- isMultifileApp: (app: Record<string, unknown>) => app.formatVersion === 2,
30
+ resolveAppSource: (id: string) => {
31
+ const app = apps.get(id);
32
+ if (!app) return null;
33
+ return {
34
+ id,
35
+ name: app.name,
36
+ dirName: id,
37
+ sourceDir: `/fake/apps/${id}`,
38
+ origin: { kind: "workspace" as const },
39
+ formatVersion: (app as { formatVersion?: number }).formatVersion ?? 1,
40
+ };
41
+ },
42
+ readAppFileBytesFromDir: () => Buffer.from(""),
34
43
  }));
35
44
 
36
45
  // Mock shared-app-links-store (imported by app-routes but unused here)
@@ -28,10 +28,8 @@ mock.module("../daemon/conversation-skill-tools.js", () => ({
28
28
  // Import after mocks
29
29
  // ---------------------------------------------------------------------------
30
30
 
31
- import {
32
- createResolveToolsCallback,
33
- type SkillProjectionContext,
34
- } from "../daemon/conversation-tool-setup.js";
31
+ import type { Conversation } from "../daemon/conversation.js";
32
+ import { createResolveToolsCallback } from "../daemon/conversation-tool-setup.js";
35
33
 
36
34
  // ---------------------------------------------------------------------------
37
35
  // Helpers
@@ -41,15 +39,13 @@ function makeToolDef(name: string): ToolDefinition {
41
39
  return { name, description: `${name} tool`, input_schema: {} };
42
40
  }
43
41
 
44
- function makeCtx(
45
- overrides: Partial<SkillProjectionContext> = {},
46
- ): SkillProjectionContext {
42
+ function makeCtx(overrides: Partial<Conversation> = {}): Conversation {
47
43
  return {
48
44
  skillProjectionState: new Map(),
49
45
  skillProjectionCache: {} as SkillProjectionCache,
50
46
  toolsDisabledDepth: 0,
51
47
  ...overrides,
52
- };
48
+ } as unknown as Conversation;
53
49
  }
54
50
 
55
51
  const EMPTY_HISTORY: Message[] = [];
@@ -1,7 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
+ import type { Conversation } from "../daemon/conversation.js";
3
4
  import type { SkillProjectionCache } from "../daemon/conversation-skill-tools.js";
4
- import type { SkillProjectionContext } from "../daemon/conversation-tool-setup.js";
5
5
  import type { Message, ToolDefinition } from "../providers/types.js";
6
6
  import type { DiskUsageInfo } from "../util/disk-usage.js";
7
7
  import * as realDiskUsage from "../util/disk-usage.js";
@@ -74,14 +74,14 @@ function makeToolDef(name: string): ToolDefinition {
74
74
  }
75
75
 
76
76
  function makeProjectionCtx(
77
- overrides: Partial<SkillProjectionContext> = {},
78
- ): SkillProjectionContext {
77
+ overrides: Partial<Conversation> = {},
78
+ ): Conversation {
79
79
  return {
80
80
  skillProjectionState: new Map(),
81
81
  skillProjectionCache: {} as SkillProjectionCache,
82
82
  toolsDisabledDepth: 0,
83
83
  ...overrides,
84
- };
84
+ } as unknown as Conversation;
85
85
  }
86
86
 
87
87
  function setDiskUsage(usedMb: number, totalMb = 100): void {
@@ -9,9 +9,9 @@ import { tmpdir } from "node:os";
9
9
  import { join } from "node:path";
10
10
  import { afterEach, describe, expect, test } from "bun:test";
11
11
 
12
+ import type { Conversation } from "../daemon/conversation.js";
12
13
  import {
13
14
  isToolActiveForContext,
14
- type SkillProjectionContext,
15
15
  SUBAGENT_ONLY_TOOL_NAMES,
16
16
  } from "../daemon/conversation-tool-setup.js";
17
17
  import { fileListTool } from "../tools/filesystem/list.js";
@@ -60,7 +60,9 @@ describe("FileSystemOps.listDirSafe", () => {
60
60
  const ops = new FileSystemOps(sandboxPolicyFor(dir));
61
61
  const result = ops.listDirSafe({ path: dir });
62
62
  expect(result.ok).toBe(true);
63
- if (!result.ok) return;
63
+ if (!result.ok) {
64
+ return;
65
+ }
64
66
 
65
67
  const lines = result.value.listing.split("\n");
66
68
  // Directories first with trailing /
@@ -81,7 +83,9 @@ describe("FileSystemOps.listDirSafe", () => {
81
83
  const ops = new FileSystemOps(sandboxPolicyFor(dir));
82
84
  const result = ops.listDirSafe({ path: dir, glob: "*.md" });
83
85
  expect(result.ok).toBe(true);
84
- if (!result.ok) return;
86
+ if (!result.ok) {
87
+ return;
88
+ }
85
89
 
86
90
  const lines = result.value.listing.split("\n");
87
91
  expect(lines.length).toBe(2);
@@ -96,7 +100,9 @@ describe("FileSystemOps.listDirSafe", () => {
96
100
  const ops = new FileSystemOps(sandboxPolicyFor(dir));
97
101
  const result = ops.listDirSafe({ path: join(dir, "regular-file.txt") });
98
102
  expect(result.ok).toBe(false);
99
- if (result.ok) return;
103
+ if (result.ok) {
104
+ return;
105
+ }
100
106
  expect(result.error.code).toBe("NOT_A_DIRECTORY");
101
107
  });
102
108
 
@@ -106,7 +112,9 @@ describe("FileSystemOps.listDirSafe", () => {
106
112
  const ops = new FileSystemOps(sandboxPolicyFor(dir));
107
113
  const result = ops.listDirSafe({ path: join(dir, "nonexistent") });
108
114
  expect(result.ok).toBe(false);
109
- if (result.ok) return;
115
+ if (result.ok) {
116
+ return;
117
+ }
110
118
  expect(result.error.code).toBe("NOT_FOUND");
111
119
  });
112
120
 
@@ -116,7 +124,9 @@ describe("FileSystemOps.listDirSafe", () => {
116
124
  const ops = new FileSystemOps(sandboxPolicyFor(dir));
117
125
  const result = ops.listDirSafe({ path: "../../../etc" });
118
126
  expect(result.ok).toBe(false);
119
- if (result.ok) return;
127
+ if (result.ok) {
128
+ return;
129
+ }
120
130
  expect(result.error.code).toBe("PATH_OUT_OF_BOUNDS");
121
131
  });
122
132
  });
@@ -186,31 +196,31 @@ describe("file_list subagent visibility", () => {
186
196
  });
187
197
 
188
198
  test("isToolActiveForContext hides file_list when isSubagent is false", () => {
189
- const ctx: SkillProjectionContext = {
199
+ const ctx = {
190
200
  skillProjectionState: new Map(),
191
201
  skillProjectionCache: {},
192
202
  toolsDisabledDepth: 0,
193
- };
203
+ } as unknown as Conversation;
194
204
  expect(isToolActiveForContext("file_list", ctx)).toBe(false);
195
205
  });
196
206
 
197
207
  test("isToolActiveForContext hides file_list when isSubagent is undefined", () => {
198
- const ctx: SkillProjectionContext = {
208
+ const ctx = {
199
209
  skillProjectionState: new Map(),
200
210
  skillProjectionCache: {},
201
211
  toolsDisabledDepth: 0,
202
212
  isSubagent: undefined,
203
- };
213
+ } as unknown as Conversation;
204
214
  expect(isToolActiveForContext("file_list", ctx)).toBe(false);
205
215
  });
206
216
 
207
217
  test("isToolActiveForContext shows file_list when isSubagent is true", () => {
208
- const ctx: SkillProjectionContext = {
218
+ const ctx = {
209
219
  skillProjectionState: new Map(),
210
220
  skillProjectionCache: {},
211
221
  toolsDisabledDepth: 0,
212
222
  isSubagent: true,
213
- };
223
+ } as unknown as Conversation;
214
224
  expect(isToolActiveForContext("file_list", ctx)).toBe(true);
215
225
  });
216
226
  });
@@ -0,0 +1,204 @@
1
+ import { mkdirSync, rmSync, symlinkSync, 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 {
7
+ createApp,
8
+ listAllApps,
9
+ listPluginApps,
10
+ resolveAppSource,
11
+ } from "../apps/app-store.js";
12
+ import { getWorkspacePluginsDir } from "../util/platform.js";
13
+
14
+ let workspaceDir: string;
15
+
16
+ function freshWorkspace(): string {
17
+ return join(
18
+ tmpdir(),
19
+ `vellum-list-all-apps-${Date.now()}-${Math.random().toString(36).slice(2)}`,
20
+ );
21
+ }
22
+
23
+ /**
24
+ * Materialize an installed plugin under `<workspace>/plugins/<name>/` with a
25
+ * package.json manifest. Returns the plugin directory path.
26
+ */
27
+ function installPlugin(
28
+ name: string,
29
+ opts: { disabled?: boolean } = {},
30
+ ): string {
31
+ const pluginDir = join(getWorkspacePluginsDir(), name);
32
+ mkdirSync(pluginDir, { recursive: true });
33
+ writeFileSync(
34
+ join(pluginDir, "package.json"),
35
+ JSON.stringify({ name, version: "1.0.0" }),
36
+ );
37
+ if (opts.disabled) {
38
+ writeFileSync(join(pluginDir, ".disabled"), "");
39
+ }
40
+ return pluginDir;
41
+ }
42
+
43
+ /**
44
+ * Bundle an app inside a plugin as a directory under `<pluginDir>/apps/<app>/`.
45
+ */
46
+ function bundleApp(pluginDir: string, app: string): void {
47
+ const appDir = join(pluginDir, "apps", app);
48
+ mkdirSync(appDir, { recursive: true });
49
+ writeFileSync(join(appDir, "index.html"), "<h1>Plugin app</h1>");
50
+ }
51
+
52
+ beforeEach(() => {
53
+ workspaceDir = freshWorkspace();
54
+ process.env.VELLUM_WORKSPACE_DIR = workspaceDir;
55
+ });
56
+
57
+ afterEach(() => {
58
+ rmSync(workspaceDir, { recursive: true, force: true });
59
+ });
60
+
61
+ describe("listAllApps", () => {
62
+ test("returns nothing when no apps exist", () => {
63
+ expect(listAllApps()).toEqual([]);
64
+ expect(listPluginApps()).toEqual([]);
65
+ });
66
+
67
+ test("tags workspace apps with the workspace origin", () => {
68
+ const created = createApp({
69
+ name: "Budget",
70
+ schemaJson: "{}",
71
+ htmlDefinition: "<h1>Budget</h1>",
72
+ });
73
+
74
+ const all = listAllApps();
75
+ expect(all).toHaveLength(1);
76
+ expect(all[0].origin).toEqual({ kind: "workspace" });
77
+ expect(all[0].id).toBe(created.id);
78
+ expect(all[0].name).toBe("Budget");
79
+ // Workspace source resolves through the dirName-based resolver.
80
+ expect(all[0].sourcePath).toContain(join("data", "apps"));
81
+ expect(all[0].sourcePath).toContain(created.dirName ?? created.id);
82
+ });
83
+
84
+ test("discovers plugin apps by directory, tagged plugin:<name>", () => {
85
+ createApp({
86
+ name: "Budget",
87
+ schemaJson: "{}",
88
+ htmlDefinition: "<h1>Budget</h1>",
89
+ });
90
+ const pluginDir = installPlugin("acme");
91
+ bundleApp(pluginDir, "acme-dashboard");
92
+
93
+ const plugin = listPluginApps();
94
+ expect(plugin).toHaveLength(1);
95
+ expect(plugin[0].origin).toEqual({ kind: "plugin", pluginName: "acme" });
96
+ expect(plugin[0].id).toBe("plugins~acme~acme-dashboard");
97
+ expect(plugin[0].name).toBe("acme-dashboard");
98
+ expect(plugin[0].sourcePath).toBe(
99
+ join(pluginDir, "apps", "acme-dashboard"),
100
+ );
101
+
102
+ // Workspace apps come first, then plugin apps.
103
+ const all = listAllApps();
104
+ expect(all.map((a) => a.origin.kind)).toEqual(["workspace", "plugin"]);
105
+ });
106
+
107
+ test("ignores directories under plugins/ without a package.json manifest", () => {
108
+ // A stray directory that is not an installed plugin, even with an apps/ dir.
109
+ const strayDir = join(getWorkspacePluginsDir(), "not-a-plugin");
110
+ mkdirSync(join(strayDir, "apps", "ghost"), { recursive: true });
111
+
112
+ expect(listPluginApps()).toEqual([]);
113
+ });
114
+
115
+ test("includes apps from a symlinked plugin directory", () => {
116
+ // Real plugin lives outside plugins/; plugins/<name> is a symlink to it.
117
+ const realPluginDir = join(workspaceDir, "external-acme");
118
+ mkdirSync(realPluginDir, { recursive: true });
119
+ writeFileSync(
120
+ join(realPluginDir, "package.json"),
121
+ JSON.stringify({ name: "acme", version: "1.0.0" }),
122
+ );
123
+ bundleApp(realPluginDir, "acme-dashboard");
124
+
125
+ mkdirSync(getWorkspacePluginsDir(), { recursive: true });
126
+ symlinkSync(realPluginDir, join(getWorkspacePluginsDir(), "acme"));
127
+
128
+ const plugin = listPluginApps();
129
+ expect(plugin).toHaveLength(1);
130
+ expect(plugin[0].name).toBe("acme-dashboard");
131
+ expect(plugin[0].origin).toEqual({ kind: "plugin", pluginName: "acme" });
132
+ });
133
+
134
+ test("excludes apps from disabled plugins", () => {
135
+ const pluginDir = installPlugin("acme", { disabled: true });
136
+ bundleApp(pluginDir, "acme-dashboard");
137
+
138
+ expect(listPluginApps()).toEqual([]);
139
+ expect(listAllApps()).toEqual([]);
140
+ });
141
+ });
142
+
143
+ describe("resolveAppSource", () => {
144
+ test("resolves a workspace app by its UUID", () => {
145
+ const created = createApp({
146
+ name: "Budget",
147
+ schemaJson: "{}",
148
+ htmlDefinition: "<h1>Budget</h1>",
149
+ });
150
+
151
+ const source = resolveAppSource(created.id);
152
+ expect(source).not.toBeNull();
153
+ expect(source!.origin).toEqual({ kind: "workspace" });
154
+ expect(source!.name).toBe("Budget");
155
+ expect(source!.sourceDir).toContain(join("data", "apps"));
156
+ // Single-file app (index.html at root).
157
+ expect(source!.formatVersion).toBe(1);
158
+ });
159
+
160
+ test("resolves a plugin app by its plugins~<name>~<app> id", () => {
161
+ const pluginDir = installPlugin("acme");
162
+ bundleApp(pluginDir, "acme-dashboard");
163
+
164
+ const source = resolveAppSource("plugins~acme~acme-dashboard");
165
+ expect(source).not.toBeNull();
166
+ expect(source!.origin).toEqual({ kind: "plugin", pluginName: "acme" });
167
+ expect(source!.name).toBe("acme-dashboard");
168
+ expect(source!.sourceDir).toBe(join(pluginDir, "apps", "acme-dashboard"));
169
+ // bundleApp writes index.html at the root → single-file.
170
+ expect(source!.formatVersion).toBe(1);
171
+ });
172
+
173
+ test("returns null for an unknown workspace id", () => {
174
+ expect(resolveAppSource("11111111-1111-1111-1111-111111111111")).toBeNull();
175
+ });
176
+
177
+ test("returns null for a plugin app whose plugin is disabled", () => {
178
+ const pluginDir = installPlugin("acme", { disabled: true });
179
+ bundleApp(pluginDir, "acme-dashboard");
180
+
181
+ expect(resolveAppSource("plugins~acme~acme-dashboard")).toBeNull();
182
+ });
183
+
184
+ test("returns null for a plugin dir without a package.json manifest", () => {
185
+ // Stray directory shaped like a plugin app but not an installed plugin.
186
+ const strayApp = join(
187
+ getWorkspacePluginsDir(),
188
+ "not-a-plugin",
189
+ "apps",
190
+ "x",
191
+ );
192
+ mkdirSync(strayApp, { recursive: true });
193
+
194
+ expect(resolveAppSource("plugins~not-a-plugin~x")).toBeNull();
195
+ });
196
+
197
+ test("returns null for traversal and malformed plugin ids", () => {
198
+ installPlugin("acme");
199
+ expect(resolveAppSource("plugins~..~secrets")).toBeNull(); // traversal name
200
+ expect(resolveAppSource("plugins~acme~..")).toBeNull(); // traversal app
201
+ expect(resolveAppSource("plugins~acme")).toBeNull(); // missing app segment
202
+ expect(resolveAppSource("plugins~acme~sub/dir")).toBeNull(); // slash in app
203
+ });
204
+ });
@@ -1,3 +1,6 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
1
4
  import { beforeEach, describe, expect, mock, test } from "bun:test";
2
5
 
3
6
  import type { MessagingProvider } from "../messaging/provider.js";
@@ -129,6 +132,7 @@ describe("messaging-send tool", () => {
129
132
  subject: undefined,
130
133
  inReplyTo: undefined,
131
134
  threadId: undefined,
135
+ attachments: undefined,
132
136
  assistantId: "ast-alpha",
133
137
  },
134
138
  );
@@ -159,11 +163,74 @@ describe("messaging-send tool", () => {
159
163
  subject: undefined,
160
164
  inReplyTo: undefined,
161
165
  threadId: "thread-abc",
166
+ attachments: undefined,
162
167
  assistantId: "ast-alpha",
163
168
  },
164
169
  );
165
170
  });
166
171
 
172
+ test("rejects attachments on platforms that can't carry them", async () => {
173
+ const result = await run(
174
+ {
175
+ platform: "phone",
176
+ conversation_id: "+12025550142",
177
+ text: "with a file",
178
+ attachment_paths: ["/tmp/does-not-matter.pdf"],
179
+ },
180
+ {
181
+ workingDir: "/tmp",
182
+ conversationId: "conv-1",
183
+ assistantId: "ast-alpha",
184
+ trustClass: "guardian" as const,
185
+ },
186
+ );
187
+
188
+ expect(result.isError).toBe(true);
189
+ expect(result.content).toContain("Gmail and Outlook");
190
+ expect(sendMessageMock).not.toHaveBeenCalled();
191
+ });
192
+
193
+ test("reads and forwards attachments to attachment-capable non-Gmail providers", async () => {
194
+ const dir = mkdtempSync(join(tmpdir(), "msg-send-att-"));
195
+ const filePath = join(dir, "report.pdf");
196
+ writeFileSync(filePath, "pdf-bytes");
197
+ provider.id = "outlook";
198
+
199
+ try {
200
+ const result = await run(
201
+ {
202
+ platform: "outlook",
203
+ conversation_id: "user@example.com",
204
+ text: "see attached",
205
+ subject: "Docs",
206
+ attachment_paths: [filePath],
207
+ },
208
+ {
209
+ workingDir: "/tmp",
210
+ conversationId: "conv-1",
211
+ assistantId: "ast-alpha",
212
+ trustClass: "guardian" as const,
213
+ },
214
+ );
215
+
216
+ expect(result.isError).toBe(false);
217
+ const options = sendMessageMock.mock.calls[0][3] as {
218
+ attachments?: Array<{
219
+ filename: string;
220
+ mimeType: string;
221
+ data: Buffer;
222
+ }>;
223
+ };
224
+ expect(options.attachments).toHaveLength(1);
225
+ expect(options.attachments?.[0]?.filename).toBe("report.pdf");
226
+ expect(options.attachments?.[0]?.mimeType).toBe("application/pdf");
227
+ expect(options.attachments?.[0]?.data.toString()).toBe("pdf-bytes");
228
+ } finally {
229
+ provider.id = "phone";
230
+ rmSync(dir, { recursive: true, force: true });
231
+ }
232
+ });
233
+
167
234
  test("cross-posts outbound message to bound conversation", async () => {
168
235
  getBindingByChannelChatMock.mockImplementation(() => ({
169
236
  conversationId: "bound-conv-99",
@@ -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" });