@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
@@ -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));
@@ -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>;
@@ -72,6 +72,7 @@ mock.module("../../persistence/conversation-crud.js", () => ({
72
72
 
73
73
  import { setConfig } from "../../__tests__/helpers/set-config.js";
74
74
  import { ABORT_WATCHDOG_MS } from "../../daemon/abort-watchdog.js";
75
+ import { VOICE_ESCALATION_CONTINUATION_MESSAGE_KIND } from "../../plugin-api/constants.js";
75
76
  import { assistantEventHub } from "../../runtime/assistant-event-hub.js";
76
77
  import {
77
78
  CALL_OPENING_MARKER,
@@ -125,7 +126,7 @@ interface FakeConversation {
125
126
  metadata?: Record<string, unknown>;
126
127
  }) => Promise<{ id: string }>;
127
128
  workingDir: string;
128
- updateClient: (cb: unknown, reset?: boolean) => void;
129
+ addEventObserver: (observer: unknown) => () => void;
129
130
  handleConfirmationResponse: (
130
131
  requestId: string,
131
132
  decision: string,
@@ -191,13 +192,15 @@ function makeFakeConversation(opts: {
191
192
  opts.onPersist?.(persistCount);
192
193
  return { id: `msg-${persistCount}` };
193
194
  },
194
- // The install (reset falsy) / reset (reset true) pair marks a turn
195
- // taking ownership of the conversation vs a turn's cleanup releasing it.
196
- updateClient: (cb, reset) => {
197
- opts.events?.push(reset ? "client:reset" : "client:install");
198
- if (reset !== true) {
199
- clientCallback = cb as (msg: unknown) => Promise<void>;
200
- }
195
+ // The install / reset pair marks a turn taking ownership of the
196
+ // conversation (observer registered) vs a turn's cleanup releasing it
197
+ // (the returned disposer invoked).
198
+ addEventObserver: (observer) => {
199
+ opts.events?.push("client:install");
200
+ clientCallback = observer as (msg: unknown) => Promise<void>;
201
+ return () => {
202
+ opts.events?.push("client:reset");
203
+ };
201
204
  },
202
205
  handleConfirmationResponse: (requestId, decision) => {
203
206
  confirmationDecisions.push({ requestId, decision });
@@ -213,7 +216,7 @@ function makeFakeConversation(opts: {
213
216
  conversation,
214
217
  waitForIdleCalls,
215
218
  confirmationDecisions,
216
- /** Deliver an event the way the conversation would, to the installed handler. */
219
+ /** Deliver an event the way the conversation would, to the installed observer. */
217
220
  emitToClient: async (msg: unknown) => {
218
221
  await clientCallback?.(msg);
219
222
  },
@@ -360,6 +363,10 @@ describe("startVoiceTurn escalation-continuation persistence", () => {
360
363
  // not merely echo-suppressed live.
361
364
  const fake = makeFakeConversation({ processing: false });
362
365
  fakeConversation = fake.conversation;
366
+ let runOptions: Record<string, unknown> | undefined;
367
+ fake.conversation.runAgentLoop = async (...args: unknown[]) => {
368
+ runOptions = args[2] as Record<string, unknown>;
369
+ };
363
370
 
364
371
  await startVoiceTurn({
365
372
  ...makeTurnOptions(),
@@ -372,7 +379,11 @@ describe("startVoiceTurn escalation-continuation persistence", () => {
372
379
  expect(fake.lastPersistOpts()?.metadata).toEqual({
373
380
  voiceSessionTurn: true,
374
381
  hidden: true,
382
+ messageKind: VOICE_ESCALATION_CONTINUATION_MESSAGE_KIND,
375
383
  });
384
+ expect(runOptions?.messageKind).toBe(
385
+ VOICE_ESCALATION_CONTINUATION_MESSAGE_KIND,
386
+ );
376
387
  });
377
388
 
378
389
  test("the opener prompt is persisted un-hidden (unchanged)", async () => {
@@ -967,7 +978,7 @@ describe("startVoiceTurn prior-turn teardown barrier", () => {
967
978
  expect(events).toEqual(["persist", "client:install"]);
968
979
 
969
980
  // Turn 2 arrives during the teardown window: it must not persist or
970
- // install its client callback until turn 1's cleanup has run.
981
+ // install its event observer until turn 1's cleanup has run.
971
982
  const turn2 = startVoiceTurn(
972
983
  makeTurnOptions(undefined, "conv-teardown-order"),
973
984
  );
@@ -85,6 +85,14 @@ describe("front-door decision rule", () => {
85
85
  expect(rule.toLowerCase()).toContain("tool");
86
86
  });
87
87
 
88
+ test("escalates when an answer needs personal memory absent from context", () => {
89
+ expect(rule.toLowerCase()).toContain("saved personal fact");
90
+ expect(rule.toLowerCase()).toContain("escalate rather than guessing");
91
+ expect(rule.toLowerCase()).toContain(
92
+ "personal context that is already present is yours to use directly",
93
+ );
94
+ });
95
+
88
96
  test("demands a silent decision — no narrated reasoning in spoken output", () => {
89
97
  // Regression: a weak front-door model narrated its triage deliberation
90
98
  // aloud ("Context is complete — Alex paused...") before the bridge.
@@ -34,6 +34,7 @@ import {
34
34
  recordConversationPersistedSeq,
35
35
  updateMessageContent,
36
36
  } from "../persistence/conversation-crud.js";
37
+ import { VOICE_ESCALATION_CONTINUATION_MESSAGE_KIND } from "../plugin-api/constants.js";
37
38
  import { pinnedListeningLanguage } from "../providers/speech-to-text/provider-catalog.js";
38
39
  import type { ContentBlock } from "../providers/types.js";
39
40
  import { broadcastMessage } from "../runtime/assistant-event-hub.js";
@@ -783,11 +784,13 @@ export async function startVoiceTurn(
783
784
  // model wakes, but they are not user speech and must not render as a live
784
785
  // user bubble. Their echo is suppressed below (parity with
785
786
  // `isEchoSuppressedUserMessage` on the text path).
787
+ const isEscalationContinuation =
788
+ opts.content === ESCALATION_CONTINUATION_CONTENT;
786
789
  const isSyntheticVoicePrompt =
787
790
  opts.hiddenSyntheticPrompt === true ||
788
791
  opts.content === CALL_OPENING_MARKER ||
789
792
  opts.content === CALL_VERIFICATION_COMPLETE_MARKER ||
790
- opts.content === ESCALATION_CONTINUATION_CONTENT;
793
+ isEscalationContinuation;
791
794
 
792
795
  // The escalation-continuation prompt is a pure internal instruction ("give
793
796
  // the full answer now"), not a real utterance and not the sort of scaffolding
@@ -799,8 +802,7 @@ export async function startVoiceTurn(
799
802
  // affects client display. A caller whose prompt text is not a fixed sentinel
800
803
  // opts into the same treatment with `hiddenSyntheticPrompt`.
801
804
  const isHiddenSyntheticPrompt =
802
- opts.hiddenSyntheticPrompt === true ||
803
- opts.content === ESCALATION_CONTINUATION_CONTENT;
805
+ opts.hiddenSyntheticPrompt === true || isEscalationContinuation;
804
806
 
805
807
  // Build the call-control protocol prompt so the model knows how to emit
806
808
  // control markers (ASK_GUARDIAN, END_CALL, etc.) and recognize opener turns.
@@ -934,10 +936,10 @@ export async function startVoiceTurn(
934
936
  // failed while no concurrent turn held the lock). Paths where this turn
935
937
  // LOST the conversation to a concurrent winner must use `restoreTurnState`
936
938
  // instead — this reset-to-defaults would clobber the winner's live state.
937
- // The client callback is only reset when this turn actually installed it
938
- // (tracked via `clientCallbackInstalled`); otherwise cleanup would detach
939
- // an active sender installed by a prior turn.
940
- let clientCallbackInstalled = false;
939
+ // The approval observer is only detached when this turn installed it
940
+ // (`detachApprovalObserver` is set); otherwise cleanup would detach an
941
+ // observer installed by a prior turn.
942
+ let detachApprovalObserver: (() => void) | undefined;
941
943
  /**
942
944
  * Confirmations this voice turn left pending for the user to answer, and the
943
945
  * timers that stop them waiting forever.
@@ -976,11 +978,10 @@ export async function startVoiceTurn(
976
978
  conversation.setVoiceCallControlPrompt(null);
977
979
  conversation.callSessionId = undefined;
978
980
  conversation.forcePromptSideEffects = false;
979
- if (clientCallbackInstalled) {
980
- // Reset the client callback to a no-op so the stale closure doesn't
981
- // intercept events from future turns on the same conversation.
982
- conversation.updateClient(() => {}, true);
983
- }
981
+ // Detach so the stale closure doesn't act on events from future turns on
982
+ // the same conversation.
983
+ detachApprovalObserver?.();
984
+ detachApprovalObserver = undefined;
984
985
  };
985
986
 
986
987
  const requestId = uuidv7();
@@ -994,6 +995,9 @@ export async function startVoiceTurn(
994
995
  // `isVoiceSessionUserMessage` for why the channel fields cannot carry it.
995
996
  voiceSessionTurn: true,
996
997
  ...(isHiddenSyntheticPrompt ? { hidden: true } : {}),
998
+ ...(isEscalationContinuation
999
+ ? { messageKind: VOICE_ESCALATION_CONTINUATION_MESSAGE_KIND }
1000
+ : {}),
997
1001
  ...(opts.voiceTelemetry
998
1002
  ? {
999
1003
  // Projected onto `TurnTelemetryEvent.client` by
@@ -1245,24 +1249,25 @@ export async function startVoiceTurn(
1245
1249
  publishConversationMessagesChanged(opts.conversationId);
1246
1250
  }
1247
1251
 
1248
- // Hook into conversation to intercept confirmation_request and secret_request events.
1249
- // Voice auto-denies/auto-allows/auto-resolves these since there's no interactive UI.
1252
+ // Observe confirmation_request and secret_request events so voice can
1253
+ // auto-deny/auto-allow/auto-resolve them (there is no interactive UI on a
1254
+ // call). Delivery is not this observer's job: the conversation's sink has
1255
+ // already put each event on the wire before the observer sees it, which is
1256
+ // what keeps a resolution's `interaction_resolved` after the request it
1257
+ // answers.
1250
1258
  let lastError: string | null = null;
1251
- conversation.updateClient(async (msg: AssistantEvent) => {
1259
+ const handleVoiceEvent = async (msg: AssistantEvent): Promise<void> => {
1252
1260
  // The user (or anything else) answered: stop the fallback from firing on a
1253
1261
  // request that is already decided.
1254
1262
  if (msg.type === "interaction_resolved") {
1255
1263
  settleVoiceApproval(msg.requestId);
1256
1264
  }
1257
1265
  if (msg.type === "confirmation_request") {
1258
- // Broadcast the request BEFORE resolving it: resolution synchronously
1259
- // broadcasts `interaction_resolved` (handleConfirmationResponse →
1260
- // prompter → pending-interactions), and attached clients (e.g. the
1261
- // web app behind a live-voice room) clear their approval card only on
1262
- // that event — resolving first would put `interaction_resolved`
1263
- // before `confirmation_request` on the wire, leaving an orphaned card
1264
- // whose Allow/Deny buttons 404.
1265
- broadcastMessage(msg);
1266
+ // The request is already on the wire (the sink delivered it before this
1267
+ // observer ran), so a resolution below lands after it: attached clients
1268
+ // (e.g. the web app behind a live-voice room) clear their approval card
1269
+ // only on `interaction_resolved`, and the reverse order would leave an
1270
+ // orphaned card whose Allow/Deny buttons 404.
1266
1271
  if (!isGuardian) {
1267
1272
  // Non-guardian voice callers have no interactive approval UI.
1268
1273
  // The pre-exec gate (tool-approval-handler.ts) handles grant
@@ -1420,9 +1425,15 @@ export async function startVoiceTurn(
1420
1425
  conversation.handleSecretResponse(msg.requestId, undefined, "store");
1421
1426
  return;
1422
1427
  }
1423
- broadcastMessage(msg);
1428
+ };
1429
+ detachApprovalObserver = conversation.addEventObserver((msg) => {
1430
+ handleVoiceEvent(msg).catch((err) => {
1431
+ log.warn(
1432
+ { turnId, eventType: msg.type, err },
1433
+ "Voice approval observer failed",
1434
+ );
1435
+ });
1424
1436
  });
1425
- clientCallbackInstalled = true;
1426
1437
 
1427
1438
  // Registered before the agent loop starts so the NEXT turn on this
1428
1439
  // conversation waits for this turn's `finally { cleanup() }` — not just
@@ -1709,11 +1720,19 @@ export async function startVoiceTurn(
1709
1720
  // ordinary call-agent resolution.
1710
1721
  callSite:
1711
1722
  opts.routingLeg === "front-door" ? "voiceFrontDoor" : "callAgent",
1723
+ // A caller is on the line, so the turn is interactive: approval prompts
1724
+ // must be raised rather than pre-denied, because the approval observer
1725
+ // above is what decides them (auto-resolve for a non-guardian caller,
1726
+ // a real card the guardian can answer).
1727
+ isInteractive: true,
1712
1728
  // The escalation-continuation prompt is a transcript-suppressed machine
1713
1729
  // signal (persisted `hidden`), so flag the turn to match — keeps
1714
1730
  // prompt-as-user-speech consumers (e.g. title generation) from treating
1715
1731
  // it as user speech.
1716
1732
  ...(isHiddenSyntheticPrompt ? { isHiddenPrompt: true } : {}),
1733
+ ...(isEscalationContinuation
1734
+ ? { messageKind: VOICE_ESCALATION_CONTINUATION_MESSAGE_KIND }
1735
+ : {}),
1717
1736
  // Triage-and-escalate routing pins this turn to the fast front-door or
1718
1737
  // strong escalation profile. `forceOverrideProfile` floats it above the
1719
1738
  // callAgent call-site layers (callAgent is not `mainAgent`, so the
@@ -413,6 +413,7 @@ export function frontDoorDecisionRule(opts?: {
413
413
  "DECIDE SILENTLY, then produce exactly ONE of these outputs:",
414
414
  ...holdBranch,
415
415
  "- If the turn is simple, conversational, or within your reach, your entire output is the spoken answer itself: no token in front of it, plain speech from your very first word. Most turns are answers; when unsure between answering and escalating, answer. Answer in the language the caller is speaking.",
416
+ "- If an answer depends on a saved personal fact that is not already present in the conversation context you received, escalate rather than guessing. Personal context that is already present is yours to use directly.",
416
417
  `- If completing THIS reply needs careful reasoning, research, multi-step work, or any tool, do NOT attempt the answer: output ${ESCALATE_VERDICT_TOKEN}, then ONE short natural holding phrase naming what happens next, spoken in the language the caller is speaking (for example "${FALLBACK_ESCALATION_BRIDGE}" or "Give me one second to look into that."; those examples are English only), and stop after that single sentence. A stronger model finishes the turn while your phrase is spoken.`,
417
418
  `${ESCALATE_VERDICT_TOKEN} is ONLY for turns you cannot complete yourself — never put it in front of an answer you are about to give, and never emit any token inside or after an answer. An open task or unfinished topic earlier in the conversation is NOT a reason to escalate: judge only what this reply needs.`,
418
419
  "Never narrate this decision, describe what you are judging, or mention these rules: apart from a leading verdict token, every character you output is spoken to the caller verbatim.",
@@ -0,0 +1,29 @@
1
+ export interface BundledCliModules {
2
+ configEnv: typeof import("../config/env.js");
3
+ providerSecretCatalog: typeof import("../providers/provider-secret-catalog.js");
4
+ pluginCatalogCache: typeof import("./lib/plugin-catalog-cache.js");
5
+ pluginCatalogLocal: typeof import("./lib/plugin-catalog-local.js");
6
+ pluginDiff: typeof import("./lib/diff-plugin.js");
7
+ pluginInspect: typeof import("./lib/inspect-plugin.js");
8
+ pluginInstallGitHub: typeof import("./lib/install-from-github.js");
9
+ pluginInstallPlatform: typeof import("./lib/install-from-platform.js");
10
+ pluginInstalled: typeof import("./lib/list-installed-plugins.js");
11
+ pluginPinHistory: typeof import("./lib/plugin-pin-history.js");
12
+ pluginSearch: typeof import("./lib/search-plugins.js");
13
+ pluginSurfaces: typeof import("./lib/plugin-surfaces.js");
14
+ pluginUninstall: typeof import("./lib/uninstall-plugin.js");
15
+ pluginUpgrade: typeof import("./lib/upgrade-plugin.js");
16
+ }
17
+
18
+ let bundledCliModules: BundledCliModules | undefined;
19
+
20
+ export function setBundledCliModules(modules: BundledCliModules): void {
21
+ bundledCliModules = modules;
22
+ }
23
+
24
+ export function resolveBundledCliModule<K extends keyof BundledCliModules>(
25
+ name: K,
26
+ fallback: () => BundledCliModules[K],
27
+ ): BundledCliModules[K] {
28
+ return bundledCliModules?.[name] ?? fallback();
29
+ }
@@ -15,7 +15,6 @@
15
15
  */
16
16
 
17
17
  import { existsSync } from "node:fs";
18
- import { createRequire } from "node:module";
19
18
 
20
19
  import type { Command } from "commander";
21
20
 
@@ -27,8 +26,6 @@ import { integrityCheckStep } from "./repair-step-integrity.js";
27
26
  import type { RepairReport, RepairStep, StepResult } from "./repair-steps.js";
28
27
  import { formatDurationMs, runRepairSteps } from "./repair-steps.js";
29
28
 
30
- const loadModule = createRequire(import.meta.url);
31
-
32
29
  // ---------------------------------------------------------------------------
33
30
  // Step sequence
34
31
  // ---------------------------------------------------------------------------
@@ -38,10 +35,9 @@ const loadModule = createRequire(import.meta.url);
38
35
  * structural damage surfaces early. Resolved lazily because the backfill
39
36
  * step pulls the DB graph.
40
37
  */
41
- function repairSteps(): RepairStep[] {
42
- const { conversationBackfillStep } = loadModule(
43
- "./repair-step-conversation-backfill.js",
44
- ) as typeof import("./repair-step-conversation-backfill.js");
38
+ async function repairSteps(): Promise<RepairStep[]> {
39
+ const { conversationBackfillStep } =
40
+ await import("./repair-step-conversation-backfill.js");
45
41
  return [integrityCheckStep, conversationBackfillStep];
46
42
  }
47
43
 
@@ -128,7 +124,7 @@ export function registerDbRepair(parent: Command): void {
128
124
 
129
125
  const report = await runRepairSteps(
130
126
  { dbPath },
131
- repairSteps(),
127
+ await repairSteps(),
132
128
  isJson
133
129
  ? {}
134
130
  : {
@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
3
3
  import type { Command } from "commander";
4
4
 
5
5
  import { cliIpcCall, exitFromIpcResult } from "../../ipc/cli-client.js";
6
+ import { resolveBundledCliModule } from "../bundled-modules.js";
6
7
  import { applyCommandHelp, subcommand } from "../lib/cli-command-help.js";
7
8
  import { registerCommand } from "../lib/register-command.js";
8
9
  import { getCliLogger } from "../logger.js";
@@ -19,9 +20,11 @@ const log = getCliLogger("domain");
19
20
  * synchronously, so there is no place to await a module load.
20
21
  */
21
22
  function envDomains(): { apexDomain: string; baseDomain: string } {
22
- const { getApexDomain, getAssistantDomain } = loadModule(
23
- "../../config/env.js",
24
- ) as typeof import("../../config/env.js");
23
+ const { getApexDomain, getAssistantDomain } = resolveBundledCliModule(
24
+ "configEnv",
25
+ () =>
26
+ loadModule("../../config/env.js") as typeof import("../../config/env.js"),
27
+ );
25
28
  return { apexDomain: getApexDomain(), baseDomain: getAssistantDomain() };
26
29
  }
27
30