@vellumai/assistant 0.12.2-dev.202609181515.7e76c92 → 0.12.2-dev.202609181713.fb1ff61

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 (37) hide show
  1. package/ARCHITECTURE.md +4 -1
  2. package/package.json +1 -1
  3. package/src/__tests__/agent-loop-output-hooks.test.ts +150 -1
  4. package/src/__tests__/call-conversation-messages.test.ts +5 -0
  5. package/src/__tests__/call-recovery.test.ts +7 -2
  6. package/src/__tests__/call-routes-http.test.ts +12 -0
  7. package/src/__tests__/call-store.test.ts +7 -2
  8. package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +8 -2
  9. package/src/__tests__/conversation-agent-loop.test.ts +120 -2
  10. package/src/__tests__/deterministic-verification-control-plane.test.ts +2 -0
  11. package/src/__tests__/guardian-dispatch.test.ts +13 -0
  12. package/src/__tests__/notification-guardian-path.test.ts +7 -0
  13. package/src/__tests__/twilio-routes.test.ts +1 -0
  14. package/src/agent/loop.ts +76 -11
  15. package/src/calls/__tests__/call-controller.test.ts +94 -0
  16. package/src/calls/__tests__/call-funnel-telemetry.test.ts +204 -0
  17. package/src/{live-voice/__tests__/live-voice-metrics.test.ts → calls/__tests__/voice-metrics.test.ts} +32 -38
  18. package/src/calls/__tests__/voice-session-bridge.test.ts +342 -14
  19. package/src/calls/call-controller.ts +177 -1
  20. package/src/calls/call-domain.ts +4 -0
  21. package/src/calls/call-store.ts +87 -2
  22. package/src/calls/voice-escalation-target.ts +11 -0
  23. package/src/{live-voice/live-voice-metrics.ts → calls/voice-metrics.ts} +94 -87
  24. package/src/calls/voice-session-bridge.ts +140 -51
  25. package/src/daemon/conversation-agent-loop.ts +46 -18
  26. package/src/daemon/conversation.ts +80 -32
  27. package/src/live-voice/__tests__/live-activity-reporter.test.ts +154 -15
  28. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +172 -0
  29. package/src/live-voice/live-activity-reporter.ts +115 -6
  30. package/src/live-voice/live-voice-session.ts +106 -31
  31. package/src/live-voice/protocol.ts +10 -2
  32. package/src/onboarding/onboarding-events-store.ts +99 -12
  33. package/src/plugin-api/vision-support.test.ts +22 -0
  34. package/src/plugin-api/vision-support.ts +17 -5
  35. package/src/runtime/routes/conversation-routes.ts +1 -1
  36. package/src/telemetry/__tests__/phone-call-funnel.test.ts +72 -0
  37. package/src/telemetry/phone-call-funnel.ts +144 -0
package/ARCHITECTURE.md CHANGED
@@ -614,6 +614,8 @@ Transcription mode is selected once per session in `media-stream-stt-session.ts`
614
614
 
615
615
  Every phone turn runs the same two-leg triage as live voice through `startVoiceTurn` (`src/calls/voice-session-bridge.ts`): `call-controller.ts` opens on a toolless front-door leg (`routingLeg: "front-door"`, the `voiceFrontDoor` call site) and drives it through the shared `createFrontDoorLegCoordinator` (`src/calls/voice-leg-coordinator.ts`), which reads the stream through the verdict machine and sequences the hand-off (pause narration, abort the leg, resolve and speak the bridge, mark it as the floor holder, start the escalated leg pinned to the conversation's own model, re-arm narration); each driver supplies only a host for how text and the bridge are spoken, how a leg is started or aborted, and (live voice only) the speculative hold and commit. Phone has no partial transcripts, so the hold verdict is never taught and routing is escalate-only. The controller also passes the bridge's turn callbacks (tool activity is recorded as `tool_use_started` / `tool_use_completed` call events, persisted row ids ride the `assistant_spoke` event), `launchedAtMs` for dispatch timing, and a `voiceTelemetry` bag keyed by the call session with a `phone_inbound` / `phone_outbound` entry. Both drivers share the spoken progress narration cadence (`src/calls/voice-progress-cadence.ts`, tuned by `voice.frontModel.progress`): the cadence owns the tool-activity log, the triggers (an ops burst, a long operation completing, a full interval of audible silence with news, the `maxSilenceMs` heartbeat) and the generated or static phrase, while each driver supplies its own view of audible silence (live voice from its TTS queue and playback-tail estimate; the media-stream transport from `isPlaybackIdle()` and a running sum of sent frame durations) and how to speak a phrase.
616
616
 
617
+ Both drivers also share the per-turn latency marks (`src/calls/voice-metrics.ts`). The controller opens a turn's marks when it dispatches, seeded with when the caller's transcript arrived, and stamps the leg dispatch, the first assistant delta, the first audible audio, a barge-in, and how the turn settled. Live voice streams a snapshot to its client on every mark; a call has no client on the line, so the aggregate is logged once per turn, cancelled turns included. Each call also records a `phone_call_started` / `phone_call_ended` pair on the onboarding telemetry substrate (`src/telemetry/phone-call-funnel.ts`), written by the call store at session creation and at the single terminal status transition: duration is the gap between the two rows, and a call that took no caller turn carries why on the end row (`silent_no_connect` when it never connected, `silent_no_turn` when it did and nobody spoke).
618
+
617
619
  A credential preflight (`resolveTelephonyCredentialReadiness()` in `src/calls/telephony-credential-preflight.ts`) gates every call: it requires a credentialed, telephony-capable STT provider **and** a media-stream-playable TTS provider (the configured one or a credentialed playable fallback). Inbound calls that fail the preflight receive `<Say>` setup-required copy plus `<Hangup/>` instead of a doomed stream; outbound placement fails before dialing via `preflightVoiceIngress()` with the same user-facing message.
618
620
 
619
621
  Key modules:
@@ -623,6 +625,7 @@ Key modules:
623
625
  | `src/calls/twilio-routes.ts` | Voice webhook handler; generates `<Connect><Stream>` TwiML, enforces the inbound credential preflight |
624
626
  | `src/calls/telephony-credential-preflight.ts` | Combined STT + TTS credential-readiness resolver |
625
627
  | `src/calls/media-stream-parser.ts` | Twilio Media Streams protocol parser |
628
+ | `src/calls/voice-metrics.ts` | Per-turn latency marks, shared with live voice |
626
629
  | `src/calls/media-turn-detector.ts` | Energy-based VAD turn detector for raw audio (batch mode) |
627
630
  | `src/calls/media-stream-stt-session.ts` | STT session — streaming/batch mode selection and transcription via `services.stt` |
628
631
  | `src/calls/media-stream-audio-transcode.ts` | Mu-law ↔ PCM16 codecs and resampling |
@@ -729,7 +732,7 @@ The assistant-side live voice module is intentionally bounded under `src/live-vo
729
732
  | `live-voice-session.ts` | Session orchestration: streaming STT, push-to-talk release, voice turn bridge callbacks, assistant text deltas, TTS, archive, metrics, interrupt, and close |
730
733
  | `live-voice-tts.ts` | Streaming TTS helper that resolves `services.tts`, requires `TtsProvider.synthesizeStream()`, and forwards audio chunks as `tts_audio` frames |
731
734
  | `live-voice-archive.ts` | Audio artifact creation/linking for user utterance and assistant response message IDs |
732
- | `live-voice-metrics.ts` | Per-session and per-turn latency snapshots emitted as `metrics` frames |
735
+ | `../calls/voice-metrics.ts` | Shared per-session and per-turn latency marks; live voice emits each snapshot as a `metrics` frame, phone calls log the per-turn aggregate |
733
736
 
734
737
  Live voice STT uses the same `resolveStreamingTranscriber()` path as conversation streaming, dialed with the provider the `liveVoice` STT role resolves to after managed-speech defaulting (`resolveEffectiveSpeechProviders`, see `config/managed-speech-defaults.ts`). For V1 latency-sensitive behavior, that provider must resolve to a `daemon-streaming` transcriber whose catalog entry has `conversationStreamingMode: "realtime-ws"` and usable credentials. Providers that only support batch or incremental-batch transcription remain valid for other voice surfaces, but do not satisfy live voice's streaming STT requirement.
735
738
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.2-dev.202609181515.7e76c92",
3
+ "version": "0.12.2-dev.202609181713.fb1ff61",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { beforeEach, describe, expect, test } from "bun:test";
8
8
 
9
- import type { AgentEvent } from "../agent/loop.js";
9
+ import type { AgentEvent, PreparedModelCall } from "../agent/loop.js";
10
10
  import { AgentLoop } from "../agent/loop.js";
11
11
  import type {
12
12
  PostModelCallContext,
@@ -18,12 +18,14 @@ import type {
18
18
  ContentBlock,
19
19
  Message,
20
20
  ProviderResponse,
21
+ ToolDefinition,
21
22
  } from "../providers/types.js";
22
23
  import {
23
24
  createMockProvider,
24
25
  textResponse,
25
26
  toolUseResponse,
26
27
  } from "./helpers/mock-provider.js";
28
+ import { setConfig } from "./helpers/set-config.js";
27
29
 
28
30
  const userMessage: Message = {
29
31
  role: "user",
@@ -91,6 +93,7 @@ function registerOutputHookPlugin(hooks: {
91
93
  describe("agent loop output hooks", () => {
92
94
  beforeEach(() => {
93
95
  resetPluginRegistryAndRegisterDefaults();
96
+ setConfig("llm", {});
94
97
  });
95
98
 
96
99
  test("post-model-call transforms the persisted message content", async () => {
@@ -403,6 +406,152 @@ describe("agent loop output hooks", () => {
403
406
  expect(calls[0].options?.config?.overrideProfile).toBe("fast-profile");
404
407
  });
405
408
 
409
+ test("prepared model call carries the post-hook route and wire tool surface", async () => {
410
+ registerOutputHookPlugin({
411
+ preModelCall: (ctx) => {
412
+ ctx.systemPrompt = `${ctx.systemPrompt ?? ""} [EDITED]`;
413
+ ctx.modelProfile = "fast-profile";
414
+ },
415
+ });
416
+ const tool: ToolDefinition = {
417
+ name: "dynamic_tool",
418
+ description: "Dynamic",
419
+ input_schema: { type: "object" },
420
+ };
421
+ const { provider, calls } = createMockProvider([textResponse("hi")]);
422
+ Object.assign(provider, { supportsNativeWebSearch: true });
423
+ const loop = new AgentLoop({
424
+ provider,
425
+ systemPrompt: "base prompt",
426
+ conversationId: "test-conversation",
427
+ config: { enableNativeWebSearch: true },
428
+ resolveTools: () => [tool],
429
+ });
430
+ let prepared: PreparedModelCall | undefined;
431
+
432
+ await loop.run({
433
+ requestId: "test-request",
434
+ messages: [userMessage],
435
+ onEvent: collect([]),
436
+ callSite: "mainAgent",
437
+ overrideProfile: "conversation-profile",
438
+ forceOverrideProfile: true,
439
+ onModelCallPrepared: (value) => {
440
+ prepared = value;
441
+ },
442
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
443
+ });
444
+
445
+ expect(prepared).toMatchObject({
446
+ callSite: "mainAgent",
447
+ overrideProfile: "fast-profile",
448
+ forceOverrideProfile: true,
449
+ systemPrompt: "base prompt [EDITED]",
450
+ });
451
+ expect(prepared?.tools.map((item) => item.name)).toEqual([
452
+ "dynamic_tool",
453
+ "web_search",
454
+ ]);
455
+ expect(calls[0].tools).toEqual(prepared?.tools);
456
+ });
457
+
458
+ test("prepared model call carries the finalized cache policy", async () => {
459
+ setConfig("llm", {
460
+ callSites: { mainAgent: { disableCache: true } },
461
+ });
462
+ const { provider } = createMockProvider([textResponse("hi")]);
463
+ const loop = new AgentLoop({
464
+ provider,
465
+ systemPrompt: "base prompt",
466
+ conversationId: "test-conversation",
467
+ });
468
+ let prepared: PreparedModelCall | undefined;
469
+
470
+ await loop.run({
471
+ requestId: "test-request",
472
+ messages: [userMessage],
473
+ onEvent: collect([]),
474
+ callSite: "mainAgent",
475
+ onModelCallPrepared: (value) => {
476
+ prepared = value;
477
+ },
478
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
479
+ });
480
+
481
+ expect(prepared?.disableCache).toBe(true);
482
+ });
483
+
484
+ test("prepared model call preserves explicit system-prompt removal", async () => {
485
+ registerOutputHookPlugin({
486
+ preModelCall: (ctx) => {
487
+ ctx.systemPrompt = null;
488
+ },
489
+ });
490
+ const { provider, calls } = createMockProvider([textResponse("hi")]);
491
+ const loop = new AgentLoop({
492
+ provider,
493
+ systemPrompt: "base prompt",
494
+ conversationId: "test-conversation",
495
+ });
496
+ let prepared: PreparedModelCall | undefined;
497
+
498
+ await loop.run({
499
+ requestId: "test-request",
500
+ messages: [userMessage],
501
+ onEvent: collect([]),
502
+ callSite: "mainAgent",
503
+ onModelCallPrepared: (value) => {
504
+ prepared = value;
505
+ },
506
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
507
+ });
508
+
509
+ expect(prepared?.systemPrompt).toBeNull();
510
+ expect(calls[0].options?.systemPrompt).toBeUndefined();
511
+ });
512
+
513
+ test("inference routing preserves the semantic call site for hooks and events", async () => {
514
+ const preModelCallSites: Array<string | null> = [];
515
+ const postModelCallSites: Array<string | null> = [];
516
+ registerOutputHookPlugin({
517
+ preModelCall: (ctx) => {
518
+ preModelCallSites.push(ctx.callSite);
519
+ },
520
+ postModelCall: (ctx) => {
521
+ postModelCallSites.push(ctx.callSite);
522
+ },
523
+ });
524
+ const { provider, calls } = createMockProvider([textResponse("hi")]);
525
+ const loop = new AgentLoop({
526
+ provider,
527
+ systemPrompt: "system",
528
+ conversationId: "test-conversation",
529
+ });
530
+ const events: AgentEvent[] = [];
531
+ let prepared: PreparedModelCall | undefined;
532
+
533
+ await loop.run({
534
+ requestId: "test-request",
535
+ messages: [userMessage],
536
+ onEvent: collect(events),
537
+ callSite: "callAgent",
538
+ inferenceCallSite: "mainAgent",
539
+ onModelCallPrepared: (value) => {
540
+ prepared = value;
541
+ },
542
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
543
+ });
544
+
545
+ expect(calls[0].options?.config?.callSite).toBe("mainAgent");
546
+ expect(prepared?.callSite).toBe("mainAgent");
547
+ expect(preModelCallSites).toEqual(["callAgent"]);
548
+ expect(postModelCallSites).toEqual(["callAgent"]);
549
+ expect(events).toContainEqual({
550
+ type: "llm_call_started",
551
+ callSite: "callAgent",
552
+ });
553
+ });
554
+
406
555
  test("pre-model-call seeds modelProfile from the resolved override and clearing it drops the override", async () => {
407
556
  // GIVEN a hook that observes the seeded override and then clears it
408
557
  let seeded: string | null | undefined;
@@ -102,6 +102,7 @@ describe("call-conversation-messages", () => {
102
102
  const conversationId = "conv-call-msg-failed";
103
103
  ensureConversation(conversationId);
104
104
  const session = createCallSession({
105
+ direction: "inbound",
105
106
  conversationId,
106
107
  provider: "twilio",
107
108
  fromNumber: "+15550001111",
@@ -122,6 +123,7 @@ describe("call-conversation-messages", () => {
122
123
  const conversationId = "conv-call-msg-cancelled";
123
124
  ensureConversation(conversationId);
124
125
  const session = createCallSession({
126
+ direction: "inbound",
125
127
  conversationId,
126
128
  provider: "twilio",
127
129
  fromNumber: "+15550001111",
@@ -142,6 +144,7 @@ describe("call-conversation-messages", () => {
142
144
  const conversationId = "conv-call-msg-completed";
143
145
  ensureConversation(conversationId);
144
146
  const session = createCallSession({
147
+ direction: "inbound",
145
148
  conversationId,
146
149
  provider: "twilio",
147
150
  fromNumber: "+15550001111",
@@ -171,6 +174,7 @@ describe("call-conversation-messages", () => {
171
174
  const conversationId = "conv-call-msg-fallback";
172
175
  ensureConversation(conversationId);
173
176
  const session = createCallSession({
177
+ direction: "inbound",
174
178
  conversationId,
175
179
  provider: "twilio",
176
180
  fromNumber: "+12025550101",
@@ -208,6 +212,7 @@ describe("call-conversation-messages", () => {
208
212
  const conversationId = "conv-call-msg-schema";
209
213
  ensureConversation(conversationId);
210
214
  const session = createCallSession({
215
+ direction: "inbound",
211
216
  conversationId,
212
217
  provider: "twilio",
213
218
  fromNumber: "+12025550101",
@@ -54,9 +54,14 @@ function resetTables() {
54
54
  ensuredConvIds = new Set();
55
55
  }
56
56
 
57
- function createTestCallSession(opts: Parameters<typeof createCallSession>[0]) {
57
+ function createTestCallSession(
58
+ opts: Omit<Parameters<typeof createCallSession>[0], "direction"> & {
59
+ direction?: Parameters<typeof createCallSession>[0]["direction"];
60
+ },
61
+ ) {
58
62
  ensureConversation(opts.conversationId);
59
- return createCallSession(opts);
63
+ // Direction only shapes the funnel stamp; these suites test other things.
64
+ return createCallSession({ direction: "inbound", ...opts });
60
65
  }
61
66
 
62
67
  /** Backdate a session's createdAt so it appears older than the grace period. */
@@ -327,6 +327,7 @@ describe("runtime call routes — HTTP layer", () => {
327
327
  ensureConversation("conv-get-1");
328
328
 
329
329
  const session = createCallSession({
330
+ direction: "outbound",
330
331
  conversationId: "conv-get-1",
331
332
  provider: "twilio",
332
333
  fromNumber: "+15550001111",
@@ -378,6 +379,7 @@ describe("runtime call routes — HTTP layer", () => {
378
379
  ensureConversation("conv-cancel-1");
379
380
 
380
381
  const session = createCallSession({
382
+ direction: "inbound",
381
383
  conversationId: "conv-cancel-1",
382
384
  provider: "twilio",
383
385
  fromNumber: "+15550001111",
@@ -407,6 +409,7 @@ describe("runtime call routes — HTTP layer", () => {
407
409
  ensureConversation("conv-cancel-2");
408
410
 
409
411
  const session = createCallSession({
412
+ direction: "inbound",
410
413
  conversationId: "conv-cancel-2",
411
414
  provider: "twilio",
412
415
  fromNumber: "+15550001111",
@@ -447,6 +450,7 @@ describe("runtime call routes — HTTP layer", () => {
447
450
  ensureConversation("conv-answer-badjson");
448
451
 
449
452
  const session = createCallSession({
453
+ direction: "inbound",
450
454
  conversationId: "conv-answer-badjson",
451
455
  provider: "twilio",
452
456
  fromNumber: "+15550001111",
@@ -469,6 +473,7 @@ describe("runtime call routes — HTTP layer", () => {
469
473
  ensureConversation("conv-answer-1");
470
474
 
471
475
  const session = createCallSession({
476
+ direction: "inbound",
472
477
  conversationId: "conv-answer-1",
473
478
  provider: "twilio",
474
479
  fromNumber: "+15550001111",
@@ -495,6 +500,7 @@ describe("runtime call routes — HTTP layer", () => {
495
500
  ensureConversation("conv-answer-2");
496
501
 
497
502
  const session = createCallSession({
503
+ direction: "inbound",
498
504
  conversationId: "conv-answer-2",
499
505
  provider: "twilio",
500
506
  fromNumber: "+15550001111",
@@ -517,6 +523,7 @@ describe("runtime call routes — HTTP layer", () => {
517
523
  ensureConversation("conv-answer-3");
518
524
 
519
525
  const session = createCallSession({
526
+ direction: "inbound",
520
527
  conversationId: "conv-answer-3",
521
528
  provider: "twilio",
522
529
  fromNumber: "+15550001111",
@@ -548,6 +555,7 @@ describe("runtime call routes — HTTP layer", () => {
548
555
  ensureConversation("conv-instr-badjson");
549
556
 
550
557
  const session = createCallSession({
558
+ direction: "inbound",
551
559
  conversationId: "conv-instr-badjson",
552
560
  provider: "twilio",
553
561
  fromNumber: "+15550001111",
@@ -570,6 +578,7 @@ describe("runtime call routes — HTTP layer", () => {
570
578
  ensureConversation("conv-instr-empty");
571
579
 
572
580
  const session = createCallSession({
581
+ direction: "inbound",
573
582
  conversationId: "conv-instr-empty",
574
583
  provider: "twilio",
575
584
  fromNumber: "+15550001111",
@@ -596,6 +605,7 @@ describe("runtime call routes — HTTP layer", () => {
596
605
  ensureConversation("conv-instr-missing");
597
606
 
598
607
  const session = createCallSession({
608
+ direction: "inbound",
599
609
  conversationId: "conv-instr-missing",
600
610
  provider: "twilio",
601
611
  fromNumber: "+15550001111",
@@ -640,6 +650,7 @@ describe("runtime call routes — HTTP layer", () => {
640
650
  ensureConversation("conv-instr-ended");
641
651
 
642
652
  const session = createCallSession({
653
+ direction: "inbound",
643
654
  conversationId: "conv-instr-ended",
644
655
  provider: "twilio",
645
656
  fromNumber: "+15550001111",
@@ -668,6 +679,7 @@ describe("runtime call routes — HTTP layer", () => {
668
679
  ensureConversation("conv-instr-no-orch");
669
680
 
670
681
  const session = createCallSession({
682
+ direction: "inbound",
671
683
  conversationId: "conv-instr-no-orch",
672
684
  provider: "twilio",
673
685
  fromNumber: "+15550001111",
@@ -53,9 +53,14 @@ function resetTables() {
53
53
  }
54
54
 
55
55
  /** Wrapper that ensures the FK conversation row exists before creating a session. */
56
- function createTestCallSession(opts: Parameters<typeof createCallSession>[0]) {
56
+ function createTestCallSession(
57
+ opts: Omit<Parameters<typeof createCallSession>[0], "direction"> & {
58
+ direction?: Parameters<typeof createCallSession>[0]["direction"];
59
+ },
60
+ ) {
57
61
  ensureConversation(opts.conversationId);
58
- return createCallSession(opts);
62
+ // Direction only shapes the funnel stamp; these suites test other things.
63
+ return createCallSession({ direction: "inbound", ...opts });
59
64
  }
60
65
 
61
66
  describe("call-store", () => {
@@ -169,6 +169,7 @@ describe("runAgentLoopImpl disk pressure gate", () => {
169
169
  const events: AssistantEvent[] = [];
170
170
  const activityStates: unknown[][] = [];
171
171
  const drainQueue = mock(async (_reason: unknown) => {});
172
+ const onFirstModelCallPrepared = mock(() => {});
172
173
  const ctx = makeCtx({
173
174
  emitActivityState: (...args: unknown[]) => {
174
175
  activityStates.push(args);
@@ -176,8 +177,12 @@ describe("runAgentLoopImpl disk pressure gate", () => {
176
177
  drainQueue,
177
178
  });
178
179
 
179
- await runAgentLoopImpl(ctx, "background task", "msg-1", (event) =>
180
- events.push(event),
180
+ await runAgentLoopImpl(
181
+ ctx,
182
+ "background task",
183
+ "msg-1",
184
+ (event) => events.push(event),
185
+ { onFirstModelCallPrepared },
181
186
  );
182
187
 
183
188
  expect(events.find((event) => event.type === "error")).toMatchObject({
@@ -195,5 +200,6 @@ describe("runAgentLoopImpl disk pressure gate", () => {
195
200
  expect(ctx.abortController).toBeNull();
196
201
  expect(ctx.currentRequestId).toBeUndefined();
197
202
  expect(drainQueue).toHaveBeenCalledWith("loop_complete");
203
+ expect(onFirstModelCallPrepared).not.toHaveBeenCalled();
198
204
  });
199
205
  });
@@ -24,7 +24,12 @@ import { getConversationDirName } from "../persistence/conversation-directories.
24
24
  import type { UserPromptSubmitContext } from "../plugin-api/types.js";
25
25
  import { resetPluginRegistryAndRegisterDefaults } from "../plugins/defaults/index.js";
26
26
  import { registerPlugin } from "../plugins/registry.js";
27
- import type { Message, Provider, ToolDefinition } from "../providers/types.js";
27
+ import type {
28
+ Message,
29
+ Provider,
30
+ SendMessageOptions,
31
+ ToolDefinition,
32
+ } from "../providers/types.js";
28
33
  import { ContextOverflowError } from "../providers/types.js";
29
34
  import {
30
35
  resolveUsageAttribution,
@@ -715,7 +720,7 @@ mock.module("../persistence/llm-request-log-store.js", () => ({
715
720
  // ── Imports (after mocks) ────────────────────────────────────────────
716
721
 
717
722
  import { AgentLoop } from "../agent/loop.js";
718
- import type { Conversation } from "../daemon/conversation.js";
723
+ import { Conversation } from "../daemon/conversation.js";
719
724
  import {
720
725
  applyCompactionResult,
721
726
  runAgentLoopImpl,
@@ -1100,6 +1105,82 @@ beforeEach(() => {
1100
1105
  resetPluginRegistryAndRegisterDefaults();
1101
1106
  });
1102
1107
 
1108
+ describe("prompt cache warming", () => {
1109
+ test("attributes provider usage to the conversation", async () => {
1110
+ const sendMessage = mock(
1111
+ async (_messages: Message[], _options?: SendMessageOptions) =>
1112
+ textResponse("unused"),
1113
+ );
1114
+ const conversation = Object.assign(
1115
+ Object.create(Conversation.prototype) as object,
1116
+ {
1117
+ conversationId: "conv-cache-warm-test",
1118
+ messages: [],
1119
+ provider: { name: "mock-provider", sendMessage },
1120
+ agentLoop: { getResolvedTools: () => [] },
1121
+ buildCurrentSystemPrompt: () => "system prompt",
1122
+ },
1123
+ ) as unknown as Conversation;
1124
+
1125
+ await conversation.warmPromptCache();
1126
+
1127
+ expect(sendMessage).toHaveBeenCalledTimes(1);
1128
+ expect(sendMessage.mock.calls[0]?.[1]?.config).toMatchObject({
1129
+ callSite: "mainAgent",
1130
+ conversationId: "conv-cache-warm-test",
1131
+ max_tokens: 16,
1132
+ selectionSeed: "conv-cache-warm-test",
1133
+ });
1134
+ expect(
1135
+ sendMessage.mock.calls[0]?.[1]?.config?.usageTracking,
1136
+ ).toBeUndefined();
1137
+ });
1138
+
1139
+ test("stays non-rejecting when request preparation fails", async () => {
1140
+ const sendMessage = mock(async () => textResponse("unused"));
1141
+ const conversation = Object.assign(
1142
+ Object.create(Conversation.prototype) as object,
1143
+ {
1144
+ conversationId: "conv-cache-warm-test",
1145
+ messages: [],
1146
+ provider: { name: "mock-provider", sendMessage },
1147
+ agentLoop: {
1148
+ getResolvedTools: () => {
1149
+ throw new Error("tool resolution failed");
1150
+ },
1151
+ },
1152
+ buildCurrentSystemPrompt: () => "system prompt",
1153
+ },
1154
+ ) as unknown as Conversation;
1155
+
1156
+ await expect(conversation.warmPromptCache()).resolves.toBeUndefined();
1157
+ expect(sendMessage).not.toHaveBeenCalled();
1158
+ });
1159
+
1160
+ test("does not rebuild a system prompt explicitly removed by a hook", async () => {
1161
+ const sendMessage = mock(
1162
+ async (_messages: Message[], _options?: SendMessageOptions) =>
1163
+ textResponse("unused"),
1164
+ );
1165
+ const buildCurrentSystemPrompt = mock(() => "rebuilt prompt");
1166
+ const conversation = Object.assign(
1167
+ Object.create(Conversation.prototype) as object,
1168
+ {
1169
+ conversationId: "conv-cache-warm-test",
1170
+ messages: [],
1171
+ provider: { name: "mock-provider", sendMessage },
1172
+ agentLoop: { getResolvedTools: () => [] },
1173
+ buildCurrentSystemPrompt,
1174
+ },
1175
+ ) as unknown as Conversation;
1176
+
1177
+ await conversation.warmPromptCache({ systemPrompt: null, tools: [] });
1178
+
1179
+ expect(buildCurrentSystemPrompt).not.toHaveBeenCalled();
1180
+ expect(sendMessage.mock.calls[0]?.[1]?.systemPrompt).toBeUndefined();
1181
+ });
1182
+ });
1183
+
1103
1184
  describe("session-agent-loop", () => {
1104
1185
  describe("user-prompt-submit hook failures", () => {
1105
1186
  test("passes the effective profile to hooks even when it was already announced", async () => {
@@ -2165,6 +2246,43 @@ describe("session-agent-loop", () => {
2165
2246
  const call = recordRequestLogMock.mock.calls[0] as unknown as unknown[];
2166
2247
  expect(call[5]).toBe("callAgent");
2167
2248
  });
2249
+
2250
+ test("reports only the first finalized model call", async () => {
2251
+ const tool: ToolDefinition = {
2252
+ name: "echo",
2253
+ description: "Echo",
2254
+ input_schema: { type: "object" },
2255
+ };
2256
+ const onFirstModelCallPrepared = mock(() => {});
2257
+ const ctx = makeCtx({
2258
+ providerResponses: [
2259
+ toolUseResponse("tool-1", "echo", {}),
2260
+ textResponse("done"),
2261
+ ],
2262
+ loopTools: [tool],
2263
+ toolExecutor: async () => ({ content: "ok", isError: false }),
2264
+ });
2265
+ const turnSignal = ctx.abortController?.signal;
2266
+
2267
+ await runAgentLoopImpl(ctx, "hello", "msg-1", () => {}, {
2268
+ callSite: "callAgent",
2269
+ inferenceCallSite: "mainAgent",
2270
+ overrideProfile: "quality-optimized",
2271
+ forceOverrideProfile: true,
2272
+ onFirstModelCallPrepared,
2273
+ });
2274
+
2275
+ expect(onFirstModelCallPrepared).toHaveBeenCalledTimes(1);
2276
+ expect(onFirstModelCallPrepared).toHaveBeenCalledWith({
2277
+ callSite: "mainAgent",
2278
+ overrideProfile: "quality-optimized",
2279
+ forceOverrideProfile: true,
2280
+ signal: turnSignal,
2281
+ systemPrompt: "system prompt",
2282
+ tools: [tool],
2283
+ });
2284
+ expect(ctx.currentCallSite).toBe("callAgent");
2285
+ });
2168
2286
  });
2169
2287
 
2170
2288
  describe("usage accounting", () => {
@@ -153,6 +153,7 @@ describe("Call session mode metadata", () => {
153
153
 
154
154
  const { conversationId } = getOrCreateConversation("test-conv-mode");
155
155
  const session = createCallSession({
156
+ direction: "inbound",
156
157
  conversationId,
157
158
  provider: "twilio",
158
159
  fromNumber: "+15551234567",
@@ -181,6 +182,7 @@ describe("Call session mode metadata", () => {
181
182
  "test-conv-mode-default",
182
183
  );
183
184
  const session = createCallSession({
185
+ direction: "inbound",
184
186
  conversationId,
185
187
  provider: "twilio",
186
188
  fromNumber: "+15551234567",