@vellumai/assistant 0.8.9-staging.2 → 0.8.9-staging.4

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 (196) hide show
  1. package/docs/activation-funnel-telemetry.md +310 -0
  2. package/openapi.yaml +16 -115
  3. package/package.json +1 -1
  4. package/src/__tests__/activation-early-marking.test.ts +120 -0
  5. package/src/__tests__/agent-loop-output-hooks.test.ts +13 -13
  6. package/src/__tests__/anthropic-provider.test.ts +23 -8
  7. package/src/__tests__/approval-cascade.test.ts +1 -1
  8. package/src/__tests__/compaction-direct.test.ts +32 -18
  9. package/src/__tests__/compaction-events.test.ts +2 -2
  10. package/src/__tests__/compaction.benchmark.test.ts +1 -1
  11. package/src/__tests__/context-overflow-reducer.test.ts +5 -5
  12. package/src/__tests__/context-window-manager-compact-retry.test.ts +121 -15
  13. package/src/__tests__/conversation-abort-tool-results.test.ts +1 -1
  14. package/src/__tests__/conversation-confirmation-signals.test.ts +1 -1
  15. package/src/__tests__/conversation-error.test.ts +15 -1
  16. package/src/__tests__/conversation-history-web-search.test.ts +5 -0
  17. package/src/__tests__/conversation-media-retry.test.ts +1 -1
  18. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +40 -0
  19. package/src/__tests__/conversation-process-callsite.test.ts +1 -1
  20. package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -1
  21. package/src/__tests__/conversation-queue.test.ts +1 -1
  22. package/src/__tests__/conversation-runtime-assembly.test.ts +71 -0
  23. package/src/__tests__/conversation-slash-queue.test.ts +1 -1
  24. package/src/__tests__/conversation-slash-unknown.test.ts +1 -1
  25. package/src/__tests__/conversation-speed-override.test.ts +1 -1
  26. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +395 -0
  27. package/src/__tests__/conversation-surfaces-app-control.test.ts +44 -0
  28. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +73 -4
  29. package/src/__tests__/conversation-undo.test.ts +2 -2
  30. package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -1
  31. package/src/__tests__/conversation-workspace-injection.test.ts +1 -1
  32. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
  33. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  34. package/src/__tests__/cu-unified-flow.test.ts +36 -0
  35. package/src/__tests__/history-repair-hook.test.ts +2 -0
  36. package/src/__tests__/llm-resolver.test.ts +73 -0
  37. package/src/__tests__/memory-retrieval-hook.test.ts +1 -2
  38. package/src/__tests__/persist-unsendable-image-downscale.test.ts +145 -0
  39. package/src/__tests__/persist-unsendable-image.test.ts +97 -1
  40. package/src/__tests__/plugin-external-api.test.ts +68 -0
  41. package/src/__tests__/post-turn-tool-result-truncation.test.ts +69 -0
  42. package/src/__tests__/published-app-updater.test.ts +138 -0
  43. package/src/__tests__/skill-feature-flags-integration.test.ts +5 -7
  44. package/src/__tests__/title-generate-hook.test.ts +2 -0
  45. package/src/__tests__/web-fetch.test.ts +45 -0
  46. package/src/acp/__tests__/helpers/acp-config-stub.ts +0 -2
  47. package/src/acp/resolve-agent.test.ts +0 -56
  48. package/src/acp/resolve-agent.ts +10 -38
  49. package/src/agent/loop.ts +13 -27
  50. package/src/api/responses/memory-v3-selection-log.ts +19 -10
  51. package/src/cli/commands/__tests__/memory-v3.test.ts +191 -210
  52. package/src/cli/commands/memory-v3.ts +57 -199
  53. package/src/cli/lib/__tests__/install-from-github.test.ts +232 -29
  54. package/src/cli/lib/__tests__/plugin-details.test.ts +28 -19
  55. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +57 -7
  56. package/src/cli/lib/__tests__/search-plugins.test.ts +17 -10
  57. package/src/cli/lib/install-from-github.ts +258 -41
  58. package/src/cli/lib/plugin-details.ts +20 -13
  59. package/src/cli/lib/plugin-marketplace.ts +23 -5
  60. package/src/cli/lib/search-plugins.ts +14 -8
  61. package/src/config/acp-defaults.ts +3 -3
  62. package/src/config/acp-schema.ts +1 -7
  63. package/src/config/bundled-skills/acp/SKILL.md +4 -17
  64. package/src/config/bundled-skills/acp/TOOLS.json +2 -2
  65. package/src/config/call-site-defaults.ts +0 -1
  66. package/src/config/feature-flag-registry.json +3 -18
  67. package/src/config/llm-resolver.ts +39 -7
  68. package/src/config/schemas/__tests__/memory-v3.test.ts +25 -9
  69. package/src/config/schemas/call-site-catalog.ts +0 -7
  70. package/src/config/schemas/llm.ts +17 -1
  71. package/src/config/schemas/memory-v3.ts +58 -8
  72. package/src/config/seed-inference-profiles.ts +18 -0
  73. package/src/context/post-turn-tool-result-truncation.ts +39 -1
  74. package/src/daemon/conversation-agent-loop-handlers.ts +8 -1
  75. package/src/daemon/conversation-agent-loop.ts +87 -95
  76. package/src/daemon/conversation-error.ts +31 -4
  77. package/src/daemon/conversation-history.ts +1 -1
  78. package/src/daemon/conversation-media-retry.ts +19 -6
  79. package/src/daemon/conversation-messaging.ts +17 -0
  80. package/src/daemon/conversation-process.ts +14 -5
  81. package/src/daemon/conversation-queue-manager.ts +8 -0
  82. package/src/daemon/conversation-runtime-assembly.ts +37 -1
  83. package/src/daemon/conversation-surfaces.ts +141 -3
  84. package/src/daemon/conversation.ts +48 -13
  85. package/src/daemon/external-plugins-bootstrap.ts +8 -3
  86. package/src/daemon/persist-unsendable-image.ts +62 -25
  87. package/src/daemon/process-message.ts +1 -1
  88. package/src/daemon/tool-side-effects.ts +15 -0
  89. package/src/memory/__tests__/activation-session-store.test.ts +41 -0
  90. package/src/memory/__tests__/onboarding-events-store.test.ts +80 -0
  91. package/src/memory/activation-session-store.ts +43 -0
  92. package/src/memory/db-init.ts +4 -0
  93. package/src/memory/migrations/273-onboarding-events-funnel-columns.ts +46 -0
  94. package/src/memory/migrations/274-create-activation-sessions.ts +15 -0
  95. package/src/memory/migrations/index.ts +2 -0
  96. package/src/memory/onboarding-events-store.ts +66 -18
  97. package/src/memory/schema/infrastructure.ts +13 -0
  98. package/src/memory/v2/__tests__/consolidation-job.test.ts +4 -97
  99. package/src/memory/v2/__tests__/page-store.test.ts +22 -0
  100. package/src/memory/v2/consolidation-job.ts +2 -72
  101. package/src/memory/v2/types.ts +5 -0
  102. package/src/messaging/providers/telegram-bot/api.ts +14 -5
  103. package/src/notifications/adapters/telegram.ts +7 -1
  104. package/src/plugin-api/constants.ts +2 -2
  105. package/src/plugin-api/index.ts +2 -2
  106. package/src/plugin-api/types.ts +19 -5
  107. package/src/plugins/defaults/compaction/compact.ts +24 -15
  108. package/src/plugins/defaults/compaction/context-overflow-reducer.ts +4 -4
  109. package/src/plugins/defaults/compaction/manager-store.ts +1 -1
  110. package/src/{context → plugins/defaults/compaction}/window-manager.ts +68 -12
  111. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +12 -18
  112. package/src/plugins/defaults/memory-v3-shadow/__tests__/capabilities.test.ts +19 -66
  113. package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +181 -0
  114. package/src/plugins/defaults/memory-v3-shadow/__tests__/edge.test.ts +247 -0
  115. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +139 -129
  116. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +332 -164
  117. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +516 -293
  118. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +306 -0
  119. package/src/plugins/defaults/memory-v3-shadow/__tests__/render-injection.test.ts +51 -21
  120. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +402 -0
  121. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-needle.test.ts +135 -0
  122. package/src/plugins/defaults/memory-v3-shadow/__tests__/sections.test.ts +125 -0
  123. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +66 -11
  124. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +446 -0
  125. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +271 -110
  126. package/src/plugins/defaults/memory-v3-shadow/__tests__/types.test.ts +0 -22
  127. package/src/plugins/defaults/memory-v3-shadow/capabilities.ts +26 -62
  128. package/src/plugins/defaults/memory-v3-shadow/dense.ts +97 -0
  129. package/src/plugins/defaults/memory-v3-shadow/edge.ts +252 -0
  130. package/src/plugins/defaults/memory-v3-shadow/injector.ts +3 -2
  131. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +415 -181
  132. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +196 -56
  133. package/src/plugins/defaults/memory-v3-shadow/page-content.ts +39 -2
  134. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +204 -0
  135. package/src/plugins/defaults/memory-v3-shadow/render-injection.ts +15 -7
  136. package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +236 -0
  137. package/src/plugins/defaults/memory-v3-shadow/section-needle.ts +200 -0
  138. package/src/plugins/defaults/memory-v3-shadow/sections.ts +115 -0
  139. package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +75 -3
  140. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +111 -78
  141. package/src/plugins/defaults/memory-v3-shadow/types.ts +52 -19
  142. package/src/plugins/defaults/memory-v3-shadow/working-set.ts +4 -1
  143. package/src/plugins/external-api.ts +114 -0
  144. package/src/prompts/system-prompt.ts +61 -10
  145. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +37 -2
  146. package/src/providers/anthropic/client.ts +9 -10
  147. package/src/providers/inference/kimi-cjk-token-ids.ts +493 -0
  148. package/src/providers/inference/logit-bias.ts +55 -0
  149. package/src/providers/openai/__tests__/vision-not-supported.test.ts +75 -0
  150. package/src/providers/openai/chat-completions-provider.ts +44 -1
  151. package/src/providers/retry.ts +22 -0
  152. package/src/providers/types.ts +6 -0
  153. package/src/runtime/routes/__tests__/stt-routes.test.ts +112 -0
  154. package/src/runtime/routes/acp-routes.test.ts +3 -20
  155. package/src/runtime/routes/app-management-routes.ts +3 -0
  156. package/src/runtime/routes/conversation-routes.ts +2 -0
  157. package/src/runtime/routes/memory-v3-routes.ts +64 -314
  158. package/src/runtime/routes/playground/__tests__/force-compact.test.ts +1 -1
  159. package/src/runtime/routes/stt-routes.ts +45 -12
  160. package/src/runtime/routes/workspace-routes.ts +50 -15
  161. package/src/services/published-app-updater.ts +30 -8
  162. package/src/telemetry/__tests__/activation-funnel.test.ts +95 -0
  163. package/src/telemetry/activation-funnel.ts +167 -0
  164. package/src/telemetry/types.ts +13 -0
  165. package/src/telemetry/usage-telemetry-reporter.test.ts +154 -0
  166. package/src/telemetry/usage-telemetry-reporter.ts +26 -1
  167. package/src/tools/acp/list-agents.test.ts +2 -18
  168. package/src/tools/acp/list-agents.ts +3 -15
  169. package/src/tools/acp/spawn.test.ts +0 -10
  170. package/src/tools/browser/browser-execution.ts +12 -2
  171. package/src/tools/network/web-fetch.ts +65 -24
  172. package/src/tools/skills/load.ts +1 -1
  173. package/src/tools/ui-surface/definitions.ts +7 -0
  174. package/src/acp/feature-gate.test.ts +0 -48
  175. package/src/acp/feature-gate.ts +0 -34
  176. package/src/plugins/defaults/memory-v3-shadow/__tests__/assign.test.ts +0 -242
  177. package/src/plugins/defaults/memory-v3-shadow/__tests__/core.test.ts +0 -39
  178. package/src/plugins/defaults/memory-v3-shadow/__tests__/health.test.ts +0 -219
  179. package/src/plugins/defaults/memory-v3-shadow/__tests__/needle.test.ts +0 -107
  180. package/src/plugins/defaults/memory-v3-shadow/__tests__/provider-blocks.test.ts +0 -13
  181. package/src/plugins/defaults/memory-v3-shadow/__tests__/reconcile.test.ts +0 -274
  182. package/src/plugins/defaults/memory-v3-shadow/__tests__/router.test.ts +0 -337
  183. package/src/plugins/defaults/memory-v3-shadow/__tests__/selector.test.ts +0 -470
  184. package/src/plugins/defaults/memory-v3-shadow/__tests__/snapshot.test.ts +0 -168
  185. package/src/plugins/defaults/memory-v3-shadow/__tests__/tree.test.ts +0 -192
  186. package/src/plugins/defaults/memory-v3-shadow/assign.ts +0 -272
  187. package/src/plugins/defaults/memory-v3-shadow/core.ts +0 -26
  188. package/src/plugins/defaults/memory-v3-shadow/health.ts +0 -0
  189. package/src/plugins/defaults/memory-v3-shadow/needle.ts +0 -115
  190. package/src/plugins/defaults/memory-v3-shadow/provider-blocks.ts +0 -26
  191. package/src/plugins/defaults/memory-v3-shadow/reconcile.ts +0 -527
  192. package/src/plugins/defaults/memory-v3-shadow/router.ts +0 -190
  193. package/src/plugins/defaults/memory-v3-shadow/selector.ts +0 -226
  194. package/src/plugins/defaults/memory-v3-shadow/snapshot.ts +0 -209
  195. package/src/plugins/defaults/memory-v3-shadow/tree.ts +0 -174
  196. package/src/util/map-limit.ts +0 -27
@@ -2,9 +2,9 @@
2
2
  * Regression tests for app surface refresh and eventing side effects in
3
3
  * createToolExecutor (conversation-tool-setup.ts).
4
4
  *
5
- * Tests verify that app_refresh, app_create, and app_delete hooks fire
6
- * correctly, and that removed hooks (app_update, app_file_edit,
7
- * app_file_write) no longer trigger side effects.
5
+ * Tests verify that app_refresh, app_update, app_create, and app_delete hooks
6
+ * fire correctly, and that non-hooked tools (app_file_edit, app_file_write) do
7
+ * not trigger side effects.
8
8
  *
9
9
  * File-change detection for file_write/file_edit is handled by
10
10
  * AppSourceWatcher (see app-source-watcher.test.ts).
@@ -248,6 +248,76 @@ describe("session-tool-setup app refresh side effects", () => {
248
248
  });
249
249
  });
250
250
 
251
+ // ── app_update ──────────────────────────────────────────────────────
252
+
253
+ describe("app_update", () => {
254
+ test("triggers refreshSurfacesForApp and broadcast on success", async () => {
255
+ const ctx = makeCtx();
256
+ const executor = makeFakeExecutor({
257
+ content: '{"updated":true,"appId":"app-7"}',
258
+ isError: false,
259
+ });
260
+
261
+ const toolFn = createToolExecutor(
262
+ executor as unknown as ToolExecutor,
263
+ noopPrompter,
264
+ noopSecretPrompter,
265
+ ctx,
266
+ noopLifecycleHandler,
267
+ );
268
+
269
+ await toolFn("app_update", { app_id: "app-7" });
270
+
271
+ expect(refreshSpy).toHaveBeenCalledTimes(1);
272
+ expect((refreshSpy.mock.calls as unknown[][])[0][1]).toBe("app-7");
273
+ expectAppChangeBroadcast("app-7");
274
+ expect(updatePublishedSpy).toHaveBeenCalledTimes(1);
275
+ expect((updatePublishedSpy.mock.calls as unknown[][])[0][0]).toBe(
276
+ "app-7",
277
+ );
278
+ });
279
+
280
+ test("skips side effects when result is an error", async () => {
281
+ const ctx = makeCtx();
282
+ const executor = makeFakeExecutor({
283
+ content: "Error: not found",
284
+ isError: true,
285
+ });
286
+
287
+ const toolFn = createToolExecutor(
288
+ executor as unknown as ToolExecutor,
289
+ noopPrompter,
290
+ noopSecretPrompter,
291
+ ctx,
292
+ noopLifecycleHandler,
293
+ );
294
+
295
+ await toolFn("app_update", { app_id: "app-err" });
296
+
297
+ expect(refreshSpy).not.toHaveBeenCalled();
298
+ expect(broadcastSpy).not.toHaveBeenCalled();
299
+ expect(updatePublishedSpy).not.toHaveBeenCalled();
300
+ });
301
+
302
+ test("skips side effects when app_id is missing", async () => {
303
+ const ctx = makeCtx();
304
+ const executor = makeFakeExecutor({ content: "{}", isError: false });
305
+
306
+ const toolFn = createToolExecutor(
307
+ executor as unknown as ToolExecutor,
308
+ noopPrompter,
309
+ noopSecretPrompter,
310
+ ctx,
311
+ noopLifecycleHandler,
312
+ );
313
+
314
+ await toolFn("app_update", {});
315
+
316
+ expect(refreshSpy).not.toHaveBeenCalled();
317
+ expect(broadcastSpy).not.toHaveBeenCalled();
318
+ });
319
+ });
320
+
251
321
  // ── app_create side effects ─────────────────────────────────────────
252
322
 
253
323
  describe("app_create side effects", () => {
@@ -518,7 +588,6 @@ describe("session-tool-setup app refresh side effects", () => {
518
588
  "write_file",
519
589
  "shell",
520
590
  "app_list",
521
- "app_update",
522
591
  "app_file_edit",
523
592
  "app_file_write",
524
593
  ]) {
@@ -1,10 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
+ import { findLastUndoableUserMessageIndex } from "../daemon/conversation.js";
3
4
  import {
4
5
  CONTEXT_SUMMARY_MARKER,
5
6
  createContextSummaryMessage,
6
- } from "../context/window-manager.js";
7
- import { findLastUndoableUserMessageIndex } from "../daemon/conversation.js";
7
+ } from "../plugins/defaults/compaction/window-manager.js";
8
8
  import type { Message } from "../providers/types.js";
9
9
 
10
10
  function textMessage(role: "user" | "assistant", text: string): Message {
@@ -137,7 +137,7 @@ mock.module("../memory/retrieval-budget.js", () => ({
137
137
  computeRecallBudget: () => 0,
138
138
  }));
139
139
 
140
- mock.module("../context/window-manager.js", () => ({
140
+ mock.module("../plugins/defaults/compaction/window-manager.js", () => ({
141
141
  ContextWindowManager: class {
142
142
  constructor() {}
143
143
  updateConfig() {}
@@ -181,7 +181,7 @@ mock.module("../memory/query-builder.js", () => ({
181
181
  mock.module("../memory/retrieval-budget.js", () => ({
182
182
  computeRecallBudget: () => 0,
183
183
  }));
184
- mock.module("../context/window-manager.js", () => ({
184
+ mock.module("../plugins/defaults/compaction/window-manager.js", () => ({
185
185
  ContextWindowManager: class {
186
186
  updateConfig() {}
187
187
  shouldCompact() {
@@ -178,7 +178,7 @@ mock.module("../memory/query-builder.js", () => ({
178
178
  mock.module("../memory/retrieval-budget.js", () => ({
179
179
  computeRecallBudget: () => 0,
180
180
  }));
181
- mock.module("../context/window-manager.js", () => ({
181
+ mock.module("../plugins/defaults/compaction/window-manager.js", () => ({
182
182
  ContextWindowManager: class {
183
183
  updateConfig() {}
184
184
  shouldCompact() {
@@ -230,6 +230,7 @@ describe("Invariant 2: no generic plaintext secret read API", () => {
230
230
  "runtime/routes/platform-routes.ts", // CLI platform connect/disconnect/status routes (CLI-migrated to IPC)
231
231
  "ipc/skill-routes/providers.ts", // host.providers.secureKeys.getProviderKey IPC route (out-of-process SkillHost companion)
232
232
  "daemon/external-plugins-bootstrap.ts", // reads credentials at plugin init (manifest.requiresCredential) via the CES-mediated getSecureKeyAsync path
233
+ "plugins/external-api.ts", // globalThis runtime bridge that exposes getSecureKeyAsync to dynamically-imported workspace plugins (compiled-binary plugin loading)
233
234
  "inbound/platform-callback-registration.ts", // managed credential lookup for platform base URL, assistant ID, and API key
234
235
  "tts/providers/elevenlabs-provider.ts", // ElevenLabs TTS API key lookup
235
236
  "tts/providers/deepgram-provider.ts", // Deepgram TTS API key lookup
@@ -69,6 +69,7 @@ type SurfaceConversationContext =
69
69
  function buildMockContext(
70
70
  hostCuProxy?: InstanceType<typeof HostCuProxy>,
71
71
  trustGuardianPrincipalId: string | null = DEFAULT_PRINCIPAL,
72
+ actorPrincipalId: string | null = trustGuardianPrincipalId,
72
73
  ): SurfaceConversationContext {
73
74
  return {
74
75
  conversationId: "test-session",
@@ -80,6 +81,16 @@ function buildMockContext(
80
81
  guardianPrincipalId: trustGuardianPrincipalId,
81
82
  }
82
83
  : undefined,
84
+ authContext:
85
+ actorPrincipalId != null
86
+ ? ({ actorPrincipalId } as SurfaceConversationContext["authContext"])
87
+ : undefined,
88
+ currentTurnAuthContext:
89
+ actorPrincipalId != null
90
+ ? ({
91
+ actorPrincipalId,
92
+ } as SurfaceConversationContext["currentTurnAuthContext"])
93
+ : undefined,
83
94
  traceEmitter: { emit: () => {} },
84
95
  sendToClient: () => {},
85
96
  pendingSurfaceActions: new Map(),
@@ -665,6 +676,31 @@ describe("surfaceProxyResolver — CU tool routing", () => {
665
676
  expect(sentMessages).toHaveLength(0);
666
677
  });
667
678
 
679
+ test("rejects untargeted CU dispatch to owner client when current requester is a trusted contact", async () => {
680
+ sentMessages.length = 0;
681
+ mockHasClient = true;
682
+ mockCuClients = [
683
+ {
684
+ clientId: "owner-cu-client",
685
+ capabilities: ["host_cu"],
686
+ actorPrincipalId: "owner-user",
687
+ },
688
+ ];
689
+ proxy = new HostCuProxy();
690
+ const ctx = buildMockContext(proxy, "owner-user", "trusted-contact-user");
691
+
692
+ const result = await surfaceProxyResolver(ctx, "computer_use_click", {
693
+ element_id: 1,
694
+ reasoning: "click",
695
+ });
696
+
697
+ expect(result.isError).toBe(true);
698
+ expect(result.content).toContain("current actor");
699
+ expect(proxy.stepCount).toBe(0);
700
+ expect(proxy.actionHistory).toHaveLength(0);
701
+ expect(sentMessages).toHaveLength(0);
702
+ });
703
+
668
704
  test("auto-resolves to the unique same-user CU client when cross-user clients are present", async () => {
669
705
  // Regression: previously the dispatch counted only same-user clients
670
706
  // for the multi-client guard, so 1 same-user + 1 cross-user passed the
@@ -40,6 +40,8 @@ const noopLogger: PluginLogger = {
40
40
  function makeCtx(messages: Message[]): UserPromptSubmitContext {
41
41
  return {
42
42
  conversationId: "conv-test",
43
+ userMessageId: "msg-test",
44
+ requestId: "req-test",
43
45
  prompt: "",
44
46
  originalMessages: messages,
45
47
  latestMessages: messages,
@@ -1249,3 +1249,76 @@ describe("resolveDefaultProfileKey", () => {
1249
1249
  expect(resolveDefaultProfileKey("mainAgent", llm)).toBe("balanced");
1250
1250
  });
1251
1251
  });
1252
+
1253
+ describe("resolveCallSiteConfig logitBias provenance", () => {
1254
+ const kimi = "accounts/fireworks/models/kimi-k2p6";
1255
+
1256
+ test("forwards logitBias from the active profile that opted in", () => {
1257
+ const llm = LLMSchema.parse({
1258
+ default: fullDefault,
1259
+ profiles: {
1260
+ "balanced-economy": {
1261
+ provider: "fireworks",
1262
+ model: kimi,
1263
+ logitBias: "suppress-cjk",
1264
+ },
1265
+ },
1266
+ activeProfile: "balanced-economy",
1267
+ });
1268
+ expect(resolveCallSiteConfig("mainAgent", llm).logitBias).toBe(
1269
+ "suppress-cjk",
1270
+ );
1271
+ });
1272
+
1273
+ test("a higher-precedence override profile that omits logitBias clears it", () => {
1274
+ // Active profile opts in, but a pinned (override) Kimi profile did not —
1275
+ // the override must not inherit suppress-cjk just because it resolves to
1276
+ // Fireworks.
1277
+ const llm = LLMSchema.parse({
1278
+ default: fullDefault,
1279
+ profiles: {
1280
+ "balanced-economy": {
1281
+ provider: "fireworks",
1282
+ model: kimi,
1283
+ logitBias: "suppress-cjk",
1284
+ },
1285
+ "my-kimi": { provider: "fireworks", model: kimi },
1286
+ },
1287
+ activeProfile: "balanced-economy",
1288
+ });
1289
+ const resolved = resolveCallSiteConfig("mainAgent", llm, {
1290
+ overrideProfile: "my-kimi",
1291
+ });
1292
+ expect(resolved.logitBias).toBeUndefined();
1293
+ });
1294
+
1295
+ test("does not leak the active profile's logitBias into a call site's own profile", () => {
1296
+ // For non-main call sites the call-site profile wins; since it didn't opt
1297
+ // in, the active profile's preset must not bleed through.
1298
+ const llm = LLMSchema.parse({
1299
+ default: fullDefault,
1300
+ profiles: {
1301
+ "balanced-economy": {
1302
+ provider: "fireworks",
1303
+ model: kimi,
1304
+ logitBias: "suppress-cjk",
1305
+ },
1306
+ plain: { provider: "anthropic", model: "claude-opus-4-7" },
1307
+ },
1308
+ activeProfile: "balanced-economy",
1309
+ callSites: { memoryExtraction: { profile: "plain" } },
1310
+ });
1311
+ expect(
1312
+ resolveCallSiteConfig("memoryExtraction", llm).logitBias,
1313
+ ).toBeUndefined();
1314
+ });
1315
+
1316
+ test("a logitBias on a non-profile layer (llm.default) does not apply when the winning profile omits it", () => {
1317
+ const llm = LLMSchema.parse({
1318
+ default: { ...fullDefault, logitBias: "suppress-cjk" },
1319
+ profiles: { plain: { provider: "anthropic", model: "claude-opus-4-7" } },
1320
+ activeProfile: "plain",
1321
+ });
1322
+ expect(resolveCallSiteConfig("mainAgent", llm).logitBias).toBeUndefined();
1323
+ });
1324
+ });
@@ -36,6 +36,7 @@ const applyRuntimeInjectionsMock = mock(async (messages: unknown) => ({
36
36
  }));
37
37
  mock.module("../daemon/conversation-runtime-assembly.js", () => ({
38
38
  applyRuntimeInjections: applyRuntimeInjectionsMock,
39
+ resolveTurnInboundActorContext: () => null,
39
40
  }));
40
41
 
41
42
  // The hook self-resolves the live conversation and its trust class; both are
@@ -146,10 +147,8 @@ function makeHookCtx(
146
147
  } as unknown as MemoryRetrievalHookContext["logger"],
147
148
  latestMessages: [],
148
149
  requestId: "req-test",
149
- mode: "full",
150
150
  isNonInteractive: false,
151
151
  modelProfile: null,
152
- actorContext: null,
153
152
  ...overrides,
154
153
  };
155
154
  }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Regression test for the durable-downscale path of the image-too-large
3
+ * recovery (JARVIS-1041 review follow-up).
4
+ *
5
+ * When an oversized stored image *can* be shrunk on this host (the common macOS
6
+ * path where `sips` is available), `persistUnsendableImageDowngrades` must write
7
+ * the downscaled bytes back to the DB — not leave the original in place. The
8
+ * latest tool-result media is intentionally kept in context, so leaving the
9
+ * full-size block would rehydrate and re-reject on every later turn instead of
10
+ * durably self-healing the conversation.
11
+ *
12
+ * `optimizeImageForTransport` needs `sips` and a decodable image to actually
13
+ * downscale, which is not portable to CI, so it is mocked here to simulate a
14
+ * successful shrink. The mock is process-global, so this case lives in its own
15
+ * file (the test runner isolates each file in its own process) to avoid
16
+ * disturbing the no-op-resize cases in persist-unsendable-image.test.ts.
17
+ */
18
+
19
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
20
+
21
+ // ── Module mock (must precede the import of the module under test) ────
22
+ // Simulate a host where resizing succeeds: any oversized image is shrunk to a
23
+ // small, distinct JPEG payload. durableImageReplacement checks the provider
24
+ // caps before calling this, so in-limit images never reach the mock.
25
+ const SHRUNK_DATA = "c2hydW5r"; // base64 for "shrunk"
26
+ mock.module("../agent/image-optimize.js", () => ({
27
+ optimizeImageForTransport: () => ({
28
+ data: SHRUNK_DATA,
29
+ mediaType: "image/jpeg",
30
+ }),
31
+ }));
32
+
33
+ import { persistUnsendableImageDowngrades } from "../daemon/persist-unsendable-image.js";
34
+ import {
35
+ addMessage,
36
+ createConversation,
37
+ getMessages,
38
+ } from "../memory/conversation-crud.js";
39
+ import { getDb } from "../memory/db-connection.js";
40
+ import { initializeDb } from "../memory/db-init.js";
41
+ import type { ContentBlock } from "../providers/types.js";
42
+
43
+ initializeDb();
44
+
45
+ function resetTables(): void {
46
+ const db = getDb();
47
+ db.run("DELETE FROM message_attachments");
48
+ db.run("DELETE FROM attachments");
49
+ db.run("DELETE FROM memory_segments");
50
+ db.run("DELETE FROM memory_embeddings");
51
+ db.run("DELETE FROM messages");
52
+ db.run("DELETE FROM conversations");
53
+ }
54
+
55
+ /** Minimal PNG whose IHDR declares dimensions past the 8000px provider cap. */
56
+ function oversizedPngBase64(): string {
57
+ const width = 12000;
58
+ const height = 9000;
59
+ return Buffer.from(
60
+ Uint8Array.from([
61
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
62
+ 0x00, 0x00, 0x00, 0x0d, // IHDR length (13)
63
+ 0x49, 0x48, 0x44, 0x52, // "IHDR"
64
+ (width >>> 24) & 0xff,
65
+ (width >>> 16) & 0xff,
66
+ (width >>> 8) & 0xff,
67
+ width & 0xff,
68
+ (height >>> 24) & 0xff,
69
+ (height >>> 16) & 0xff,
70
+ (height >>> 8) & 0xff,
71
+ height & 0xff,
72
+ 0x08, 0x06, 0x00, 0x00, 0x00,
73
+ ]),
74
+ ).toString("base64");
75
+ }
76
+
77
+ function toolResultWithImage(data: string): ContentBlock {
78
+ return {
79
+ type: "tool_result",
80
+ tool_use_id: "toolu_123",
81
+ content: "Screenshot captured",
82
+ contentBlocks: [
83
+ { type: "image", source: { type: "base64", media_type: "image/png", data } },
84
+ ],
85
+ };
86
+ }
87
+
88
+ function storedContent(conversationId: string): ContentBlock[][] {
89
+ return getMessages(conversationId).map(
90
+ (row) => JSON.parse(row.content) as ContentBlock[],
91
+ );
92
+ }
93
+
94
+ describe("persistUnsendableImageDowngrades (downscalable host)", () => {
95
+ beforeEach(() => {
96
+ resetTables();
97
+ });
98
+
99
+ /** JARVIS-1041: an oversized screenshot that CAN be shrunk must persist the
100
+ * downscaled bytes, not the note and not the original. */
101
+ test("persists the downscaled image for a shrinkable tool_result screenshot", async () => {
102
+ // GIVEN a tool_result holding an oversized but shrinkable screenshot
103
+ const conv = createConversation();
104
+ await addMessage(
105
+ conv.id,
106
+ "user",
107
+ JSON.stringify([toolResultWithImage(oversizedPngBase64())]),
108
+ { skipIndexing: true },
109
+ );
110
+
111
+ // WHEN the downgrade is persisted
112
+ const rewritten = persistUnsendableImageDowngrades(conv.id);
113
+
114
+ // THEN the nested block stays an image, rewritten to the downscaled payload
115
+ expect(rewritten).toBe(1);
116
+ const [content] = storedContent(conv.id);
117
+ const toolResult = content.find((b) => b.type === "tool_result") as {
118
+ contentBlocks?: ContentBlock[];
119
+ };
120
+ const nested = toolResult.contentBlocks?.[0];
121
+ expect(nested?.type).toBe("image");
122
+ expect((nested as Extract<ContentBlock, { type: "image" }>).source.data).toBe(
123
+ SHRUNK_DATA,
124
+ );
125
+ });
126
+
127
+ /** Re-running is a no-op: the downscaled payload is within limits. */
128
+ test("is idempotent after a downscale rewrite", async () => {
129
+ // GIVEN a conversation whose oversized screenshot was already downscaled
130
+ const conv = createConversation();
131
+ await addMessage(
132
+ conv.id,
133
+ "user",
134
+ JSON.stringify([toolResultWithImage(oversizedPngBase64())]),
135
+ { skipIndexing: true },
136
+ );
137
+ expect(persistUnsendableImageDowngrades(conv.id)).toBe(1);
138
+
139
+ // WHEN the downgrade runs again
140
+ const secondRun = persistUnsendableImageDowngrades(conv.id);
141
+
142
+ // THEN nothing further is rewritten
143
+ expect(secondRun).toBe(0);
144
+ });
145
+ });
@@ -14,7 +14,10 @@
14
14
 
15
15
  import { beforeEach, describe, expect, test } from "bun:test";
16
16
 
17
- import { persistUnsendableImageDowngrades } from "../daemon/persist-unsendable-image.js";
17
+ import {
18
+ oversizedImageReplacement,
19
+ persistUnsendableImageDowngrades,
20
+ } from "../daemon/persist-unsendable-image.js";
18
21
  import {
19
22
  addMessage,
20
23
  createConversation,
@@ -87,6 +90,20 @@ function imageBlock(data: string): ContentBlock {
87
90
  };
88
91
  }
89
92
 
93
+ /**
94
+ * A tool_result carrying a nested image in its contentBlocks, mirroring what a
95
+ * browser screenshot produces. This is the JARVIS-1041 shape: the oversized
96
+ * image lives at tool_result.contentBlocks, never as a top-level block.
97
+ */
98
+ function toolResultWithImage(data: string): ContentBlock {
99
+ return {
100
+ type: "tool_result",
101
+ tool_use_id: "toolu_123",
102
+ content: "Screenshot captured",
103
+ contentBlocks: [imageBlock(data)],
104
+ };
105
+ }
106
+
90
107
  function storedContent(conversationId: string): ContentBlock[][] {
91
108
  return getMessages(conversationId).map(
92
109
  (row) => JSON.parse(row.content) as ContentBlock[],
@@ -194,6 +211,61 @@ describe("persistUnsendableImageDowngrades", () => {
194
211
  expect(content.some((b) => b.type === "image")).toBe(false);
195
212
  });
196
213
 
214
+ /** JARVIS-1041: the oversized image is nested inside a tool_result (e.g. a
215
+ * browser screenshot), not a top-level block. The downgrade must descend
216
+ * into tool_result.contentBlocks and swap the nested image for a note, while
217
+ * keeping the tool_result itself intact so tool_use/tool_result pairing
218
+ * survives. */
219
+ test("downgrades an oversized image nested in tool_result.contentBlocks", async () => {
220
+ // GIVEN an assistant turn whose tool_result holds an oversized screenshot
221
+ const conv = createConversation();
222
+ await addMessage(
223
+ conv.id,
224
+ "user",
225
+ JSON.stringify([toolResultWithImage(makePngBase64(12000, 9000))]),
226
+ { skipIndexing: true },
227
+ );
228
+
229
+ // WHEN the downgrade is persisted
230
+ const rewritten = persistUnsendableImageDowngrades(conv.id);
231
+
232
+ // THEN the message is rewritten with the nested image swapped for a note
233
+ expect(rewritten).toBe(1);
234
+ const [content] = storedContent(conv.id);
235
+ const toolResult = content.find((b) => b.type === "tool_result");
236
+ expect(toolResult).toBeDefined();
237
+ // AND the tool_result is preserved (pairing intact) with no image left
238
+ const nested = (toolResult as { contentBlocks?: ContentBlock[] })
239
+ .contentBlocks;
240
+ expect(nested?.some((b) => b.type === "image")).toBe(false);
241
+ expect(nested?.some((b) => b.type === "text")).toBe(true);
242
+ });
243
+
244
+ /** A sendable nested screenshot is never disturbed. */
245
+ test("leaves a normally-sized tool_result image untouched", async () => {
246
+ // GIVEN a tool_result with an image well within provider limits
247
+ const conv = createConversation();
248
+ await addMessage(
249
+ conv.id,
250
+ "user",
251
+ JSON.stringify([toolResultWithImage(makePngBase64(1024, 768))]),
252
+ { skipIndexing: true },
253
+ );
254
+
255
+ // WHEN the downgrade is persisted
256
+ const rewritten = persistUnsendableImageDowngrades(conv.id);
257
+
258
+ // THEN nothing is rewritten and the nested image remains
259
+ expect(rewritten).toBe(0);
260
+ const [content] = storedContent(conv.id);
261
+ const toolResult = content.find((b) => b.type === "tool_result") as {
262
+ contentBlocks?: ContentBlock[];
263
+ };
264
+ expect(toolResult.contentBlocks?.some((b) => b.type === "image")).toBe(
265
+ true,
266
+ );
267
+ });
268
+
197
269
  /** Re-running after a rewrite is a safe no-op (no image blocks remain). */
198
270
  test("is idempotent — a second run rewrites nothing", async () => {
199
271
  // GIVEN a conversation whose oversized image has already been downgraded
@@ -213,3 +285,27 @@ describe("persistUnsendableImageDowngrades", () => {
213
285
  expect(secondRun).toBe(0);
214
286
  });
215
287
  });
288
+
289
+ describe("oversizedImageReplacement", () => {
290
+ /** A still-sendable image must be left alone — never replaced with a note.
291
+ * This is the gate that keeps the in-memory recovery from discarding valid
292
+ * screenshots when only one image in the turn was actually oversized. */
293
+ test("returns null for an image within the provider caps", () => {
294
+ const sendable = imageBlock(makePngBase64(1024, 768)) as Extract<
295
+ ContentBlock,
296
+ { type: "image" }
297
+ >;
298
+ expect(oversizedImageReplacement(sendable)).toBeNull();
299
+ });
300
+
301
+ /** An image past the provider caps that cannot be shrunk on this host (fake
302
+ * PNG that sips cannot decode) collapses to the unsendable note. */
303
+ test("returns the unsendable note when an oversized image cannot be shrunk", () => {
304
+ const oversized = imageBlock(makePngBase64(12000, 9000)) as Extract<
305
+ ContentBlock,
306
+ { type: "image" }
307
+ >;
308
+ const replacement = oversizedImageReplacement(oversized);
309
+ expect(replacement?.type).toBe("text");
310
+ });
311
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Tests for the `globalThis.__vellumPluginRuntime` bridge.
3
+ *
4
+ * The bridge exists so workspace-local plugins (`<workspaceDir>/plugins/*`)
5
+ * can register with the daemon's bundled module instances even when the
6
+ * daemon is a `bun --compile` binary. Absolute-path imports against a
7
+ * compiled binary load fresh disk copies into a disjoint module graph; the
8
+ * bridge sidesteps that by attaching a stable handle to globalThis.
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
12
+
13
+ import {
14
+ getPluginRuntime,
15
+ installPluginRuntime,
16
+ uninstallPluginRuntimeForTests,
17
+ } from "../plugins/external-api.js";
18
+ import { registerPlugin } from "../plugins/registry.js";
19
+ import { assistantEventHub } from "../runtime/assistant-event-hub.js";
20
+ import { getSecureKeyAsync } from "../security/secure-keys.js";
21
+
22
+ describe("plugin external-api bridge", () => {
23
+ beforeEach(() => {
24
+ uninstallPluginRuntimeForTests();
25
+ });
26
+
27
+ afterEach(() => {
28
+ uninstallPluginRuntimeForTests();
29
+ });
30
+
31
+ test("installs a runtime handle on globalThis", () => {
32
+ expect(getPluginRuntime()).toBeUndefined();
33
+ installPluginRuntime();
34
+ const runtime = getPluginRuntime();
35
+ expect(runtime).toBeDefined();
36
+ expect(runtime?.version).toBe(1);
37
+ });
38
+
39
+ test("exposes the canonical registerPlugin / hub / secrets references", () => {
40
+ installPluginRuntime();
41
+ const runtime = getPluginRuntime();
42
+ expect(runtime?.registerPlugin).toBe(registerPlugin);
43
+ expect(runtime?.assistantEventHub).toBe(assistantEventHub);
44
+ expect(runtime?.getSecureKeyAsync).toBe(getSecureKeyAsync);
45
+ });
46
+
47
+ test("plugins can read the runtime via the documented globalThis key", () => {
48
+ installPluginRuntime();
49
+ // Mirror the access pattern documented for plugin authors.
50
+ const runtime = (
51
+ globalThis as { __vellumPluginRuntime?: { version: number } }
52
+ ).__vellumPluginRuntime;
53
+ expect(runtime).toBeDefined();
54
+ expect(runtime?.version).toBe(1);
55
+ });
56
+
57
+ test("install is idempotent — repeat calls preserve the same handle", () => {
58
+ installPluginRuntime();
59
+ const first = getPluginRuntime();
60
+ installPluginRuntime();
61
+ installPluginRuntime();
62
+ expect(getPluginRuntime()).toBe(first);
63
+ });
64
+
65
+ test("getPluginRuntime returns undefined when the bridge is not installed", () => {
66
+ expect(getPluginRuntime()).toBeUndefined();
67
+ });
68
+ });