@vellumai/assistant 0.11.4-staging.1 → 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 (205) 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 +135 -75
  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__/byok-default-profile-ensure.test.ts +15 -0
  14. package/src/__tests__/channel-setup-panel-ack.test.ts +1 -1
  15. package/src/__tests__/compaction-events.test.ts +8 -10
  16. package/src/__tests__/conversation-agent-loop.test.ts +4 -1
  17. package/src/__tests__/conversation-confirmation-signals.test.ts +112 -0
  18. package/src/__tests__/conversation-notifiers-provenance.test.ts +1 -1
  19. package/src/__tests__/conversation-queue.test.ts +99 -0
  20. package/src/__tests__/conversation-routes-disk-view.test.ts +1 -1
  21. package/src/__tests__/conversation-routes-enabled-plugins.test.ts +1 -1
  22. package/src/__tests__/conversation-routes-guardian-reply.test.ts +9 -9
  23. package/src/__tests__/conversation-routes-hidden-queue.test.ts +1 -1
  24. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -1
  25. package/src/__tests__/conversation-slash-queue.test.ts +3 -0
  26. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +1 -0
  27. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +1 -0
  28. package/src/__tests__/conversation-surfaces-app-control.test.ts +1 -0
  29. package/src/__tests__/conversation-surfaces-app-open.test.ts +1 -1
  30. package/src/__tests__/conversation-surfaces-data-persist.test.ts +1 -1
  31. package/src/__tests__/conversation-surfaces-history-restored-completion.test.ts +21 -14
  32. package/src/__tests__/conversation-surfaces-queued-emit.test.ts +1 -0
  33. package/src/__tests__/conversation-surfaces-standalone-payloads.test.ts +1 -0
  34. package/src/__tests__/conversation-surfaces-standalone.test.ts +1 -0
  35. package/src/__tests__/conversation-surfaces-state-update.test.ts +1 -1
  36. package/src/__tests__/conversation-surfaces-table-action.test.ts +1 -1
  37. package/src/__tests__/conversation-surfaces-task-progress.test.ts +1 -1
  38. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +1 -1
  39. package/src/__tests__/conversation-tool-setup-attribution.test.ts +1 -1
  40. package/src/__tests__/cu-unified-flow.test.ts +1 -0
  41. package/src/__tests__/document-sync-tags.test.ts +0 -75
  42. package/src/__tests__/gateway-only-guard.test.ts +2 -5
  43. package/src/__tests__/http-user-message-parity.test.ts +1 -1
  44. package/src/__tests__/init-feature-flag-overrides.test.ts +49 -0
  45. package/src/__tests__/managed-skill-lifecycle.test.ts +7 -0
  46. package/src/__tests__/media-generate-image.test.ts +131 -21
  47. package/src/__tests__/memory-retrieval-hook.test.ts +94 -2
  48. package/src/__tests__/plugin-api-webhook-url.test.ts +10 -7
  49. package/src/__tests__/plugin-import-boundary-reverse-guard.test.ts +7 -3
  50. package/src/__tests__/proxy-approval-callback.test.ts +1 -0
  51. package/src/__tests__/qdrant-manager.test.ts +14 -1
  52. package/src/__tests__/run-due-schedules.test.ts +21 -0
  53. package/src/__tests__/scaffold-managed-skill-tool.test.ts +187 -18
  54. package/src/__tests__/schedule-routes.test.ts +23 -0
  55. package/src/__tests__/schedule-store.test.ts +17 -0
  56. package/src/__tests__/secret-ingress-http.test.ts +1 -1
  57. package/src/__tests__/send-endpoint-busy.test.ts +3 -3
  58. package/src/__tests__/starter-task-flow.test.ts +5 -4
  59. package/src/__tests__/subagent-fork-prompt-role.test.ts +1 -1
  60. package/src/__tests__/subagent-spawn-and-await.test.ts +4 -7
  61. package/src/__tests__/subagent-tool-gate-mode.test.ts +1 -1
  62. package/src/__tests__/subagent-tools.test.ts +81 -101
  63. package/src/__tests__/surface-completion-in-flight-snapshot.test.ts +1 -0
  64. package/src/__tests__/ui-choice-copy-surfaces.test.ts +1 -1
  65. package/src/__tests__/ui-shape-teaching.test.ts +33 -0
  66. package/src/__tests__/ui-visual-surface.test.ts +1 -1
  67. package/src/__tests__/ui-voice-picker-surface.test.ts +128 -0
  68. package/src/__tests__/ui-work-result-surface.test.ts +1 -1
  69. package/src/__tests__/voice-config-update.test.ts +40 -0
  70. package/src/__tests__/voice-scoped-grant-consumer.test.ts +5 -3
  71. package/src/__tests__/voice-session-bridge.test.ts +85 -29
  72. package/src/__tests__/workspace-migration-146-repair-retired-fireworks-deepseek-flash-model-id.test.ts +235 -0
  73. package/src/acp/session-manager.ts +8 -1
  74. package/src/api/surfaces.ts +12 -3
  75. package/src/calls/__tests__/voice-session-bridge.test.ts +21 -10
  76. package/src/calls/__tests__/voice-triage-escalate.test.ts +8 -0
  77. package/src/calls/call-controller.ts +16 -7
  78. package/src/calls/voice-session-bridge.ts +44 -25
  79. package/src/calls/voice-triage-escalate.ts +1 -0
  80. package/src/cli/bundled-modules.ts +29 -0
  81. package/src/cli/commands/db/repair.ts +4 -8
  82. package/src/cli/commands/domain.ts +6 -3
  83. package/src/cli/commands/email.ts +6 -3
  84. package/src/cli/commands/keys.ts +8 -3
  85. package/src/cli/commands/plugins.ts +85 -36
  86. package/src/cli/commands/schedules.ts +35 -1
  87. package/src/cli/lib/bundled-marketplace.json +1 -1
  88. package/src/config/__tests__/balanced-model-experiment.test.ts +278 -0
  89. package/src/config/assistant-feature-flags.ts +36 -15
  90. package/src/config/balanced-model-experiment.ts +35 -0
  91. package/src/config/bundled-skills/image-studio/SKILL.md +5 -4
  92. package/src/config/bundled-skills/image-studio/TOOLS.json +1 -1
  93. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +101 -0
  94. package/src/config/bundled-skills/settings/tools/navigate-settings-tab.test.ts +65 -0
  95. package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +7 -1
  96. package/src/config/bundled-skills/settings/tools/shared.ts +16 -0
  97. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +19 -1
  98. package/src/config/bundled-skills/skill-management/TOOLS.json +9 -3
  99. package/src/config/bundled-skills/subagent/SKILL.md +17 -12
  100. package/src/config/bundled-skills/subagent/TOOLS.json +4 -4
  101. package/src/config/call-site-defaults.ts +7 -0
  102. package/src/config/default-profile-catalog.ts +97 -5
  103. package/src/config/feature-flag-registry.json +11 -11
  104. package/src/config/skills.ts +9 -2
  105. package/src/daemon/__tests__/conversation-surfaces-launch.test.ts +1 -1
  106. package/src/daemon/conversation-agent-loop.ts +14 -13
  107. package/src/daemon/conversation-notifiers.ts +11 -9
  108. package/src/daemon/conversation-store.ts +4 -4
  109. package/src/daemon/conversation-surfaces.ts +54 -18
  110. package/src/daemon/conversation-tool-setup.ts +2 -4
  111. package/src/daemon/conversation.ts +81 -44
  112. package/src/daemon/doordash-steps.ts +2 -2
  113. package/src/daemon/lifecycle.ts +14 -1
  114. package/src/daemon/process-message.ts +0 -23
  115. package/src/daemon/windows-compiled-entry.ts +4 -0
  116. package/src/documents/document-store.ts +5 -235
  117. package/src/hooks/types.ts +5 -0
  118. package/src/ipc/gateway-flag-listener.ts +17 -3
  119. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +118 -0
  120. package/src/live-voice/live-voice-manager.ts +16 -3
  121. package/src/live-voice/live-voice-session.ts +63 -9
  122. package/src/live-voice/windows-compiled-live-voice.ts +4 -0
  123. package/src/monitoring/control.ts +1 -0
  124. package/src/monitoring/db-integrity-sample.ts +4 -5
  125. package/src/permissions/prompter.ts +1 -5
  126. package/src/persistence/conversation-queries.ts +66 -16
  127. package/src/persistence/embeddings/qdrant-manager.ts +84 -49
  128. package/src/persistence/migrations/360-add-document-workspace-path.ts +5 -14
  129. package/src/persistence/schema/documents.ts +4 -4
  130. package/src/plugin-api/constants.ts +8 -0
  131. package/src/plugin-api/index.ts +5 -1
  132. package/src/plugin-api/vision-support.test.ts +1 -1
  133. package/src/plugin-api/webhook-url.ts +13 -11
  134. package/src/plugins/defaults/main.ts +6 -7
  135. package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +77 -0
  136. package/src/plugins/defaults/memory/graph/conversation-graph-memory.ts +24 -6
  137. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +53 -5
  138. package/src/plugins/defaults/memory/memory-retrospective-job.ts +4 -4
  139. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +18 -0
  140. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +66 -1
  141. package/src/plugins/defaults/memory/v3/injector.ts +8 -0
  142. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +23 -9
  143. package/src/plugins/defaults/memory/worker-control.ts +1 -0
  144. package/src/plugins/defaults/worker-entrypoints.ts +3 -0
  145. package/src/plugins/mtime-cache.ts +17 -0
  146. package/src/prompts/templates/system-sections.ts +0 -7
  147. package/src/providers/__tests__/context-overflow-error.test.ts +24 -0
  148. package/src/providers/__tests__/retry-callsite.test.ts +20 -0
  149. package/src/providers/model-catalog.ts +3 -3
  150. package/src/providers/model-intents.ts +2 -2
  151. package/src/providers/openai/chat-completions-provider.ts +16 -7
  152. package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +11 -6
  153. package/src/providers/speech-to-text/deepgram-flux-realtime.ts +9 -49
  154. package/src/routes/control.ts +1 -0
  155. package/src/routes/route-host-client.ts +1 -0
  156. package/src/runtime/AGENTS.md +16 -17
  157. package/src/runtime/agent-wake.ts +15 -12
  158. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +170 -1
  159. package/src/runtime/routes/__tests__/schedule-routes-disarm-reason.test.ts +215 -0
  160. package/src/runtime/routes/conversation-list-routes.ts +54 -22
  161. package/src/runtime/routes/conversation-management-routes.ts +2 -3
  162. package/src/runtime/routes/conversation-routes.ts +11 -13
  163. package/src/runtime/routes/documents-routes.ts +3 -222
  164. package/src/runtime/routes/playground/__tests__/inject-failures.test.ts +2 -0
  165. package/src/runtime/routes/playground/__tests__/reset-circuit.test.ts +3 -0
  166. package/src/runtime/routes/playground/inject-failures.ts +2 -2
  167. package/src/runtime/routes/playground/reset-circuit.ts +1 -1
  168. package/src/runtime/routes/schedule-routes.ts +94 -7
  169. package/src/runtime/routes/workspace-routes.ts +0 -9
  170. package/src/runtime/routes/workspace-utils.ts +3 -13
  171. package/src/runtime/services/conversation-serializer.ts +7 -2
  172. package/src/schedule/__tests__/plugin-schedule-declarations.test.ts +68 -5
  173. package/src/schedule/__tests__/plugin-schedule-reconciler.test.ts +81 -0
  174. package/src/schedule/plugin-schedule-availability.ts +58 -0
  175. package/src/schedule/plugin-schedule-declarations.ts +23 -27
  176. package/src/schedule/plugin-schedule-reconciler.ts +12 -3
  177. package/src/schedule/schedule-store.ts +5 -1
  178. package/src/schedule/scheduler.ts +9 -4
  179. package/src/schedule/worker-control.ts +1 -0
  180. package/src/subagent/__tests__/consult-prompt.test.ts +26 -15
  181. package/src/subagent/consult-context.ts +11 -11
  182. package/src/subagent/consult-prompt.ts +26 -35
  183. package/src/subagent/manager.ts +20 -37
  184. package/src/subagent/notify.ts +7 -1
  185. package/src/subagent/types.ts +8 -6
  186. package/src/tools/acp/spawn.ts +6 -4
  187. package/src/tools/skills/scaffold-managed.ts +25 -7
  188. package/src/tools/subagent/spawn.ts +28 -88
  189. package/src/tools/ui-surface/surface-shape-docs.ts +12 -1
  190. package/src/tts/__tests__/reasoning-tag-filter.test.ts +15 -0
  191. package/src/tts/reasoning-tag-filter.ts +11 -32
  192. package/src/util/__tests__/worker-process-command.test.ts +37 -0
  193. package/src/util/logger.ts +16 -0
  194. package/src/util/think-tag-stream.ts +95 -0
  195. package/src/util/worker-process.ts +37 -4
  196. package/src/windows-compiled-cli.ts +32 -0
  197. package/src/windows-compiled-entry.ts +4 -0
  198. package/src/windows-compiled-logger.ts +6 -0
  199. package/src/windows-compiled-worker-entry.ts +29 -0
  200. package/src/workspace/byok-default-profile-ensure.ts +15 -6
  201. package/src/workspace/migrations/146-repair-retired-fireworks-deepseek-flash-model-id.ts +195 -0
  202. package/src/workspace/migrations/registry.ts +2 -0
  203. package/src/__tests__/document-workspace-file.test.ts +0 -467
  204. package/src/subagent/__tests__/consult-transcript.test.ts +0 -184
  205. package/src/subagent/consult-transcript.ts +0 -90
@@ -276,6 +276,46 @@ describe("voice_config_update — tts_voice_id (managed/vellum active)", () => {
276
276
  expect(result.content).not.toContain("broadcast");
277
277
  });
278
278
 
279
+ test("hints the inline picker for the next managed voice change", async () => {
280
+ makeVellumActive();
281
+
282
+ const result = await run(
283
+ { setting: "tts_voice_id", value: "aura-2-zeus-en" },
284
+ makeContext(),
285
+ );
286
+
287
+ expect(result.isError).toBe(false);
288
+ expect(result.content).toStartWith("Vellum managed voice updated to");
289
+ expect(result.content).toContain(
290
+ 'ui_show { surface_type: "voice_picker", data: {} }',
291
+ );
292
+ });
293
+
294
+ test("does not hint the picker when the voice is not managed", async () => {
295
+ writeConfig({ services: { tts: { provider: "elevenlabs" } } });
296
+ invalidateConfigCache();
297
+
298
+ const result = await run(
299
+ { setting: "tts_voice_id", value: "abc123" },
300
+ makeContext(),
301
+ );
302
+
303
+ expect(result.isError).toBe(false);
304
+ expect(result.content).not.toContain("voice_picker");
305
+ });
306
+
307
+ test("does not hint the picker for a non-voice setting", async () => {
308
+ makeVellumActive();
309
+
310
+ const result = await run(
311
+ { setting: "conversation_timeout", value: 30 },
312
+ makeContext(),
313
+ );
314
+
315
+ expect(result.isError).toBe(false);
316
+ expect(result.content).not.toContain("voice_picker");
317
+ });
318
+
279
319
  test("rejects a value with characters invalid for any voice id", async () => {
280
320
  makeVellumActive();
281
321
 
@@ -89,7 +89,8 @@ const CALL_SESSION_ID = "call-session-voice-grant-test";
89
89
 
90
90
  /**
91
91
  * Create a mock session that, when runAgentLoop is called, emits a
92
- * confirmation_request through the updateClient callback before completing.
92
+ * confirmation_request through the registered event observer before
93
+ * completing.
93
94
  */
94
95
  function createMockSession(opts?: {
95
96
  confirmationRequestId?: string;
@@ -118,8 +119,9 @@ function createMockSession(opts?: {
118
119
  currentRequestId: requestId,
119
120
  abort: () => {},
120
121
  persistUserMessage: async () => ({ id: "msg-1", deduplicated: false }),
121
- updateClient: (cb: (msg: AssistantEvent) => void, _reset?: boolean) => {
122
+ addEventObserver: (cb: (msg: AssistantEvent) => void) => {
122
123
  clientCallback = cb;
124
+ return () => {};
123
125
  },
124
126
  handleConfirmationResponse: (
125
127
  reqId: string,
@@ -138,7 +140,7 @@ function createMockSession(opts?: {
138
140
  _messageId: string,
139
141
  options?: { onEvent?: (msg: AssistantEvent) => void },
140
142
  ) => {
141
- // Emit a confirmation_request through the client callback
143
+ // Emit a confirmation_request through the event observer
142
144
  if (clientCallback) {
143
145
  clientCallback({
144
146
  type: "confirmation_request",
@@ -61,7 +61,7 @@ function makeStreamingSession(events: AssistantEvent[]): Conversation {
61
61
  setTurnChannelContext: () => {},
62
62
  setTurnInterfaceContext: () => {},
63
63
  setVoiceCallControlPrompt: () => {},
64
- updateClient: () => {},
64
+ addEventObserver: () => () => {},
65
65
  ensureActorScopedHistory: async () => {},
66
66
  runAgentLoop: async (
67
67
  _content: string,
@@ -116,7 +116,7 @@ function makePersistingStreamingSession(
116
116
  turnInterfaceContext = ctx;
117
117
  },
118
118
  setVoiceCallControlPrompt: () => {},
119
- updateClient: () => {},
119
+ addEventObserver: () => () => {},
120
120
  ensureActorScopedHistory: async () => {},
121
121
  runAgentLoop: async (
122
122
  _content: string,
@@ -254,7 +254,7 @@ describe("voice-session-bridge", () => {
254
254
  setTurnChannelContext: () => {},
255
255
  setTurnInterfaceContext: () => {},
256
256
  setVoiceCallControlPrompt: () => {},
257
- updateClient: () => {},
257
+ addEventObserver: () => () => {},
258
258
  ensureActorScopedHistory: async () => {},
259
259
  runAgentLoop: async () => {
260
260
  await new Promise((r) => setTimeout(r, 200));
@@ -321,6 +321,51 @@ describe("voice-session-bridge", () => {
321
321
  expect(capturedOptions?.callSite).toBe("callAgent");
322
322
  });
323
323
 
324
+ test("startVoiceTurn declares the turn interactive so approval prompts are raised for its observer", async () => {
325
+ // A caller is on the line. If the turn ran non-interactive the permission
326
+ // checker would deny an approval-gated tool before any prompt existed, and
327
+ // the bridge's approval observer (auto-resolve for a non-guardian caller, a
328
+ // real card for the guardian) would never get to decide. Presence is per
329
+ // turn, so the bridge has to say so.
330
+ const conversation = createConversation("voice bridge interactive test");
331
+ const events: AssistantEvent[] = [
332
+ { type: "message_complete", conversationId: conversation.id },
333
+ ];
334
+
335
+ let capturedOptions: Record<string, unknown> | undefined;
336
+ const session = {
337
+ ...makeStreamingSession(events),
338
+ runAgentLoop: async (
339
+ _content: string,
340
+ _messageId: string,
341
+ options?: Record<string, unknown>,
342
+ ) => {
343
+ capturedOptions = options;
344
+ const onEvent =
345
+ (options as { onEvent?: (msg: AssistantEvent) => void })?.onEvent ??
346
+ (() => {});
347
+ for (const event of events) {
348
+ onEvent(event);
349
+ }
350
+ },
351
+ } as unknown as Conversation;
352
+
353
+ injectDeps(() => session);
354
+
355
+ await startVoiceTurn({
356
+ conversationId: conversation.id,
357
+ content: "Hello",
358
+ isInbound: true,
359
+ onTextDelta: () => {},
360
+ onComplete: () => {},
361
+ onError: () => {},
362
+ });
363
+
364
+ await new Promise((r) => setTimeout(r, 50));
365
+
366
+ expect(capturedOptions?.isInteractive).toBe(true);
367
+ });
368
+
324
369
  test("external AbortSignal triggers turn abort", async () => {
325
370
  const conversation = createConversation("voice bridge signal test");
326
371
  let abortCalled = false;
@@ -339,7 +384,7 @@ describe("voice-session-bridge", () => {
339
384
  setTurnChannelContext: () => {},
340
385
  setTurnInterfaceContext: () => {},
341
386
  setVoiceCallControlPrompt: () => {},
342
- updateClient: () => {},
387
+ addEventObserver: () => () => {},
343
388
  ensureActorScopedHistory: async () => {},
344
389
  runAgentLoop: async () => {
345
390
  await new Promise((r) => setTimeout(r, 200));
@@ -704,13 +749,14 @@ describe("voice-session-bridge", () => {
704
749
  setTurnChannelContext: () => {},
705
750
  setTurnInterfaceContext: () => {},
706
751
  setVoiceCallControlPrompt: () => {},
707
- updateClient: (handler: (msg: AssistantEvent) => void) => {
752
+ addEventObserver: (handler: (msg: AssistantEvent) => void) => {
708
753
  clientHandler = handler;
754
+ return () => {};
709
755
  },
710
756
  ensureActorScopedHistory: async () => {},
711
757
  runAgentLoop: async () => {
712
- // Simulate the prompter emitting a confirmation_request via the
713
- // updateClient callback (this is how the real prompter works).
758
+ // Simulate the conversation emitting a confirmation_request to its
759
+ // event observer (this is how the real conversation fans out).
714
760
  clientHandler({
715
761
  type: "confirmation_request",
716
762
  requestId: "req-voice-1",
@@ -796,8 +842,9 @@ describe("voice-session-bridge", () => {
796
842
  setTurnChannelContext: () => {},
797
843
  setTurnInterfaceContext: () => {},
798
844
  setVoiceCallControlPrompt: () => {},
799
- updateClient: (handler: (msg: AssistantEvent) => void) => {
845
+ addEventObserver: (handler: (msg: AssistantEvent) => void) => {
800
846
  clientHandler = handler;
847
+ return () => {};
801
848
  },
802
849
  ensureActorScopedHistory: async () => {},
803
850
  runAgentLoop: async () => {
@@ -879,8 +926,9 @@ describe("voice-session-bridge", () => {
879
926
  setTurnChannelContext: () => {},
880
927
  setTurnInterfaceContext: () => {},
881
928
  setVoiceCallControlPrompt: () => {},
882
- updateClient: (handler: (msg: AssistantEvent) => void) => {
929
+ addEventObserver: (handler: (msg: AssistantEvent) => void) => {
883
930
  clientHandler = handler;
931
+ return () => {};
884
932
  },
885
933
  ensureActorScopedHistory: async () => {},
886
934
  runAgentLoop: async () => {
@@ -946,8 +994,9 @@ describe("voice-session-bridge", () => {
946
994
  setTurnChannelContext: () => {},
947
995
  setTurnInterfaceContext: () => {},
948
996
  setVoiceCallControlPrompt: () => {},
949
- updateClient: (handler: (msg: AssistantEvent) => void) => {
997
+ addEventObserver: (handler: (msg: AssistantEvent) => void) => {
950
998
  clientHandler = handler;
999
+ return () => {};
951
1000
  },
952
1001
  ensureActorScopedHistory: async () => {},
953
1002
  runAgentLoop: async () => {
@@ -1009,8 +1058,9 @@ describe("voice-session-bridge", () => {
1009
1058
  setTurnChannelContext: () => {},
1010
1059
  setTurnInterfaceContext: () => {},
1011
1060
  setVoiceCallControlPrompt: () => {},
1012
- updateClient: (handler: (msg: AssistantEvent) => void) => {
1061
+ addEventObserver: (handler: (msg: AssistantEvent) => void) => {
1013
1062
  clientHandler = handler;
1063
+ return () => {};
1014
1064
  },
1015
1065
  ensureActorScopedHistory: async () => {},
1016
1066
  runAgentLoop: async () => {
@@ -1061,13 +1111,20 @@ describe("voice-session-bridge", () => {
1061
1111
  // broadcast in voice-session-bridge.ts's confirmation_request branch. The
1062
1112
  // fake's handleConfirmationResponse mirrors production's synchronous
1063
1113
  // `interaction_resolved` broadcast so the wire order is observable through
1064
- // the event hub, which serializes publishes in call order.
1114
+ // the event hub, which serializes publishes in call order. Its `emit`
1115
+ // mirrors a real conversation's: the sink (the hub) delivers first, then
1116
+ // observers run, so the bridge's resolution lands after the request.
1065
1117
  function makeConfirmationOrderingSession(
1066
1118
  conversationId: string,
1067
1119
  requestId: string,
1068
1120
  ): Conversation {
1069
1121
  let clientHandler: (msg: AssistantEvent) => void = () => {};
1122
+ const emit = (msg: AssistantEvent) => {
1123
+ broadcastMessage(msg);
1124
+ clientHandler(msg);
1125
+ };
1070
1126
  return {
1127
+ emit,
1071
1128
  isProcessing: () => false,
1072
1129
  persistUserMessage: async () => ({
1073
1130
  id: "test-msg-id",
@@ -1080,12 +1137,13 @@ describe("voice-session-bridge", () => {
1080
1137
  setTurnChannelContext: () => {},
1081
1138
  setTurnInterfaceContext: () => {},
1082
1139
  setVoiceCallControlPrompt: () => {},
1083
- updateClient: (handler: (msg: AssistantEvent) => void) => {
1140
+ addEventObserver: (handler: (msg: AssistantEvent) => void) => {
1084
1141
  clientHandler = handler;
1142
+ return () => {};
1085
1143
  },
1086
1144
  ensureActorScopedHistory: async () => {},
1087
1145
  runAgentLoop: async () => {
1088
- clientHandler({
1146
+ emit({
1089
1147
  type: "confirmation_request",
1090
1148
  requestId,
1091
1149
  toolName: "host_bash",
@@ -1222,8 +1280,9 @@ describe("voice-session-bridge", () => {
1222
1280
  setTurnChannelContext: () => {},
1223
1281
  setTurnInterfaceContext: () => {},
1224
1282
  setVoiceCallControlPrompt: () => {},
1225
- updateClient: (handler: (msg: AssistantEvent) => void) => {
1283
+ addEventObserver: (handler: (msg: AssistantEvent) => void) => {
1226
1284
  clientHandler = handler;
1285
+ return () => {};
1227
1286
  },
1228
1287
  ensureActorScopedHistory: async () => {},
1229
1288
  runAgentLoop: async () => {
@@ -1290,7 +1349,7 @@ describe("voice-session-bridge", () => {
1290
1349
  setTurnChannelContext: () => {},
1291
1350
  setTurnInterfaceContext: () => {},
1292
1351
  setVoiceCallControlPrompt: () => {},
1293
- updateClient: () => {},
1352
+ addEventObserver: () => () => {},
1294
1353
  ensureActorScopedHistory: async () => {},
1295
1354
  runAgentLoop: async () => {},
1296
1355
  handleConfirmationResponse: () => {},
@@ -1349,7 +1408,7 @@ describe("voice-session-bridge", () => {
1349
1408
  setTurnChannelContext: recordLast("setTurnChannelContext"),
1350
1409
  setTurnInterfaceContext: recordLast("setTurnInterfaceContext"),
1351
1410
  setVoiceCallControlPrompt: recordLast("setVoiceCallControlPrompt"),
1352
- updateClient: () => {},
1411
+ addEventObserver: () => () => {},
1353
1412
  ensureActorScopedHistory: async () => {},
1354
1413
  runAgentLoop: async () => {},
1355
1414
  handleConfirmationResponse: () => {},
@@ -1391,15 +1450,12 @@ describe("voice-session-bridge", () => {
1391
1450
  expect(session.forcePromptSideEffects).toBe(false);
1392
1451
  });
1393
1452
 
1394
- test("cleanup on early persistUserMessage throw does not detach prior client sender", async () => {
1453
+ test("cleanup on early persistUserMessage throw does not detach a prior turn's observer", async () => {
1395
1454
  const conversation = createConversation(
1396
1455
  "voice bridge sender detach guard test",
1397
1456
  );
1398
1457
 
1399
- const updateClientCalls: Array<{
1400
- callback: unknown;
1401
- replace: boolean | undefined;
1402
- }> = [];
1458
+ const addEventObserverCalls: unknown[] = [];
1403
1459
 
1404
1460
  const session = {
1405
1461
  isProcessing: () => false,
@@ -1415,8 +1471,9 @@ describe("voice-session-bridge", () => {
1415
1471
  setTurnChannelContext: () => {},
1416
1472
  setTurnInterfaceContext: () => {},
1417
1473
  setVoiceCallControlPrompt: () => {},
1418
- updateClient: (callback: unknown, replace?: boolean) => {
1419
- updateClientCalls.push({ callback, replace });
1474
+ addEventObserver: (observer: unknown) => {
1475
+ addEventObserverCalls.push(observer);
1476
+ return () => {};
1420
1477
  },
1421
1478
  ensureActorScopedHistory: async () => {},
1422
1479
  runAgentLoop: async () => {},
@@ -1451,10 +1508,9 @@ describe("voice-session-bridge", () => {
1451
1508
  expect(caught?.message).toBe(
1452
1509
  "persist failed before bridge installed callback",
1453
1510
  );
1454
- // The bridge never reached its `conversation.updateClient(...)` install
1455
- // site, so cleanup must not touch updateClient — otherwise it would
1456
- // detach a sender installed by a prior turn on the same conversation.
1457
- expect(updateClientCalls).toEqual([]);
1511
+ // The bridge never reached its `conversation.addEventObserver(...)`
1512
+ // install site, so no observer was registered for cleanup to detach.
1513
+ expect(addEventObserverCalls).toEqual([]);
1458
1514
  });
1459
1515
 
1460
1516
  test("pre-aborted signal triggers immediate abort", async () => {
@@ -1475,7 +1531,7 @@ describe("voice-session-bridge", () => {
1475
1531
  setTurnChannelContext: () => {},
1476
1532
  setTurnInterfaceContext: () => {},
1477
1533
  setVoiceCallControlPrompt: () => {},
1478
- updateClient: () => {},
1534
+ addEventObserver: () => () => {},
1479
1535
  ensureActorScopedHistory: async () => {},
1480
1536
  runAgentLoop: async () => {
1481
1537
  await new Promise((r) => setTimeout(r, 200));
@@ -0,0 +1,235 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { Database } from "bun:sqlite";
11
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
12
+
13
+ import { repairRetiredFireworksDeepseekFlashModelIdMigration } from "../workspace/migrations/146-repair-retired-fireworks-deepseek-flash-model-id.js";
14
+ import { WORKSPACE_MIGRATIONS } from "../workspace/migrations/registry.js";
15
+ import { assertNotLiveDb } from "./assert-not-live-db.js";
16
+
17
+ const STALE = "accounts/fireworks/models/deepseek-v4-flash";
18
+ const REPLACEMENT = "accounts/fireworks/models/deepseek-v4-flash-0731";
19
+
20
+ let workspaceDir: string;
21
+
22
+ function freshWorkspace(): void {
23
+ workspaceDir = join(
24
+ tmpdir(),
25
+ `vellum-migration-146-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
26
+ );
27
+ mkdirSync(workspaceDir, { recursive: true });
28
+ }
29
+
30
+ function writeConfig(data: Record<string, unknown>): void {
31
+ writeFileSync(
32
+ join(workspaceDir, "config.json"),
33
+ JSON.stringify(data, null, 2) + "\n",
34
+ );
35
+ }
36
+
37
+ function readConfig(): Record<string, unknown> {
38
+ return JSON.parse(readFileSync(join(workspaceDir, "config.json"), "utf-8"));
39
+ }
40
+
41
+ function seedRows(rows: Array<{ name: string; provider: string }>): void {
42
+ mkdirSync(join(workspaceDir, "data", "db"), { recursive: true });
43
+ const db = new Database(join(workspaceDir, "data", "db", "assistant.db"));
44
+ db.run(`CREATE TABLE IF NOT EXISTS provider_connections (
45
+ name TEXT PRIMARY KEY,
46
+ provider TEXT NOT NULL,
47
+ auth TEXT NOT NULL,
48
+ created_at INTEGER NOT NULL,
49
+ updated_at INTEGER NOT NULL
50
+ )`);
51
+ for (const row of rows) {
52
+ db.query(
53
+ `INSERT INTO provider_connections (name, provider, auth, created_at, updated_at) VALUES (?, ?, '{"type":"api_key"}', 1, 1)`,
54
+ ).run(row.name, row.provider);
55
+ }
56
+ db.close();
57
+ }
58
+
59
+ beforeEach(() => {
60
+ freshWorkspace();
61
+ });
62
+
63
+ afterEach(() => {
64
+ if (existsSync(workspaceDir)) {
65
+ assertNotLiveDb(workspaceDir);
66
+ rmSync(workspaceDir, { recursive: true, force: true });
67
+ }
68
+ });
69
+
70
+ describe("146-repair-retired-fireworks-deepseek-flash-model-id migration", () => {
71
+ test("has correct migration id and is registered", () => {
72
+ expect(repairRetiredFireworksDeepseekFlashModelIdMigration.id).toBe(
73
+ "146-repair-retired-fireworks-deepseek-flash-model-id",
74
+ );
75
+ expect(WORKSPACE_MIGRATIONS.map((m) => m.id)).toContain(
76
+ "146-repair-retired-fireworks-deepseek-flash-model-id",
77
+ );
78
+ });
79
+
80
+ test("repairs the stale ID in default, call sites, and profiles", () => {
81
+ writeConfig({
82
+ llm: {
83
+ default: { provider: "fireworks", model: STALE },
84
+ callSites: {
85
+ recall: { model: STALE, maxTokens: 4096 },
86
+ heartbeat: { model: `${STALE}-tuned` },
87
+ malformed: STALE,
88
+ },
89
+ profiles: {
90
+ "cost-optimized": { provider: "vellum", model: STALE },
91
+ legacy: { model: STALE },
92
+ },
93
+ },
94
+ });
95
+
96
+ repairRetiredFireworksDeepseekFlashModelIdMigration.run(workspaceDir);
97
+
98
+ const llm = readConfig().llm as Record<string, any>;
99
+ expect(llm.default.model).toBe(REPLACEMENT);
100
+ expect(llm.callSites.recall.model).toBe(REPLACEMENT);
101
+ expect(llm.callSites.recall.maxTokens).toBe(4096);
102
+ // Non-exact matches and malformed leaves are untouched. The dated
103
+ // replacement itself extends the stale ID, so the exact match must not
104
+ // fire on already-repaired leaves.
105
+ expect(llm.callSites.heartbeat.model).toBe(`${STALE}-tuned`);
106
+ expect(llm.callSites.malformed).toBe(STALE);
107
+ // Managed profiles stamped provider "vellum" carry Fireworks model IDs.
108
+ expect(llm.profiles["cost-optimized"].model).toBe(REPLACEMENT);
109
+ expect(llm.profiles.legacy.model).toBe(REPLACEMENT);
110
+ });
111
+
112
+ test("repairs entry-bound fragments whose row kind is fireworks or vellum", () => {
113
+ seedRows([
114
+ { name: "my-fireworks", provider: "fireworks" },
115
+ { name: "managed-alt", provider: "vellum" },
116
+ ]);
117
+ writeConfig({
118
+ llm: {
119
+ profiles: {
120
+ bound: { provider: "my-fireworks", model: STALE },
121
+ managed: { provider: "managed-alt", model: STALE },
122
+ },
123
+ },
124
+ });
125
+
126
+ repairRetiredFireworksDeepseekFlashModelIdMigration.run(workspaceDir);
127
+
128
+ const llm = readConfig().llm as Record<string, any>;
129
+ expect(llm.profiles.bound.model).toBe(REPLACEMENT);
130
+ expect(llm.profiles.managed.model).toBe(REPLACEMENT);
131
+ });
132
+
133
+ test("leaves fragments with an explicit other provider untouched", () => {
134
+ seedRows([{ name: "byo-compat", provider: "openai-compatible" }]);
135
+ writeConfig({
136
+ llm: {
137
+ default: { provider: "openai-compatible", model: STALE },
138
+ profiles: {
139
+ byo: { provider: "openai-compatible", model: STALE },
140
+ // Entry-bound profile whose row kind is not repairable.
141
+ compatBound: { provider: "byo-compat", model: STALE },
142
+ // Entry name with no row: dangling, so nothing proves it is a
143
+ // fireworks-kind route.
144
+ dangling: { provider: "ghost", model: STALE },
145
+ },
146
+ },
147
+ });
148
+
149
+ repairRetiredFireworksDeepseekFlashModelIdMigration.run(workspaceDir);
150
+
151
+ const llm = readConfig().llm as Record<string, any>;
152
+ expect(llm.default.model).toBe(STALE);
153
+ expect(llm.profiles.byo.model).toBe(STALE);
154
+ expect(llm.profiles.compatBound.model).toBe(STALE);
155
+ expect(llm.profiles.dangling.model).toBe(STALE);
156
+ });
157
+
158
+ test("leaves entry-name providers untouched when no DB file exists", () => {
159
+ writeConfig({
160
+ llm: {
161
+ profiles: { bound: { provider: "my-fireworks", model: STALE } },
162
+ },
163
+ });
164
+
165
+ repairRetiredFireworksDeepseekFlashModelIdMigration.run(workspaceDir);
166
+
167
+ const llm = readConfig().llm as Record<string, any>;
168
+ expect(llm.profiles.bound.model).toBe(STALE);
169
+ });
170
+
171
+ test("throws when an entry-name provider needs rows and the DB is unreadable", () => {
172
+ // A DB file without the provider_connections table is unqueryable, so
173
+ // the run must fail (and retry later) instead of skipping the profile.
174
+ mkdirSync(join(workspaceDir, "data", "db"), { recursive: true });
175
+ writeFileSync(join(workspaceDir, "data", "db", "assistant.db"), "");
176
+ writeConfig({
177
+ llm: {
178
+ profiles: { bound: { provider: "my-fireworks", model: STALE } },
179
+ },
180
+ });
181
+
182
+ expect(() =>
183
+ repairRetiredFireworksDeepseekFlashModelIdMigration.run(workspaceDir),
184
+ ).toThrow();
185
+ const llm = readConfig().llm as Record<string, any>;
186
+ expect(llm.profiles.bound.model).toBe(STALE);
187
+
188
+ // Vendor and absent providers never need the rows, so the same broken
189
+ // DB does not block their repair.
190
+ writeConfig({
191
+ llm: {
192
+ default: { provider: "fireworks", model: STALE },
193
+ profiles: { legacy: { model: STALE } },
194
+ },
195
+ });
196
+ repairRetiredFireworksDeepseekFlashModelIdMigration.run(workspaceDir);
197
+ const repaired = readConfig().llm as Record<string, any>;
198
+ expect(repaired.default.model).toBe(REPLACEMENT);
199
+ expect(repaired.profiles.legacy.model).toBe(REPLACEMENT);
200
+ });
201
+
202
+ test("is idempotent and a no-op without the stale ID", () => {
203
+ writeConfig({
204
+ llm: {
205
+ default: { provider: "fireworks", model: STALE },
206
+ profiles: { fine: { provider: "fireworks", model: REPLACEMENT } },
207
+ },
208
+ });
209
+
210
+ repairRetiredFireworksDeepseekFlashModelIdMigration.run(workspaceDir);
211
+ const first = readConfig();
212
+ repairRetiredFireworksDeepseekFlashModelIdMigration.run(workspaceDir);
213
+ expect(readConfig()).toEqual(first);
214
+
215
+ const llm = first.llm as Record<string, any>;
216
+ expect(llm.default.model).toBe(REPLACEMENT);
217
+ expect(llm.profiles.fine.model).toBe(REPLACEMENT);
218
+ });
219
+
220
+ test("handles missing config, missing llm block, and invalid JSON", () => {
221
+ // No config.json at all.
222
+ expect(() =>
223
+ repairRetiredFireworksDeepseekFlashModelIdMigration.run(workspaceDir),
224
+ ).not.toThrow();
225
+
226
+ writeConfig({ theme: "dark" });
227
+ repairRetiredFireworksDeepseekFlashModelIdMigration.run(workspaceDir);
228
+ expect(readConfig()).toEqual({ theme: "dark" });
229
+
230
+ writeFileSync(join(workspaceDir, "config.json"), "{not json");
231
+ expect(() =>
232
+ repairRetiredFireworksDeepseekFlashModelIdMigration.run(workspaceDir),
233
+ ).not.toThrow();
234
+ });
235
+ });
@@ -1224,9 +1224,14 @@ export class AcpSessionManager {
1224
1224
  );
1225
1225
  return;
1226
1226
  }
1227
+ // The notification turn streams to whoever is watching (the parent's sink is
1228
+ // its client hub), but it is machine-injected with no human asserted to be
1229
+ // present, so it runs non-interactive: a tool that would need approval is
1230
+ // denied rather than left waiting on a prompt nobody may answer.
1227
1231
  const enqueueResult = parentConversation.enqueueMessage({
1228
1232
  content: message,
1229
1233
  metadata: { acpNotification },
1234
+ isInteractive: false,
1230
1235
  });
1231
1236
  if (enqueueResult.queued || enqueueResult.rejected) {
1232
1237
  return;
@@ -1234,7 +1239,9 @@ export class AcpSessionManager {
1234
1239
  parentConversation
1235
1240
  .persistUserMessage({ content: message, metadata: { acpNotification } })
1236
1241
  .then(({ id: messageId }) =>
1237
- parentConversation.runAgentLoop(message, messageId),
1242
+ parentConversation.runAgentLoop(message, messageId, {
1243
+ isInteractive: false,
1244
+ }),
1238
1245
  )
1239
1246
  .catch((err) => {
1240
1247
  log.error(
@@ -488,6 +488,11 @@ export const WorkResultItemSchema = z.object({
488
488
  status: tolerantString(),
489
489
  tone: WorkResultToneSchema.optional().catch(undefined),
490
490
  metadata: recordArray(WorkResultMetadataSchema).optional().catch(undefined),
491
+ /**
492
+ * Makes the item row a link. Clients follow an in-app path
493
+ * (`/assistant/skills/<skillId>?tab=history`) in place and open an http(s)
494
+ * URL externally; anything else renders as an unlinked row.
495
+ */
491
496
  href: tolerantString(),
492
497
  });
493
498
  export type WorkResultItem = z.infer<typeof WorkResultItemSchema>;
@@ -591,6 +596,7 @@ export const SURFACE_TYPES = [
591
596
  "skill_card",
592
597
  "call_summary",
593
598
  "visual",
599
+ "voice_picker",
594
600
  ] as const;
595
601
 
596
602
  export const SurfaceTypeSchema = z.enum(SURFACE_TYPES);
@@ -662,9 +668,10 @@ export type SurfaceData =
662
668
  * and serves verbatim but whose shape it does not model, which is why they
663
669
  * are absent from the renderable `SurfaceData` union: `channel_setup` (a
664
670
  * side-effect command forwarded to the setup panel), `task_preferences` (a
665
- * fixed grid that reads no data), and `skill_card` / `call_summary` (cards
666
- * the daemon appends to history directly — the memory retrospective and a
667
- * call summary — and whose data shape is owned by their client renderers).
671
+ * fixed grid that reads no data), `voice_picker` (a settings card that reads
672
+ * its own config), and `skill_card` / `call_summary` (cards the daemon appends
673
+ * to history directly, the memory retrospective and a call summary, whose
674
+ * data shape is owned by their client renderers).
668
675
  */
669
676
  export interface SurfaceDataByType {
670
677
  card: CardSurfaceData;
@@ -684,6 +691,7 @@ export interface SurfaceDataByType {
684
691
  skill_card: Record<string, unknown>;
685
692
  call_summary: Record<string, unknown>;
686
693
  visual: VisualSurfaceData;
694
+ voice_picker: Record<string, unknown>;
687
695
  }
688
696
 
689
697
  /** Any surface `data` payload, including the opaque (non-renderable) types. */
@@ -717,6 +725,7 @@ export const SURFACE_DATA_SCHEMAS: {
717
725
  skill_card: z.record(z.string(), z.unknown()),
718
726
  call_summary: z.record(z.string(), z.unknown()),
719
727
  visual: VisualSurfaceDataSchema,
728
+ voice_picker: z.record(z.string(), z.unknown()),
720
729
  };
721
730
 
722
731
  /**