@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.
- package/ARCHITECTURE.md +4 -1
- package/package.json +1 -1
- package/src/__tests__/agent-loop-output-hooks.test.ts +150 -1
- package/src/__tests__/call-conversation-messages.test.ts +5 -0
- package/src/__tests__/call-recovery.test.ts +7 -2
- package/src/__tests__/call-routes-http.test.ts +12 -0
- package/src/__tests__/call-store.test.ts +7 -2
- package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +8 -2
- package/src/__tests__/conversation-agent-loop.test.ts +120 -2
- package/src/__tests__/deterministic-verification-control-plane.test.ts +2 -0
- package/src/__tests__/guardian-dispatch.test.ts +13 -0
- package/src/__tests__/notification-guardian-path.test.ts +7 -0
- package/src/__tests__/twilio-routes.test.ts +1 -0
- package/src/agent/loop.ts +76 -11
- package/src/calls/__tests__/call-controller.test.ts +94 -0
- package/src/calls/__tests__/call-funnel-telemetry.test.ts +204 -0
- package/src/{live-voice/__tests__/live-voice-metrics.test.ts → calls/__tests__/voice-metrics.test.ts} +32 -38
- package/src/calls/__tests__/voice-session-bridge.test.ts +342 -14
- package/src/calls/call-controller.ts +177 -1
- package/src/calls/call-domain.ts +4 -0
- package/src/calls/call-store.ts +87 -2
- package/src/calls/voice-escalation-target.ts +11 -0
- package/src/{live-voice/live-voice-metrics.ts → calls/voice-metrics.ts} +94 -87
- package/src/calls/voice-session-bridge.ts +140 -51
- package/src/daemon/conversation-agent-loop.ts +46 -18
- package/src/daemon/conversation.ts +80 -32
- package/src/live-voice/__tests__/live-activity-reporter.test.ts +154 -15
- package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +172 -0
- package/src/live-voice/live-activity-reporter.ts +115 -6
- package/src/live-voice/live-voice-session.ts +106 -31
- package/src/live-voice/protocol.ts +10 -2
- package/src/onboarding/onboarding-events-store.ts +99 -12
- package/src/plugin-api/vision-support.test.ts +22 -0
- package/src/plugin-api/vision-support.ts +17 -5
- package/src/runtime/routes/conversation-routes.ts +1 -1
- package/src/telemetry/__tests__/phone-call-funnel.test.ts +72 -0
- 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
|
-
|
|
|
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
|
@@ -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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
180
|
-
|
|
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 {
|
|
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
|
|
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",
|