@vellumai/assistant 0.11.4-staging.2 → 0.11.4-staging.3

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 (189) hide show
  1. package/AGENTS.md +8 -2
  2. package/ARCHITECTURE.md +2 -0
  3. package/docs/architecture/memory.md +15 -0
  4. package/docs/browser-use-architecture-phase2.md +128 -56
  5. package/docs/flux-turn-detection-spike.md +11 -6
  6. package/knip.json +3 -0
  7. package/openapi.yaml +136 -76
  8. package/package.json +1 -1
  9. package/scripts/write-plugin-api-shim.ts +10 -0
  10. package/src/__tests__/app-control-flow.test.ts +1 -0
  11. package/src/__tests__/approval-routes-http.test.ts +2 -2
  12. package/src/__tests__/assistant-feature-flag-guard.test.ts +25 -3
  13. package/src/__tests__/channel-setup-panel-ack.test.ts +1 -1
  14. package/src/__tests__/compaction-events.test.ts +8 -10
  15. package/src/__tests__/conversation-agent-loop.test.ts +4 -1
  16. package/src/__tests__/conversation-confirmation-signals.test.ts +112 -0
  17. package/src/__tests__/conversation-notifiers-provenance.test.ts +1 -1
  18. package/src/__tests__/conversation-queue.test.ts +39 -62
  19. package/src/__tests__/conversation-routes-disk-view.test.ts +1 -1
  20. package/src/__tests__/conversation-routes-enabled-plugins.test.ts +1 -1
  21. package/src/__tests__/conversation-routes-guardian-reply.test.ts +9 -9
  22. package/src/__tests__/conversation-routes-hidden-queue.test.ts +1 -1
  23. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -1
  24. package/src/__tests__/conversation-slash-queue.test.ts +3 -0
  25. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +1 -0
  26. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +1 -0
  27. package/src/__tests__/conversation-surfaces-app-control.test.ts +1 -0
  28. package/src/__tests__/conversation-surfaces-app-open.test.ts +1 -1
  29. package/src/__tests__/conversation-surfaces-data-persist.test.ts +1 -1
  30. package/src/__tests__/conversation-surfaces-history-restored-completion.test.ts +21 -14
  31. package/src/__tests__/conversation-surfaces-queued-emit.test.ts +1 -0
  32. package/src/__tests__/conversation-surfaces-standalone-payloads.test.ts +1 -0
  33. package/src/__tests__/conversation-surfaces-standalone.test.ts +1 -0
  34. package/src/__tests__/conversation-surfaces-state-update.test.ts +1 -1
  35. package/src/__tests__/conversation-surfaces-table-action.test.ts +1 -1
  36. package/src/__tests__/conversation-surfaces-task-progress.test.ts +1 -1
  37. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +1 -1
  38. package/src/__tests__/conversation-tool-setup-attribution.test.ts +1 -1
  39. package/src/__tests__/cu-unified-flow.test.ts +1 -0
  40. package/src/__tests__/document-sync-tags.test.ts +0 -75
  41. package/src/__tests__/gateway-only-guard.test.ts +2 -5
  42. package/src/__tests__/http-user-message-parity.test.ts +1 -1
  43. package/src/__tests__/init-feature-flag-overrides.test.ts +49 -0
  44. package/src/__tests__/managed-skill-lifecycle.test.ts +7 -0
  45. package/src/__tests__/media-generate-image.test.ts +131 -21
  46. package/src/__tests__/memory-retrieval-hook.test.ts +94 -2
  47. package/src/__tests__/plugin-api-webhook-url.test.ts +10 -7
  48. package/src/__tests__/plugin-import-boundary-reverse-guard.test.ts +7 -3
  49. package/src/__tests__/proxy-approval-callback.test.ts +1 -0
  50. package/src/__tests__/qdrant-manager.test.ts +14 -1
  51. package/src/__tests__/run-due-schedules.test.ts +21 -0
  52. package/src/__tests__/scaffold-managed-skill-tool.test.ts +187 -18
  53. package/src/__tests__/schedule-routes.test.ts +23 -0
  54. package/src/__tests__/schedule-store.test.ts +17 -0
  55. package/src/__tests__/secret-ingress-http.test.ts +1 -1
  56. package/src/__tests__/send-endpoint-busy.test.ts +3 -3
  57. package/src/__tests__/starter-task-flow.test.ts +5 -4
  58. package/src/__tests__/subagent-fork-prompt-role.test.ts +1 -1
  59. package/src/__tests__/subagent-spawn-and-await.test.ts +4 -7
  60. package/src/__tests__/subagent-tool-gate-mode.test.ts +1 -1
  61. package/src/__tests__/subagent-tools.test.ts +81 -101
  62. package/src/__tests__/surface-completion-in-flight-snapshot.test.ts +1 -0
  63. package/src/__tests__/ui-choice-copy-surfaces.test.ts +1 -1
  64. package/src/__tests__/ui-visual-surface.test.ts +1 -1
  65. package/src/__tests__/ui-voice-picker-surface.test.ts +1 -1
  66. package/src/__tests__/ui-work-result-surface.test.ts +1 -1
  67. package/src/__tests__/voice-scoped-grant-consumer.test.ts +5 -3
  68. package/src/__tests__/voice-session-bridge.test.ts +85 -29
  69. package/src/acp/session-manager.ts +8 -1
  70. package/src/api/surfaces.ts +5 -0
  71. package/src/calls/__tests__/voice-session-bridge.test.ts +21 -10
  72. package/src/calls/__tests__/voice-triage-escalate.test.ts +8 -0
  73. package/src/calls/voice-session-bridge.ts +44 -25
  74. package/src/calls/voice-triage-escalate.ts +1 -0
  75. package/src/cli/bundled-modules.ts +29 -0
  76. package/src/cli/commands/db/repair.ts +4 -8
  77. package/src/cli/commands/domain.ts +6 -3
  78. package/src/cli/commands/email.ts +6 -3
  79. package/src/cli/commands/keys.ts +8 -3
  80. package/src/cli/commands/plugins.ts +85 -36
  81. package/src/cli/commands/schedules.ts +35 -1
  82. package/src/cli/lib/bundled-marketplace.json +1 -1
  83. package/src/config/__tests__/balanced-model-experiment.test.ts +278 -0
  84. package/src/config/assistant-feature-flags.ts +36 -15
  85. package/src/config/balanced-model-experiment.ts +35 -0
  86. package/src/config/bundled-skills/image-studio/SKILL.md +5 -4
  87. package/src/config/bundled-skills/image-studio/TOOLS.json +1 -1
  88. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +101 -0
  89. package/src/config/bundled-skills/skill-management/TOOLS.json +9 -3
  90. package/src/config/bundled-skills/subagent/SKILL.md +17 -12
  91. package/src/config/bundled-skills/subagent/TOOLS.json +4 -4
  92. package/src/config/call-site-defaults.ts +7 -0
  93. package/src/config/default-profile-catalog.ts +96 -4
  94. package/src/config/feature-flag-registry.json +11 -11
  95. package/src/config/skills.ts +9 -2
  96. package/src/daemon/__tests__/conversation-surfaces-launch.test.ts +1 -1
  97. package/src/daemon/conversation-agent-loop.ts +14 -13
  98. package/src/daemon/conversation-notifiers.ts +11 -9
  99. package/src/daemon/conversation-process.ts +0 -27
  100. package/src/daemon/conversation-store.ts +4 -4
  101. package/src/daemon/conversation-surfaces.ts +27 -13
  102. package/src/daemon/conversation-tool-setup.ts +2 -4
  103. package/src/daemon/conversation.ts +81 -44
  104. package/src/daemon/doordash-steps.ts +2 -2
  105. package/src/daemon/lifecycle.ts +14 -1
  106. package/src/daemon/process-message.ts +0 -13
  107. package/src/daemon/windows-compiled-entry.ts +4 -0
  108. package/src/documents/document-store.ts +5 -235
  109. package/src/hooks/types.ts +5 -0
  110. package/src/ipc/gateway-flag-listener.ts +17 -3
  111. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +118 -0
  112. package/src/live-voice/live-voice-manager.ts +16 -3
  113. package/src/live-voice/live-voice-session.ts +63 -9
  114. package/src/live-voice/windows-compiled-live-voice.ts +4 -0
  115. package/src/monitoring/control.ts +1 -0
  116. package/src/monitoring/db-integrity-sample.ts +4 -5
  117. package/src/permissions/prompter.ts +1 -5
  118. package/src/persistence/conversation-queries.ts +66 -16
  119. package/src/persistence/embeddings/qdrant-manager.ts +84 -49
  120. package/src/persistence/migrations/360-add-document-workspace-path.ts +5 -14
  121. package/src/persistence/schema/documents.ts +4 -4
  122. package/src/plugin-api/constants.ts +8 -0
  123. package/src/plugin-api/index.ts +5 -1
  124. package/src/plugin-api/webhook-url.ts +13 -11
  125. package/src/plugins/defaults/main.ts +6 -7
  126. package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +77 -0
  127. package/src/plugins/defaults/memory/graph/conversation-graph-memory.ts +24 -6
  128. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +53 -5
  129. package/src/plugins/defaults/memory/memory-retrospective-job.ts +4 -4
  130. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +18 -0
  131. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +66 -1
  132. package/src/plugins/defaults/memory/v3/injector.ts +8 -0
  133. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +23 -9
  134. package/src/plugins/defaults/memory/worker-control.ts +1 -0
  135. package/src/plugins/defaults/worker-entrypoints.ts +3 -0
  136. package/src/plugins/mtime-cache.ts +17 -0
  137. package/src/prompts/templates/system-sections.ts +0 -7
  138. package/src/providers/__tests__/context-overflow-error.test.ts +24 -0
  139. package/src/providers/__tests__/retry-callsite.test.ts +20 -0
  140. package/src/providers/openai/chat-completions-provider.ts +11 -1
  141. package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +11 -6
  142. package/src/providers/speech-to-text/deepgram-flux-realtime.ts +9 -49
  143. package/src/routes/control.ts +1 -0
  144. package/src/routes/route-host-client.ts +1 -0
  145. package/src/runtime/AGENTS.md +16 -17
  146. package/src/runtime/agent-wake.ts +15 -12
  147. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +170 -1
  148. package/src/runtime/routes/__tests__/schedule-routes-disarm-reason.test.ts +215 -0
  149. package/src/runtime/routes/conversation-list-routes.ts +54 -22
  150. package/src/runtime/routes/conversation-management-routes.ts +2 -3
  151. package/src/runtime/routes/conversation-routes.ts +11 -13
  152. package/src/runtime/routes/documents-routes.ts +3 -222
  153. package/src/runtime/routes/playground/__tests__/inject-failures.test.ts +2 -0
  154. package/src/runtime/routes/playground/__tests__/reset-circuit.test.ts +3 -0
  155. package/src/runtime/routes/playground/inject-failures.ts +2 -2
  156. package/src/runtime/routes/playground/reset-circuit.ts +1 -1
  157. package/src/runtime/routes/schedule-routes.ts +94 -7
  158. package/src/runtime/routes/workspace-routes.ts +0 -9
  159. package/src/runtime/routes/workspace-utils.ts +3 -13
  160. package/src/runtime/services/conversation-serializer.ts +7 -2
  161. package/src/schedule/__tests__/plugin-schedule-declarations.test.ts +68 -5
  162. package/src/schedule/__tests__/plugin-schedule-reconciler.test.ts +81 -0
  163. package/src/schedule/plugin-schedule-availability.ts +58 -0
  164. package/src/schedule/plugin-schedule-declarations.ts +23 -27
  165. package/src/schedule/plugin-schedule-reconciler.ts +12 -3
  166. package/src/schedule/schedule-store.ts +5 -1
  167. package/src/schedule/scheduler.ts +9 -4
  168. package/src/schedule/worker-control.ts +1 -0
  169. package/src/subagent/__tests__/consult-prompt.test.ts +26 -15
  170. package/src/subagent/consult-context.ts +11 -11
  171. package/src/subagent/consult-prompt.ts +26 -35
  172. package/src/subagent/manager.ts +20 -37
  173. package/src/subagent/notify.ts +7 -1
  174. package/src/subagent/types.ts +8 -6
  175. package/src/tools/acp/spawn.ts +6 -4
  176. package/src/tools/skills/scaffold-managed.ts +25 -7
  177. package/src/tools/subagent/spawn.ts +28 -88
  178. package/src/tools/ui-surface/surface-shape-docs.ts +1 -1
  179. package/src/util/__tests__/worker-process-command.test.ts +37 -0
  180. package/src/util/logger.ts +16 -0
  181. package/src/util/worker-process.ts +37 -4
  182. package/src/windows-compiled-cli.ts +32 -0
  183. package/src/windows-compiled-entry.ts +4 -0
  184. package/src/windows-compiled-logger.ts +6 -0
  185. package/src/windows-compiled-worker-entry.ts +29 -0
  186. package/src/__tests__/document-workspace-file.test.ts +0 -467
  187. package/src/daemon/interactive-turn-sender.ts +0 -59
  188. package/src/subagent/__tests__/consult-transcript.test.ts +0 -184
  189. package/src/subagent/consult-transcript.ts +0 -90
@@ -11,6 +11,7 @@ import {
11
11
  clearFeatureFlagOverridesCache,
12
12
  initFeatureFlagOverrides,
13
13
  isAssistantFeatureFlagEnabled,
14
+ refreshOverridesFromGateway,
14
15
  } from "../config/assistant-feature-flags.js";
15
16
  import { mockGatewayIpc, resetMockGatewayIpc } from "./mock-gateway-ipc.js";
16
17
 
@@ -110,3 +111,51 @@ describe("initFeatureFlagOverrides", () => {
110
111
  expect(isAssistantFeatureFlagEnabled("second-call", config)).toBe(false);
111
112
  });
112
113
  });
114
+
115
+ describe("refreshOverridesFromGateway", () => {
116
+ it("replaces the cache with the new gateway values", async () => {
117
+ mockGatewayIpc({ "moving-flag": true });
118
+ await initFeatureFlagOverrides();
119
+
120
+ resetMockGatewayIpc();
121
+ mockGatewayIpc({ "moving-flag": false });
122
+
123
+ expect(await refreshOverridesFromGateway()).toBe(true);
124
+
125
+ const config = {} as any;
126
+ expect(isAssistantFeatureFlagEnabled("moving-flag", config)).toBe(false);
127
+ });
128
+
129
+ it("keeps the last known values when the refresh fails", async () => {
130
+ mockGatewayIpc({ "moving-flag": true });
131
+ await initFeatureFlagOverrides();
132
+
133
+ resetMockGatewayIpc();
134
+ mockGatewayIpc(null, { error: true });
135
+
136
+ expect(await refreshOverridesFromGateway()).toBe(false);
137
+
138
+ // A failed refresh must not drop the flag to its registry default: a
139
+ // value the assistant selects behavior from (an experiment arm, a kill
140
+ // switch) would otherwise flip on one timed-out IPC call.
141
+ const config = {} as any;
142
+ expect(isAssistantFeatureFlagEnabled("moving-flag", config)).toBe(true);
143
+ });
144
+
145
+ it("keeps the cache readable for the whole call", async () => {
146
+ mockGatewayIpc({ "moving-flag": true });
147
+ await initFeatureFlagOverrides();
148
+
149
+ resetMockGatewayIpc();
150
+ mockGatewayIpc({ "moving-flag": false });
151
+
152
+ const config = {} as any;
153
+ const inFlight = refreshOverridesFromGateway();
154
+ // A sync read landing mid-refresh sees the previous values, never an
155
+ // empty cache.
156
+ expect(isAssistantFeatureFlagEnabled("moving-flag", config)).toBe(true);
157
+
158
+ await inFlight;
159
+ expect(isAssistantFeatureFlagEnabled("moving-flag", config)).toBe(false);
160
+ });
161
+ });
@@ -47,6 +47,9 @@ import { skillLoadTool } from "../tools/skills/load.js";
47
47
  import { executeScaffoldManagedSkill } from "../tools/skills/scaffold-managed.js";
48
48
  import type { ToolContext } from "../tools/types.js";
49
49
 
50
+ /** scaffold_managed_skill requires activation hints; a fixed set for tests. */
51
+ const HINTS = ["user asks to run the lifecycle test procedure"];
52
+
50
53
  function makeContext(): ToolContext {
51
54
  return {
52
55
  workingDir: "/tmp",
@@ -126,6 +129,7 @@ Run the custom lifecycle verification procedure.
126
129
  name: "Lifecycle Test",
127
130
  description: "Integration test skill.",
128
131
  body_markdown: "Run the lifecycle test procedure.",
132
+ activation_hints: HINTS,
129
133
  emoji: "🧪",
130
134
  },
131
135
  makeContext(),
@@ -185,6 +189,7 @@ Run the custom lifecycle verification procedure.
185
189
  name: "V1",
186
190
  description: "Version 1.",
187
191
  body_markdown: "Original body.",
192
+ activation_hints: HINTS,
188
193
  },
189
194
  ctx,
190
195
  );
@@ -196,6 +201,7 @@ Run the custom lifecycle verification procedure.
196
201
  name: "V2",
197
202
  description: "Version 2.",
198
203
  body_markdown: "Updated body.",
204
+ activation_hints: HINTS,
199
205
  overwrite: true,
200
206
  },
201
207
  ctx,
@@ -241,6 +247,7 @@ Run the custom lifecycle verification procedure.
241
247
  description: "Created from scaffold.",
242
248
  body_markdown:
243
249
  "This skill was dynamically created.\n\nRun: `echo chain-test-ok`",
250
+ activation_hints: HINTS,
244
251
  },
245
252
  ctx,
246
253
  );
@@ -1,6 +1,14 @@
1
1
  import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
- import { readFileSync } from "fs";
3
+ import {
4
+ mkdtempSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ rmSync,
8
+ symlinkSync,
9
+ writeFileSync,
10
+ } from "fs";
11
+ import { tmpdir } from "os";
4
12
  import { dirname, join } from "path";
5
13
 
6
14
  import { __resetRegistryForTesting, getTool } from "../tools/registry.js";
@@ -97,8 +105,23 @@ import { run } from "../config/bundled-skills/image-studio/tools/media-generate-
97
105
  // Clean up after this file to prevent contamination of later test files.
98
106
  afterAll(() => {
99
107
  __resetRegistryForTesting();
108
+ for (const dir of tempWorkingDirs) {
109
+ rmSync(dir, { recursive: true, force: true });
110
+ }
100
111
  });
101
112
 
113
+ const tempWorkingDirs: string[] = [];
114
+
115
+ /** Build a ToolContext with a fresh temp working directory. */
116
+ function makeContext(): ToolContext {
117
+ const workingDir = mkdtempSync(join(tmpdir(), "media-gen-test-"));
118
+ tempWorkingDirs.push(workingDir);
119
+ return {
120
+ conversationId: "conv-123",
121
+ workingDir,
122
+ } as unknown as ToolContext;
123
+ }
124
+
102
125
  const CONFIG_DIR = join(
103
126
  dirname(import.meta.dirname!),
104
127
  "config",
@@ -128,12 +151,10 @@ beforeEach(() => {
128
151
  platformBaseUrl: "",
129
152
  assistantApiKey: "",
130
153
  };
154
+ fakeContext = makeContext();
131
155
  });
132
156
 
133
- const fakeContext = {
134
- conversationId: "conv-123",
135
- workingDir: "/tmp",
136
- } as unknown as ToolContext;
157
+ let fakeContext: ToolContext;
137
158
 
138
159
  describe("image-studio skill script wrapper", () => {
139
160
  test("exports a run function without registering media_generate_image in the tool registry", async () => {
@@ -431,28 +452,117 @@ describe("image-studio skill script wrapper", () => {
431
452
  });
432
453
 
433
454
  test("reads source images from file paths on disk", async () => {
434
- // Write a temp image file inside the workspace (fakeContext.workingDir = /tmp)
435
- const tmpPath = join("/tmp", "test-source-image.png");
455
+ // Write a temp image file inside the workspace
456
+ const tmpPath = join(fakeContext.workingDir, "test-source-image.png");
436
457
  const pngBytes = Buffer.from(
437
458
  "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
438
459
  "base64",
439
460
  );
440
461
  await Bun.write(tmpPath, pngBytes);
441
462
 
442
- try {
443
- const result = await run(
444
- { prompt: "edit this", mode: "edit", source_paths: [tmpPath] },
445
- fakeContext,
446
- );
447
-
448
- expect(result.isError).toBe(false);
449
- expect(result.content).toContain("Generated 1 image");
450
- } finally {
451
- const { unlink } = await import("fs/promises");
452
- if (await Bun.file(tmpPath).exists()) {
453
- await unlink(tmpPath);
454
- }
455
- }
463
+ const result = await run(
464
+ { prompt: "edit this", mode: "edit", source_paths: [tmpPath] },
465
+ fakeContext,
466
+ );
467
+
468
+ expect(result.isError).toBe(false);
469
+ expect(result.content).toContain("Generated 1 image");
470
+ });
471
+
472
+ test("saves the image into media/generated and returns the embed instruction", async () => {
473
+ const result = await run({ prompt: "a sunset" }, fakeContext);
474
+
475
+ expect(result.isError).toBe(false);
476
+ expect(result.content).toContain("Saved to media/generated/a-sunset.png");
477
+ expect(result.content).toContain(
478
+ "![description](vellum://workspace/media/generated/a-sunset.png)",
479
+ );
480
+ const saved = Bun.file(
481
+ join(fakeContext.workingDir, "media/generated/a-sunset.png"),
482
+ );
483
+ expect(await saved.exists()).toBe(true);
484
+ const bytes = Buffer.from(await saved.arrayBuffer());
485
+ expect(bytes.equals(Buffer.from("generated-data", "base64"))).toBe(true);
486
+ });
487
+
488
+ test("uses the image title for the filename when present", async () => {
489
+ mockGenerateResult = {
490
+ images: [
491
+ {
492
+ mimeType: "image/png",
493
+ dataBase64: "img1",
494
+ title: "Twilight Gremlin!",
495
+ } as never,
496
+ ],
497
+ text: "",
498
+ resolvedModel: "gemini-3.1-flash-image-preview",
499
+ };
500
+
501
+ const result = await run({ prompt: "a pond creature" }, fakeContext);
502
+
503
+ expect(result.isError).toBe(false);
504
+ expect(result.content).toContain(
505
+ "Saved to media/generated/twilight-gremlin.png",
506
+ );
507
+ });
508
+
509
+ test("gives each variant a distinct filename", async () => {
510
+ mockGenerateResult = {
511
+ images: [
512
+ { mimeType: "image/png", dataBase64: "img1" },
513
+ { mimeType: "image/png", dataBase64: "img2" },
514
+ ],
515
+ text: undefined as unknown as string,
516
+ resolvedModel: "gemini-3.1-flash-image-preview",
517
+ };
518
+
519
+ const result = await run({ prompt: "a robot", variants: 2 }, fakeContext);
520
+
521
+ expect(result.isError).toBe(false);
522
+ expect(result.content).toContain("- media/generated/a-robot.png");
523
+ expect(result.content).toContain("- media/generated/a-robot-2.png");
524
+ expect(
525
+ await Bun.file(
526
+ join(fakeContext.workingDir, "media/generated/a-robot-2.png"),
527
+ ).exists(),
528
+ ).toBe(true);
529
+ });
530
+
531
+ test("suffixes the filename when a previous generation already used it", async () => {
532
+ const first = await run({ prompt: "a fox" }, fakeContext);
533
+ expect(first.content).toContain("Saved to media/generated/a-fox.png");
534
+
535
+ const second = await run({ prompt: "a fox" }, fakeContext);
536
+ expect(second.content).toContain("Saved to media/generated/a-fox-2.png");
537
+ });
538
+
539
+ test("refuses to write through a symlink that escapes the workspace", async () => {
540
+ const outside = mkdtempSync(join(tmpdir(), "media-gen-outside-"));
541
+ tempWorkingDirs.push(outside);
542
+ symlinkSync(outside, join(fakeContext.workingDir, "media"));
543
+
544
+ const result = await run({ prompt: "a cat" }, fakeContext);
545
+
546
+ expect(result.isError).toBe(false);
547
+ expect(result.content).toContain("Could not save to the workspace");
548
+ expect(result.content).not.toContain("vellum://workspace/");
549
+ expect(readdirSync(outside)).toEqual([]);
550
+ });
551
+
552
+ test("falls back to inline-only when the workspace write fails", async () => {
553
+ // Occupy the media path with a regular file so the directory creation
554
+ // under it fails.
555
+ writeFileSync(join(fakeContext.workingDir, "media"), "not a directory");
556
+
557
+ const result = await run({ prompt: "a cat" }, fakeContext);
558
+
559
+ expect(result.isError).toBe(false);
560
+ expect(result.content).toContain("Could not save to the workspace");
561
+ expect(result.content).toContain(
562
+ "will be attached to your reply automatically",
563
+ );
564
+ expect(result.content).not.toContain("vellum://workspace/");
565
+ expect(result.contentBlocks).toHaveLength(1);
456
566
  });
457
567
 
458
568
  test("returns error when all source_paths are invalid", async () => {
@@ -64,9 +64,13 @@ mock.module("../runtime/assistant-event-hub.js", () => ({
64
64
  broadcastMessage: broadcastMessageMock,
65
65
  }));
66
66
 
67
- import type { UserPromptSubmitContext } from "@vellumai/plugin-api";
67
+ import {
68
+ type UserPromptSubmitContext,
69
+ VOICE_ESCALATION_CONTINUATION_MESSAGE_KIND,
70
+ } from "@vellumai/plugin-api";
68
71
 
69
72
  import type { AssistantEvent } from "../api/index.js";
73
+ import { ESCALATION_CONTINUATION_CONTENT } from "../calls/voice-triage-escalate.js";
70
74
  import type { AssistantConfig } from "../config/schema.js";
71
75
  import type { Conversation } from "../daemon/conversation.js";
72
76
  import type { QdrantSparseVector } from "../persistence/embeddings/qdrant-client.js";
@@ -74,6 +78,7 @@ import type { ConversationGraphMemory } from "../plugins/defaults/memory/graph/c
74
78
  import userPromptSubmitMemoryRetrieval from "../plugins/defaults/memory/hooks/user-prompt-submit.js";
75
79
  import type { Message } from "../providers/types.js";
76
80
  import { asConversation } from "./helpers/mock-conversation.js";
81
+ import { setConfig } from "./helpers/set-config.js";
77
82
 
78
83
  /** Canonical metrics payload the graph retriever attaches to a real hit. */
79
84
  function makeMetrics() {
@@ -175,13 +180,14 @@ function installConversation(
175
180
  trusted?: boolean;
176
181
  abortController?: AbortController;
177
182
  },
178
- ): void {
183
+ ): Conversation {
179
184
  currentTrustClass = opts?.trusted === false ? "unknown" : "guardian";
180
185
  currentConversation = asConversation({
181
186
  graphMemory,
182
187
  trustContext: undefined,
183
188
  abortController: opts?.abortController ?? new AbortController(),
184
189
  });
190
+ return currentConversation;
185
191
  }
186
192
 
187
193
  beforeEach(() => {
@@ -192,9 +198,95 @@ beforeEach(() => {
192
198
  broadcastMessageMock.mockReset();
193
199
  currentConversation = undefined;
194
200
  currentTrustClass = "guardian";
201
+ setConfig("memory", { enabled: true, v3: { live: false } });
195
202
  });
196
203
 
197
204
  describe("user-prompt-submit hook (memory retrieval)", () => {
205
+ test("voice front door skips serial legacy retrieval", async () => {
206
+ const { memory, prepareMemoryMock, recordPkbQueryVectorsMock } =
207
+ makeFakeGraphMemory();
208
+ const conversation = installConversation(memory, { trusted: true });
209
+ conversation.currentCallSite = "voiceFrontDoor";
210
+ const ctx = makeHookCtx({ conversationId: "conv-voice" });
211
+
212
+ await userPromptSubmitMemoryRetrieval(ctx);
213
+
214
+ expect(prepareMemoryMock).not.toHaveBeenCalled();
215
+ expect(recordPkbQueryVectorsMock).toHaveBeenCalledWith(
216
+ undefined,
217
+ undefined,
218
+ );
219
+ expect(applyRuntimeInjectionsMock).toHaveBeenCalledTimes(1);
220
+ });
221
+
222
+ test("voice escalation continuation routes legacy retrieval on the preceding user message", async () => {
223
+ const { memory, prepareMemoryMock } = makeFakeGraphMemory();
224
+ installConversation(memory, { trusted: true });
225
+ const latestMessages: Message[] = [
226
+ {
227
+ role: "user",
228
+ content: [
229
+ { type: "text", text: "<info>\ncarried context\n</info>" },
230
+ {
231
+ type: "text",
232
+ text: "<turn_context>\nturn context\n</turn_context>",
233
+ },
234
+ { type: "text", text: "What is my preferred editor?" },
235
+ ],
236
+ },
237
+ {
238
+ role: "assistant",
239
+ content: [{ type: "text", text: "Let me check that." }],
240
+ },
241
+ {
242
+ role: "user",
243
+ content: [{ type: "text", text: "Continue the answer." }],
244
+ },
245
+ ];
246
+ const ctx = makeHookCtx({
247
+ latestMessages,
248
+ isHiddenPrompt: true,
249
+ prompt: ESCALATION_CONTINUATION_CONTENT,
250
+ messageKind: VOICE_ESCALATION_CONTINUATION_MESSAGE_KIND,
251
+ });
252
+
253
+ await userPromptSubmitMemoryRetrieval(ctx);
254
+
255
+ expect(prepareMemoryMock).toHaveBeenCalledTimes(1);
256
+ expect(prepareMemoryMock.mock.calls[0]?.[0]).toBe(latestMessages);
257
+ expect(prepareMemoryMock.mock.calls[0]?.[4]).toEqual([
258
+ {
259
+ role: "user",
260
+ content: [{ type: "text", text: "What is my preferred editor?" }],
261
+ },
262
+ ]);
263
+ });
264
+
265
+ test("generic hidden prompts route legacy retrieval on their own content", async () => {
266
+ const { memory, prepareMemoryMock } = makeFakeGraphMemory();
267
+ installConversation(memory, { trusted: true });
268
+ const latestMessages: Message[] = [
269
+ {
270
+ role: "user",
271
+ content: [{ type: "text", text: "Visible caller message" }],
272
+ },
273
+ {
274
+ role: "user",
275
+ content: [{ type: "text", text: "Hidden prompt content" }],
276
+ },
277
+ ];
278
+ const ctx = makeHookCtx({
279
+ latestMessages,
280
+ isHiddenPrompt: true,
281
+ prompt: "Hidden prompt content",
282
+ });
283
+
284
+ await userPromptSubmitMemoryRetrieval(ctx);
285
+
286
+ expect(prepareMemoryMock).toHaveBeenCalledTimes(1);
287
+ expect(prepareMemoryMock.mock.calls[0]?.[4]).toBe(latestMessages);
288
+ });
289
+
198
290
  test("adopts the injected run messages when the actor is trusted", async () => {
199
291
  const injected: Message[] = [
200
292
  { role: "user", content: [{ type: "text", text: "injected" }] },
@@ -26,7 +26,7 @@ describe("resolveWebhookUrl", () => {
26
26
  });
27
27
 
28
28
  expect(url).toBe(
29
- "https://callbacks.vellum.ai/abc/webhooks/plugins/imessage/events-photon",
29
+ "https://callbacks.vellum.ai/abc/webhooks/plugins/imessage/events-photon/",
30
30
  );
31
31
  expect(spy.mock.calls[0]![1]).toBe(
32
32
  "webhooks/plugins/imessage/events-photon",
@@ -124,10 +124,10 @@ describe("resolveWebhookUrl", () => {
124
124
  }
125
125
  });
126
126
 
127
- test("hands the vendor a URL with no trailing slash", async () => {
128
- // The platform appends one, and a provider delivers to the URL it was
129
- // given verbatim. The gateway matches the declared path, which never ends
130
- // in a slash, so a registration carrying one 404s every delivery.
127
+ test("hands the vendor a URL with a trailing slash", async () => {
128
+ // Django in front of managed callbacks canonicalizes onto `/`. A vendor
129
+ // given the slashless spelling is 301'd, and clients that follow a 301
130
+ // on POST typically retry as GET and drop the body.
131
131
  const spy = spyOn(registration, "resolveCallbackUrl").mockResolvedValue(
132
132
  "https://callbacks.vellum.ai/abc/webhooks/plugins/imessage/events-comms/",
133
133
  );
@@ -135,13 +135,16 @@ describe("resolveWebhookUrl", () => {
135
135
  await expect(
136
136
  resolveWebhookUrl({ plugin: "imessage", path: "events-comms" }),
137
137
  ).resolves.toBe(
138
- "https://callbacks.vellum.ai/abc/webhooks/plugins/imessage/events-comms",
138
+ "https://callbacks.vellum.ai/abc/webhooks/plugins/imessage/events-comms/",
139
+ );
140
+ expect(spy.mock.calls[0]![1]).toBe(
141
+ "webhooks/plugins/imessage/events-comms",
139
142
  );
140
143
  spy.mockRestore();
141
144
  });
142
145
 
143
146
  test("leaves a URL carrying a query string alone", async () => {
144
- // Trimming there would cut into the query rather than the path.
147
+ // Appending there would cut into the query rather than the path.
145
148
  const spy = spyOn(registration, "resolveCallbackUrl").mockResolvedValue(
146
149
  "https://example.test/webhooks/plugins/imessage/events-comms?token=abc/",
147
150
  );
@@ -22,6 +22,7 @@ import { Glob } from "bun";
22
22
  * barrel. Bundled defaults are compiled into the daemon, so the barrel is
23
23
  * their loading mechanism: the in-process analog of loading an external
24
24
  * plugin from its manifest.
25
+ * - `src/plugins/defaults/worker-entrypoints.ts`, the compiled sidecar loader.
25
26
  * - Imports of modules directly under `defaults/` (e.g.
26
27
  * `injector-order.js`) — those are host-level shared modules, not plugin
27
28
  * internals.
@@ -47,8 +48,11 @@ import { Glob } from "bun";
47
48
  const DEFAULTS_REL = join("src", "plugins", "defaults");
48
49
  const DEFAULTS_ABS = join(process.cwd(), DEFAULTS_REL);
49
50
 
50
- /** The bundled-plugin registration barrel — the sanctioned host→plugin edge. */
51
- const COMPOSITION_ROOT = join(DEFAULTS_REL, "index.ts");
51
+ /** Bundled-plugin composition roots allowed to load plugin internals. */
52
+ const COMPOSITION_ROOTS = new Set([
53
+ join(DEFAULTS_REL, "index.ts"),
54
+ join(DEFAULTS_REL, "worker-entrypoints.ts"),
55
+ ]);
52
56
 
53
57
  /**
54
58
  * Allowed host→plugin reaches: plugin name → sorted list of host files (paths
@@ -151,7 +155,7 @@ function collectHostFiles(): HostFile[] {
151
155
  if (norm.split(sep).includes("__tests__")) {
152
156
  continue;
153
157
  }
154
- if (norm === COMPOSITION_ROOT) {
158
+ if (COMPOSITION_ROOTS.has(norm)) {
155
159
  continue;
156
160
  }
157
161
  // A file at least two segments below defaults/ belongs to a plugin —
@@ -34,6 +34,7 @@ function makeContext(overrides?: Partial<Conversation>): Conversation {
34
34
  workingDir: "/tmp/test-project",
35
35
  abortController: null,
36
36
  sendToClient: () => {},
37
+ emit: () => {},
37
38
  surfacesByAppId: new Map(),
38
39
  ...overrides,
39
40
  } as unknown as Conversation;
@@ -20,7 +20,10 @@ import {
20
20
 
21
21
  const testDataDir = process.env.VELLUM_WORKSPACE_DIR!;
22
22
 
23
- import { QdrantManager } from "../persistence/embeddings/qdrant-manager.js";
23
+ import {
24
+ QdrantManager,
25
+ resolveQdrantReleaseAsset,
26
+ } from "../persistence/embeddings/qdrant-manager.js";
24
27
 
25
28
  /**
26
29
  * Short timeouts so tests complete fast but with enough headroom for CI and
@@ -96,6 +99,16 @@ describe("QdrantManager", () => {
96
99
  });
97
100
  });
98
101
 
102
+ test("selects the pinned Windows x64 zip release", () => {
103
+ expect(resolveQdrantReleaseAsset("win32", "x64")).toEqual({
104
+ binaryName: "qdrant.exe",
105
+ filename: "qdrant-x86_64-pc-windows-msvc.zip",
106
+ format: "zip",
107
+ sha256:
108
+ "9d3b1d1fa58566bc71709347c4c9b83a0111d23a550daa93c3ee48e3150c4470",
109
+ });
110
+ });
111
+
99
112
  // ── getUrl ───────────────────────────────────────────────────
100
113
 
101
114
  describe("getUrl", () => {
@@ -57,6 +57,7 @@ mock.module("../persistence/lifecycle-quiesce.js", () => ({
57
57
 
58
58
  import { getDb } from "../persistence/db-connection.js";
59
59
  import { initializeDb } from "../persistence/db-init.js";
60
+ import { isPluginDirActivated } from "../plugins/mtime-cache.js";
60
61
  import {
61
62
  createSchedule,
62
63
  deferClaimedSchedule,
@@ -342,6 +343,26 @@ describe("fire-time source-availability gate", () => {
342
343
  expect(skipRunsFor(job.id)).toHaveLength(1);
343
344
  });
344
345
 
346
+ test("executes an armed sourced row even though no plugin is activated in this process", async () => {
347
+ const job = await seedDueSourcedScript();
348
+ // The tick runs in the schedule worker, which activates no plugins, so the
349
+ // activation ledger is empty here. The gate must therefore read the disk
350
+ // alone: reading activation would skip every plugin schedule. Arming is
351
+ // the daemon-side reconciler's call.
352
+ expect(isPluginDirActivated(pluginDir)).toBe(false);
353
+
354
+ const result = await runDueSchedulesOnce();
355
+
356
+ expect(result.completed).toBe(1);
357
+ expect(result.skipped).toBe(0);
358
+ expect(existsSync(marker)).toBe(true);
359
+ const runs = rawDb()
360
+ .query("SELECT status FROM cron_runs WHERE job_id = ?")
361
+ .all(job.id) as Array<{ status: string }>;
362
+ expect(runs).toHaveLength(1);
363
+ expect(runs[0].status).toBe("ok");
364
+ });
365
+
345
366
  test("does not execute a due sourced row while the feature flag is off", async () => {
346
367
  const job = await seedDueSourcedScript();
347
368
  setOverridesForTesting({ "plugin-schedules": false });