@vellumai/assistant 0.10.4-dev.202607011113.fffc936 → 0.10.4-dev.202607011452.4dbd565

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 (34) hide show
  1. package/openapi.yaml +6 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/call-controller.test.ts +292 -34
  4. package/src/__tests__/conversation-routes-guardian-reply.test.ts +84 -0
  5. package/src/__tests__/voice-session-bridge-processing-wait.test.ts +27 -0
  6. package/src/calls/call-controller.ts +75 -8
  7. package/src/calls/voice-session-bridge.ts +25 -1
  8. package/src/config/feature-flag-registry.json +8 -0
  9. package/src/config/schemas/__tests__/memory-v3.test.ts +52 -0
  10. package/src/config/schemas/memory-v3.ts +76 -0
  11. package/src/daemon/abort-watchdog.ts +7 -0
  12. package/src/daemon/conversation-agent-loop.ts +19 -21
  13. package/src/daemon/lifecycle.ts +3 -6
  14. package/src/persistence/schema/conversation-groups.ts +12 -0
  15. package/src/persistence/schema/documents.ts +65 -0
  16. package/src/persistence/schema/index.ts +4 -0
  17. package/src/persistence/schema/memory-injection.ts +62 -0
  18. package/src/persistence/schema/workflows.ts +55 -0
  19. package/src/plugins/defaults/memory/v3/__tests__/carry-integration.test.ts +6 -0
  20. package/src/plugins/defaults/memory/v3/__tests__/dense.test.ts +76 -1
  21. package/src/plugins/defaults/memory/v3/__tests__/gate-flag.test.ts +56 -0
  22. package/src/plugins/defaults/memory/v3/__tests__/gate.test.ts +181 -0
  23. package/src/plugins/defaults/memory/v3/__tests__/live-integration.test.ts +6 -0
  24. package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +230 -3
  25. package/src/plugins/defaults/memory/v3/__tests__/section-needle.test.ts +64 -0
  26. package/src/plugins/defaults/memory/v3/__tests__/shadow-integration.test.ts +9 -0
  27. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +43 -1
  28. package/src/plugins/defaults/memory/v3/dense.ts +44 -9
  29. package/src/plugins/defaults/memory/v3/gate-flag.ts +15 -0
  30. package/src/plugins/defaults/memory/v3/gate.ts +158 -0
  31. package/src/plugins/defaults/memory/v3/orchestrate.ts +154 -25
  32. package/src/plugins/defaults/memory/v3/section-needle.ts +27 -7
  33. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +8 -0
  34. package/src/runtime/routes/conversation-routes.ts +32 -9
package/openapi.yaml CHANGED
@@ -18137,6 +18137,12 @@ paths:
18137
18137
  - low
18138
18138
  - medium
18139
18139
  - high
18140
+ hidden:
18141
+ description:
18142
+ When true, persist the user message but suppress it from the UI transcript (it stays in LLM-side history
18143
+ and still drives the turn). Used to prime a proactive assistant greeting without showing the
18144
+ triggering user message. Honored on the standard send path only.
18145
+ type: boolean
18140
18146
  onboarding:
18141
18147
  type: object
18142
18148
  properties:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.4-dev.202607011113.fffc936",
3
+ "version": "0.10.4-dev.202607011452.4dbd565",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1025,6 +1025,52 @@ describe("call-controller", () => {
1025
1025
  controller.destroy();
1026
1026
  });
1027
1027
 
1028
+ test("lock-contention rejection: speaks a brief re-prompt, no technical-issue speech, returns to idle", async () => {
1029
+ mockStartVoiceTurn.mockImplementation(async () => {
1030
+ throw new Error("Conversation is already processing a message");
1031
+ });
1032
+
1033
+ const { relay, controller } = setupController();
1034
+
1035
+ await controller.handleCallerUtterance("Hello");
1036
+
1037
+ // A wedged prior turn must never surface a technical-error message.
1038
+ const errorTokens = relay.sentTokens.filter((t) =>
1039
+ t.token.includes("technical issue"),
1040
+ );
1041
+ expect(errorTokens.length).toBe(0);
1042
+
1043
+ // A brief natural re-prompt (last=true) is spoken so the caller knows to
1044
+ // repeat themselves and the relay transitions back to listening.
1045
+ const reprompts = relay.sentTokens.filter(
1046
+ (t) => t.token === "Sorry, could you say that again?" && t.last === true,
1047
+ );
1048
+ expect(reprompts.length).toBeGreaterThan(0);
1049
+
1050
+ // Controller goes idle and re-arms the silence watchdog.
1051
+ expect(controller.getState()).toBe("idle");
1052
+
1053
+ controller.destroy();
1054
+ });
1055
+
1056
+ test("genuine non-lock-contention error still speaks the technical-issue fallback", async () => {
1057
+ mockStartVoiceTurn.mockImplementation(async () => {
1058
+ throw new Error("boom");
1059
+ });
1060
+
1061
+ const { relay, controller } = setupController();
1062
+
1063
+ await controller.handleCallerUtterance("Hello");
1064
+
1065
+ const errorTokens = relay.sentTokens.filter((t) =>
1066
+ t.token.includes("technical issue"),
1067
+ );
1068
+ expect(errorTokens.length).toBeGreaterThan(0);
1069
+ expect(controller.getState()).toBe("idle");
1070
+
1071
+ controller.destroy();
1072
+ });
1073
+
1028
1074
  test("handleUserAnswer: returns false when no pending consultation exists", async () => {
1029
1075
  const { controller } = setupController();
1030
1076
 
@@ -1054,17 +1100,13 @@ describe("call-controller", () => {
1054
1100
  onComplete: () => void;
1055
1101
  }) => {
1056
1102
  return new Promise((resolve) => {
1057
- // Simulate a long-running turn that can be aborted
1058
- const timeout = setTimeout(() => {
1059
- opts.onTextDelta("This should be interrupted");
1060
- opts.onComplete();
1061
- resolve({ turnId: "run-1", abort: () => {} });
1062
- }, 1000);
1103
+ // Emit a token right away so the assistant is actively speaking
1104
+ // (state flips to `speaking`) before the interrupt arrives.
1105
+ opts.onTextDelta("This should be interrupted");
1063
1106
 
1064
1107
  opts.signal?.addEventListener(
1065
1108
  "abort",
1066
1109
  () => {
1067
- clearTimeout(timeout);
1068
1110
  // In the real system, generation_cancelled triggers
1069
1111
  // onComplete via the event sink. The AbortSignal listener
1070
1112
  // in call-controller also resolves turnComplete defensively.
@@ -2123,7 +2165,10 @@ describe("call-controller", () => {
2123
2165
  "Are you still there?",
2124
2166
  );
2125
2167
  await new Promise((r) => setTimeout(r, 10));
2126
- expect(controller.getState()).toBe("speaking");
2168
+ // The turn is paused before emitting any token, so the controller is still
2169
+ // in the pre-speech `processing` phase (it only flips to `speaking` once
2170
+ // real outbound audio/tokens begin).
2171
+ expect(controller.getState()).toBe("processing");
2127
2172
 
2128
2173
  // Answer arrives while the controller is processing/speaking
2129
2174
  const accepted = await controller.handleUserAnswer("3pm works");
@@ -2682,6 +2727,65 @@ describe("call-controller", () => {
2682
2727
  controller.destroy();
2683
2728
  });
2684
2729
 
2730
+ test("synthesized provider: a superseded run does not emit native fallback text", async () => {
2731
+ const cfg = loadConfig();
2732
+ cfg.services.tts.provider = "fish-audio";
2733
+ cfg.services.tts.providers["fish-audio"].referenceId = "fish-ref-123";
2734
+
2735
+ _resetTtsProviderRegistry();
2736
+ // elevenlabs present so native fallback is available for fish-audio.
2737
+ const elevenlabs: TtsProvider = {
2738
+ id: "elevenlabs",
2739
+ capabilities: { supportsStreaming: false, supportedFormats: ["mp3"] },
2740
+ async synthesize() {
2741
+ return { audio: Buffer.from(""), contentType: "audio/mpeg" };
2742
+ },
2743
+ };
2744
+ registerTtsProvider(elevenlabs);
2745
+
2746
+ let releaseSynth: (() => void) | undefined;
2747
+ const synthGate = new Promise<void>((resolve) => {
2748
+ releaseSynth = resolve;
2749
+ });
2750
+ const fishAudioGatedFailing: TtsProvider = {
2751
+ id: "fish-audio",
2752
+ capabilities: {
2753
+ supportsStreaming: true,
2754
+ supportedFormats: ["mp3", "wav", "opus"],
2755
+ },
2756
+ async synthesize() {
2757
+ throw new Error("fish-audio synth failure");
2758
+ },
2759
+ async synthesizeStream() {
2760
+ // Ignore the abort signal, block, then fail with no audio — this would
2761
+ // normally trigger the native fallback text emission.
2762
+ await synthGate;
2763
+ throw new Error("fish-audio stream failure");
2764
+ },
2765
+ };
2766
+ registerTtsProvider(fishAudioGatedFailing);
2767
+
2768
+ mockStartVoiceTurn.mockImplementation(
2769
+ createMockVoiceTurn(["Stale synthesized response"]),
2770
+ );
2771
+ const { relay, controller } = setupController();
2772
+
2773
+ const turn = controller.handleCallerUtterance("Hi");
2774
+ await new Promise((r) => setTimeout(r, 20)); // reach synthesis, blocked
2775
+
2776
+ // Supersede the turn mid-synthesis, then let synthesis fail.
2777
+ controller.handleInterrupt();
2778
+ releaseSynth?.();
2779
+ await turn;
2780
+
2781
+ // The stale run must NOT leak its native fallback text into the relay.
2782
+ const emitted = relay.sentTokens.map((t) => t.token).join("");
2783
+ expect(emitted).not.toContain("Stale synthesized response");
2784
+ expect(relay.sentPlayUrls.length).toBe(0);
2785
+
2786
+ controller.destroy();
2787
+ });
2788
+
2685
2789
  test("synthesized provider: play URL uses public base URL", async () => {
2686
2790
  const cfg = loadConfig();
2687
2791
  cfg.ingress.publicBaseUrl = "https://twilio.example.com/";
@@ -2726,6 +2830,113 @@ describe("call-controller", () => {
2726
2830
  controller.destroy();
2727
2831
  });
2728
2832
 
2833
+ test("synthesized provider: stays 'processing' during synthesis latency, so barge-in cannot abort an inaudible turn", async () => {
2834
+ const cfg = loadConfig();
2835
+ cfg.services.tts.provider = "fish-audio";
2836
+ cfg.services.tts.providers["fish-audio"].referenceId = "fish-ref-123";
2837
+
2838
+ _resetTtsProviderRegistry();
2839
+ let releaseChunk: (() => void) | undefined;
2840
+ const chunkGate = new Promise<void>((resolve) => {
2841
+ releaseChunk = resolve;
2842
+ });
2843
+ const fishAudioStreaming: TtsProvider = {
2844
+ id: "fish-audio",
2845
+ capabilities: {
2846
+ supportsStreaming: true,
2847
+ supportedFormats: ["mp3", "wav", "opus"],
2848
+ },
2849
+ async synthesize() {
2850
+ return { audio: Buffer.from("x"), contentType: "audio/mpeg" };
2851
+ },
2852
+ async synthesizeStream(_request, onChunk) {
2853
+ // Simulate provider latency: emit no audio until the gate releases.
2854
+ await chunkGate;
2855
+ onChunk(Buffer.from("fish-audio-stream"));
2856
+ return {
2857
+ audio: Buffer.from("fish-audio-stream"),
2858
+ contentType: "audio/mpeg",
2859
+ };
2860
+ },
2861
+ };
2862
+ registerTtsProvider(fishAudioStreaming);
2863
+
2864
+ mockStartVoiceTurn.mockImplementation(
2865
+ createMockVoiceTurn(["Hello from synthesized path."]),
2866
+ );
2867
+ const { relay, controller } = setupController();
2868
+
2869
+ const turn = controller.handleCallerUtterance("Hi");
2870
+ // Let the turn generate its text and reach synthesis, blocked on the gate.
2871
+ await new Promise((r) => setTimeout(r, 20));
2872
+
2873
+ // No audio has reached the caller yet (play URL not sent), so the controller
2874
+ // must stay in `processing` — a barge-in here would abort a turn the caller
2875
+ // cannot hear. See JARVIS-1232.
2876
+ expect(relay.sentPlayUrls.length).toBe(0);
2877
+ expect(controller.getState()).toBe("processing");
2878
+ expect(controller.handleBargeIn()).toBe(false);
2879
+
2880
+ // Release audio → play URL sent → turn finishes and returns to idle.
2881
+ releaseChunk?.();
2882
+ await turn;
2883
+ expect(relay.sentPlayUrls.length).toBeGreaterThan(0);
2884
+ expect(controller.getState()).toBe("idle");
2885
+
2886
+ controller.destroy();
2887
+ });
2888
+
2889
+ test("synthesized provider: a superseded turn does not send a stale play URL", async () => {
2890
+ const cfg = loadConfig();
2891
+ cfg.services.tts.provider = "fish-audio";
2892
+ cfg.services.tts.providers["fish-audio"].referenceId = "fish-ref-123";
2893
+
2894
+ _resetTtsProviderRegistry();
2895
+ let releaseChunk: (() => void) | undefined;
2896
+ const chunkGate = new Promise<void>((resolve) => {
2897
+ releaseChunk = resolve;
2898
+ });
2899
+ const fishAudioStreaming: TtsProvider = {
2900
+ id: "fish-audio",
2901
+ capabilities: {
2902
+ supportsStreaming: true,
2903
+ supportedFormats: ["mp3", "wav", "opus"],
2904
+ },
2905
+ async synthesize() {
2906
+ return { audio: Buffer.from("x"), contentType: "audio/mpeg" };
2907
+ },
2908
+ async synthesizeStream(_request, onChunk) {
2909
+ // Block until released so we can supersede the turn mid-synthesis.
2910
+ await chunkGate;
2911
+ onChunk(Buffer.from("stale-audio"));
2912
+ return { audio: Buffer.from("stale-audio"), contentType: "audio/mpeg" };
2913
+ },
2914
+ };
2915
+ registerTtsProvider(fishAudioStreaming);
2916
+
2917
+ mockStartVoiceTurn.mockImplementation(
2918
+ createMockVoiceTurn(["Hello from synthesized path."]),
2919
+ );
2920
+ const { relay, controller } = setupController();
2921
+
2922
+ const turn = controller.handleCallerUtterance("first");
2923
+ // Let the turn generate text and block inside provider synthesis.
2924
+ await new Promise((r) => setTimeout(r, 20));
2925
+ expect(relay.sentPlayUrls.length).toBe(0);
2926
+
2927
+ // Supersede the in-flight synthesized turn (bumps run version + aborts synthesis).
2928
+ controller.handleInterrupt();
2929
+
2930
+ // Releasing the stale synthesis must NOT send a play URL for the old run,
2931
+ // nor inject a stale end-of-turn marker into the relay stream.
2932
+ releaseChunk?.();
2933
+ await turn;
2934
+ expect(relay.sentPlayUrls.length).toBe(0);
2935
+ expect(relay.sentTokens.filter((t) => t.last).length).toBe(0);
2936
+
2937
+ controller.destroy();
2938
+ });
2939
+
2729
2940
  test("Deepgram selected path resolves useSynthesizedPath to true", () => {
2730
2941
  const cfg = loadConfig();
2731
2942
  cfg.services.tts.provider = "deepgram";
@@ -2841,16 +3052,13 @@ describe("call-controller", () => {
2841
3052
  onComplete: () => void;
2842
3053
  }) => {
2843
3054
  return new Promise((resolve) => {
2844
- const timeout = setTimeout(() => {
2845
- opts.onTextDelta("This should be interrupted");
2846
- opts.onComplete();
2847
- resolve({ turnId: "run-1", abort: () => {} });
2848
- }, 1000);
3055
+ // Emit a token right away so the assistant is actively speaking
3056
+ // before the interrupt arrives.
3057
+ opts.onTextDelta("This should be interrupted");
2849
3058
 
2850
3059
  opts.signal?.addEventListener(
2851
3060
  "abort",
2852
3061
  () => {
2853
- clearTimeout(timeout);
2854
3062
  opts.onComplete();
2855
3063
  resolve({ turnId: "run-1", abort: () => {} });
2856
3064
  },
@@ -2972,49 +3180,99 @@ describe("call-controller", () => {
2972
3180
  controller.destroy();
2973
3181
  });
2974
3182
 
2975
- test("handleBargeIn returns false when controller is processing", async () => {
2976
- // Use a slow turn that never completes so we can observe
2977
- // the processing state.
3183
+ test("handleBargeIn returns false and does not abort while still processing (no output yet)", async () => {
3184
+ // Simulate a turn stuck waiting for the processing lock: no tokens
3185
+ // emitted, no completion. The controller must stay in `processing` and
3186
+ // NOT flip to `speaking`, so barge-in can't abort a silent turn.
2978
3187
  mockStartVoiceTurn.mockImplementation(
2979
3188
  async (opts: {
2980
3189
  onTextDelta: (t: string) => void;
2981
3190
  onComplete: () => void;
2982
3191
  signal?: AbortSignal;
2983
3192
  }) => {
2984
- // Don't call onCompletekeep in processing/speaking
3193
+ // Never emit tokens or complete remain in the pre-speech phase.
2985
3194
  return { turnId: "run-slow", abort: () => opts.onComplete() };
2986
3195
  },
2987
3196
  );
2988
3197
 
2989
3198
  const { relay, controller } = setupController();
2990
- // Kick off a turn (moves to speaking state)
2991
3199
  const turnPromise = controller.handleCallerUtterance("Hello");
2992
3200
 
2993
3201
  // Wait for microtasks to settle
2994
3202
  for (let i = 0; i < 5; i++) await Promise.resolve();
2995
3203
 
2996
- // The controller transitions to "speaking" once runTurnInner starts.
2997
- // Before any onTextDelta, a barge-in should be accepted if speaking.
2998
- // But if no text has been emitted yet, the state is "speaking" per
2999
- // the implementation (state is set to speaking at the start of
3000
- // runTurnInner). So handleBargeIn should accept. Let's verify the
3001
- // state and behavior.
3002
- const bargeResult = controller.handleBargeIn();
3204
+ // No outbound audio/tokens yet still processing.
3205
+ expect(controller.getState()).toBe("processing");
3003
3206
 
3004
- // Regardless of the specific state, if accepted the transport
3005
- // should see an interrupt token.
3006
- if (bargeResult) {
3007
- const endTokens = relay.sentTokens.filter(
3008
- (t) => t.last === true && t.token === "",
3009
- );
3010
- expect(endTokens.length).toBeGreaterThan(0);
3011
- }
3207
+ const bargeResult = controller.handleBargeIn();
3208
+ expect(bargeResult).toBe(false);
3209
+ // Still processing (not aborted), and no interrupt/end-of-turn token sent.
3210
+ expect(controller.getState()).toBe("processing");
3211
+ const endTokens = relay.sentTokens.filter(
3212
+ (t) => t.last === true && t.token === "",
3213
+ );
3214
+ expect(endTokens.length).toBe(0);
3012
3215
 
3013
3216
  // Cleanup: abort the pending turn
3014
3217
  controller.destroy();
3015
3218
  await turnPromise.catch(() => {});
3016
3219
  });
3017
3220
 
3221
+ test("stays in processing until first token, then flips to speaking and barge-in is accepted", async () => {
3222
+ // Gate the first token so we can observe the pre-speech processing phase
3223
+ // (e.g. the lock-wait / generation window) before any audio is emitted.
3224
+ let emitFirstToken!: () => void;
3225
+ const firstTokenGate = new Promise<void>((r) => {
3226
+ emitFirstToken = r;
3227
+ });
3228
+ let releaseTurn!: () => void;
3229
+ const completeGate = new Promise<void>((r) => {
3230
+ releaseTurn = r;
3231
+ });
3232
+
3233
+ mockStartVoiceTurn.mockImplementation(
3234
+ async (opts: {
3235
+ onTextDelta: (t: string) => void;
3236
+ onComplete: () => void;
3237
+ signal?: AbortSignal;
3238
+ }) => {
3239
+ await firstTokenGate;
3240
+ if (opts.signal?.aborted) {
3241
+ const err = new Error("aborted");
3242
+ err.name = "AbortError";
3243
+ throw err;
3244
+ }
3245
+ opts.onTextDelta("Hello");
3246
+ // Hold the turn open so state remains `speaking` until we barge in.
3247
+ opts.signal?.addEventListener("abort", () => releaseTurn());
3248
+ await completeGate;
3249
+ opts.onComplete();
3250
+ return { turnId: "run-gate", abort: () => releaseTurn() };
3251
+ },
3252
+ );
3253
+
3254
+ const { controller } = setupController();
3255
+ const turnPromise = controller.handleCallerUtterance("Hi");
3256
+ for (let i = 0; i < 5; i++) await Promise.resolve();
3257
+
3258
+ // Before any token: processing, barge-in ignored (turn not aborted).
3259
+ expect(controller.getState()).toBe("processing");
3260
+ expect(controller.handleBargeIn()).toBe(false);
3261
+ expect(controller.getState()).toBe("processing");
3262
+
3263
+ // Release the first token → controller flips to speaking.
3264
+ emitFirstToken();
3265
+ for (let i = 0; i < 5; i++) await Promise.resolve();
3266
+ expect(controller.getState()).toBe("speaking");
3267
+
3268
+ // Now barge-in is accepted and interrupts the turn.
3269
+ expect(controller.handleBargeIn()).toBe(true);
3270
+ expect(controller.getState()).toBe("idle");
3271
+
3272
+ controller.destroy();
3273
+ await turnPromise.catch(() => {});
3274
+ });
3275
+
3018
3276
  test("handleBargeIn returns true and interrupts when controller is speaking", async () => {
3019
3277
  // Create a turn that holds the speaking state long enough to test barge-in
3020
3278
  let resolveComplete: () => void;
@@ -323,6 +323,90 @@ describe("handleSendMessage canonical guardian reply interception", () => {
323
323
  expect(runAgentLoop).toHaveBeenCalledTimes(1);
324
324
  });
325
325
 
326
+ test("hidden:true persists hidden metadata and still runs the turn", async () => {
327
+ listPendingByDestinationMock.mockReturnValue([]);
328
+ listCanonicalMock.mockReturnValue([]);
329
+ routeGuardianReplyMock.mockResolvedValue({
330
+ consumed: false,
331
+ decisionApplied: false,
332
+ type: "not_consumed",
333
+ });
334
+
335
+ const persistUserMessage = mock(async () => ({
336
+ id: "persisted-user-id",
337
+ deduplicated: false,
338
+ }));
339
+ const runAgentLoop = mock(async () => undefined);
340
+ const session = {
341
+ setTrustContext: () => {},
342
+ updateClient: () => {},
343
+ emitConfirmationStateChanged: () => {},
344
+ emitActivityState: () => {},
345
+ setTurnChannelContext: () => {},
346
+ setTurnInterfaceContext: () => {},
347
+ ensureActorScopedHistory: async () => {},
348
+ usageStats: { inputTokens: 0, outputTokens: 0, estimatedCost: 0 },
349
+ isProcessing: () => false,
350
+ hasAnyPendingConfirmation: () => false,
351
+ denyAllPendingConfirmations: () => {},
352
+ enqueueMessage: () => ({ queued: true, requestId: "queued-id" }),
353
+ persistUserMessage,
354
+ runAgentLoop,
355
+ getMessages: () => [] as unknown[],
356
+ assistantId: "self",
357
+ trustContext: undefined,
358
+ hasPendingConfirmation: () => false,
359
+ setHostBrowserProxy: () => {},
360
+ setHostCuProxy: () => {},
361
+ setHostAppControlProxy: () => {},
362
+ restoreBrowserProxyAvailability: () => {},
363
+ addPreactivatedSkillId: () => {},
364
+ } as unknown as import("../daemon/conversation.js").Conversation;
365
+
366
+ const req = new Request("http://localhost/v1/messages", {
367
+ method: "POST",
368
+ headers: {
369
+ "Content-Type": "application/json",
370
+ "x-vellum-actor-principal-id": "test-user",
371
+ "x-vellum-principal-type": "actor",
372
+ },
373
+ body: JSON.stringify({
374
+ conversationKey: "guardian-conversation-key",
375
+ content: "Hey, how are you? Show me what you can do.",
376
+ sourceChannel: "vellum",
377
+ interface: "web",
378
+ hidden: true,
379
+ }),
380
+ });
381
+
382
+ const res = await callHandler(
383
+ (args) =>
384
+ handleSendMessage(args, {
385
+ sendMessageDeps: {
386
+ getOrCreateConversation: async () => session,
387
+ assistantEventHub: { publish: async () => {} } as any,
388
+ resolveAttachments: () => [],
389
+ },
390
+ }),
391
+ req,
392
+ undefined,
393
+ 202,
394
+ );
395
+
396
+ expect(res.status).toBe(202);
397
+ // The hidden message is persisted with metadata.hidden so the list-messages
398
+ // filter suppresses it from the UI transcript while keeping it in LLM-side
399
+ // history (see list-messages-hidden-metadata.test.ts for the filter).
400
+ expect(persistUserMessage).toHaveBeenCalledTimes(1);
401
+ const persistArgs = (persistUserMessage as any).mock.calls[0][0] as {
402
+ metadata?: { hidden?: boolean };
403
+ };
404
+ expect(persistArgs.metadata?.hidden).toBe(true);
405
+ // The turn still runs — the assistant's reply streams normally, so the chat
406
+ // reads as a proactive greeting.
407
+ expect(runAgentLoop).toHaveBeenCalledTimes(1);
408
+ });
409
+
326
410
  test("excludes stale tool_approval hints without a live pending confirmation", async () => {
327
411
  listPendingByDestinationMock.mockReturnValue([
328
412
  { id: "tool-approval-live", kind: "tool_approval" },
@@ -0,0 +1,27 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Mocks — declared before importing voice-session-bridge so its module-level
5
+ // imports (logger, config loader) stay side-effect free for this pure helper.
6
+ // ---------------------------------------------------------------------------
7
+
8
+ mock.module("../util/logger.js", () => ({
9
+ getLogger: () =>
10
+ new Proxy({} as Record<string, unknown>, {
11
+ get: () => () => {},
12
+ }),
13
+ }));
14
+
15
+ mock.module("../config/loader.js", () => ({
16
+ getConfig: () => ({}),
17
+ }));
18
+
19
+ import { resolveProcessingWaitMs } from "../calls/voice-session-bridge.js";
20
+
21
+ describe("resolveProcessingWaitMs", () => {
22
+ test("sums commit window, abort unwind budget, and fixed margin", () => {
23
+ expect(resolveProcessingWaitMs(4000, 5000)).toBe(10000);
24
+ expect(resolveProcessingWaitMs(1000, 5000)).toBe(7000);
25
+ expect(resolveProcessingWaitMs(4000, 0)).toBe(5000);
26
+ });
27
+ });