@vellumai/assistant 0.9.0-dev.202606181324.0804a69 → 0.9.0-dev.202606181513.fd39213
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/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +4 -9
- package/package.json +1 -1
- package/src/__tests__/cancel-clears-processing.test.ts +22 -19
- package/src/__tests__/conversation-agent-loop.test.ts +48 -41
- package/src/__tests__/media-stream-server-integration.test.ts +317 -13
- package/src/__tests__/relay-server.test.ts +285 -0
- package/src/__tests__/twilio-routes.test.ts +81 -1
- package/src/calls/__tests__/channel-admission-reader.test.ts +132 -0
- package/src/calls/__tests__/relay-setup-router.test.ts +350 -0
- package/src/calls/channel-admission-reader.ts +104 -0
- package/src/calls/media-stream-server.ts +84 -2
- package/src/calls/relay-server.ts +66 -0
- package/src/calls/relay-setup-router.ts +77 -0
- package/src/calls/twilio-routes.ts +17 -8
- package/src/daemon/conversation-agent-loop.ts +122 -56
- package/src/daemon/conversation.ts +7 -0
- package/src/daemon/handlers/conversations.ts +6 -8
- package/src/runtime/routes/inbound-stages/admission-policy.test.ts +4 -2
- package/src/telemetry/usage-telemetry-reporter.test.ts +59 -13
- package/src/telemetry/usage-telemetry-reporter.ts +28 -16
|
@@ -53,20 +53,15 @@ export const ADMISSION_FLOOR: Record<AdmissionPolicy, number> = {
|
|
|
53
53
|
*
|
|
54
54
|
* `platform` / `a2a` are peer/internal channels with no human-trust model.
|
|
55
55
|
*
|
|
56
|
-
*
|
|
57
|
-
* (twilio-voice-webhook → relay-setup-router) does not yet read
|
|
58
|
-
* AdmissionPolicyStore / sourceMetadata.admissionPolicy. Storing a policy for
|
|
59
|
-
* `phone` would have no runtime effect, so we exclude it from the API surface
|
|
60
|
-
* until voice ingress is wired in a follow-up PR. Remove `"phone"` from this
|
|
61
|
-
* set once the voice path enforces admission.
|
|
56
|
+
* `phone` is NOT exempt — voice ingress enforces the admission floor.
|
|
62
57
|
*
|
|
63
|
-
* `vellum`
|
|
64
|
-
* hidden from the configurable UI; see
|
|
58
|
+
* `vellum` / `whatsapp` are NOT exempt — their floors are still enforced at
|
|
59
|
+
* runtime — but they are hidden from the configurable UI; see
|
|
60
|
+
* {@link ADMISSION_POLICY_HIDDEN_CHANNELS}.
|
|
65
61
|
*/
|
|
66
62
|
export const ADMISSION_POLICY_EXEMPT_CHANNELS: ReadonlySet<string> = new Set([
|
|
67
63
|
"platform",
|
|
68
64
|
"a2a",
|
|
69
|
-
"phone",
|
|
70
65
|
]);
|
|
71
66
|
|
|
72
67
|
export function isAdmissionPolicyExemptChannel(channelType: string): boolean {
|
package/package.json
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* Cancel contract: a user cancel raises the conversation's AbortController and
|
|
3
|
+
* defers clearing the `processing` flag to the in-flight turn's `finally`.
|
|
4
|
+
*
|
|
5
|
+
* `cancelGeneration` no longer force-clears `processing` itself. Abort now
|
|
6
|
+
* propagates into the provider call and tool execution — and is backed by the
|
|
7
|
+
* agent loop's abort watchdog — so a cancelled turn always reaches its
|
|
8
|
+
* `finally` within a bounded time and tears down its own state there. The
|
|
9
|
+
* watchdog-driven path (turn reaches `finally`, processing clears) is covered
|
|
10
|
+
* by `conversation-agent-loop.test.ts`; this file pins the handler-side
|
|
11
|
+
* contract: cancel signals abort and leaves the flag to the turn.
|
|
8
12
|
*/
|
|
9
13
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
10
14
|
|
|
@@ -15,18 +19,18 @@ import {
|
|
|
15
19
|
} from "../daemon/conversation-registry.js";
|
|
16
20
|
import { cancelGeneration } from "../daemon/handlers/conversations.js";
|
|
17
21
|
|
|
18
|
-
interface
|
|
22
|
+
interface CancelledConversation {
|
|
19
23
|
isProcessing: () => boolean;
|
|
20
24
|
setProcessingCalls: () => boolean[];
|
|
21
25
|
abortCount: () => number;
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
/**
|
|
25
|
-
* Register a conversation whose in-flight turn is
|
|
26
|
-
*
|
|
27
|
-
*
|
|
29
|
+
* Register a conversation whose in-flight turn is processing. The fake records
|
|
30
|
+
* abort calls and any `setProcessing` writes so the test can assert that
|
|
31
|
+
* `cancelGeneration` signals abort without flipping the flag itself.
|
|
28
32
|
*/
|
|
29
|
-
function
|
|
33
|
+
function registerProcessingTurn(id: string): CancelledConversation {
|
|
30
34
|
let processing = true;
|
|
31
35
|
let abortCount = 0;
|
|
32
36
|
const setProcessingCalls: boolean[] = [];
|
|
@@ -55,10 +59,9 @@ describe("cancelGeneration", () => {
|
|
|
55
59
|
deleteConversation(conversationId);
|
|
56
60
|
});
|
|
57
61
|
|
|
58
|
-
test("
|
|
62
|
+
test("raises abort and defers clearing processing to the turn's finally", () => {
|
|
59
63
|
// GIVEN a registered conversation that is processing
|
|
60
|
-
|
|
61
|
-
const fake = registerWedgedTurn(conversationId);
|
|
64
|
+
const fake = registerProcessingTurn(conversationId);
|
|
62
65
|
expect(fake.isProcessing()).toBe(true);
|
|
63
66
|
|
|
64
67
|
// WHEN the user cancels generation
|
|
@@ -66,12 +69,12 @@ describe("cancelGeneration", () => {
|
|
|
66
69
|
|
|
67
70
|
// THEN the cancel is acknowledged
|
|
68
71
|
expect(cancelled).toBe(true);
|
|
69
|
-
// AND the abort signal is
|
|
72
|
+
// AND the abort signal is raised on the conversation
|
|
70
73
|
expect(fake.abortCount()).toBe(1);
|
|
71
|
-
// AND the
|
|
72
|
-
//
|
|
73
|
-
expect(fake.setProcessingCalls()).toContain(false);
|
|
74
|
-
expect(fake.isProcessing()).toBe(
|
|
74
|
+
// AND cancelGeneration does NOT force-clear the flag itself — the in-flight
|
|
75
|
+
// turn's `finally` owns that teardown once abort drives it there.
|
|
76
|
+
expect(fake.setProcessingCalls()).not.toContain(false);
|
|
77
|
+
expect(fake.isProcessing()).toBe(true);
|
|
75
78
|
});
|
|
76
79
|
|
|
77
80
|
test("returns false for a conversation that is not registered", () => {
|
|
@@ -1749,55 +1749,62 @@ describe("session-agent-loop", () => {
|
|
|
1749
1749
|
expect(drainReason).toBe("loop_complete");
|
|
1750
1750
|
});
|
|
1751
1751
|
|
|
1752
|
-
test("
|
|
1753
|
-
// GIVEN a
|
|
1754
|
-
// (
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
//
|
|
1760
|
-
//
|
|
1761
|
-
|
|
1752
|
+
test("abort watchdog drives a wedged turn to its finally", async () => {
|
|
1753
|
+
// GIVEN a provider whose call wedges: it acknowledges the user cancel
|
|
1754
|
+
// (aborts the signal) but its promise never settles and never observes
|
|
1755
|
+
// the signal — the exact condition that latched `processing` true.
|
|
1756
|
+
const events: ServerMessage[] = [];
|
|
1757
|
+
const abortController = new AbortController();
|
|
1758
|
+
let drainReason: string | undefined;
|
|
1759
|
+
// The provider's call wedges on this promise. It settles only on test
|
|
1760
|
+
// teardown so the abandoned `run()` can unwind cleanly instead of leaking
|
|
1761
|
+
// background work (e.g. partial-persist debounce timers) into later tests.
|
|
1762
|
+
let releaseHang: (reason: unknown) => void = () => {};
|
|
1763
|
+
const hang = new Promise<never>((_, reject) => {
|
|
1764
|
+
releaseHang = reject;
|
|
1765
|
+
});
|
|
1762
1766
|
const provider: Provider = {
|
|
1763
1767
|
name: "mock-provider",
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
conversation.currentRequestId = "newer-turn-req";
|
|
1770
|
-
}
|
|
1771
|
-
options?.onEvent?.({ type: "text_delta", text: "done" });
|
|
1772
|
-
return {
|
|
1773
|
-
content: [{ type: "text", text: "done" }],
|
|
1774
|
-
model: "mock-model",
|
|
1775
|
-
usage: { inputTokens: 0, outputTokens: 0 },
|
|
1776
|
-
stopReason: "end_turn",
|
|
1777
|
-
};
|
|
1768
|
+
sendMessage(_messages, _options) {
|
|
1769
|
+
abortController.abort();
|
|
1770
|
+
// Never observes the signal — the exact condition that latched
|
|
1771
|
+
// `processing` true before the watchdog existed.
|
|
1772
|
+
return hang;
|
|
1778
1773
|
},
|
|
1779
1774
|
};
|
|
1780
1775
|
const ctx = makeCtx({
|
|
1781
1776
|
loopProvider: provider,
|
|
1782
|
-
|
|
1783
|
-
|
|
1777
|
+
abortController,
|
|
1778
|
+
// Fire the watchdog quickly instead of the ~45s production default.
|
|
1779
|
+
abortWatchdogMs: 30,
|
|
1780
|
+
drainQueue: (reason: string) => {
|
|
1781
|
+
drainReason = reason;
|
|
1784
1782
|
},
|
|
1785
|
-
});
|
|
1786
|
-
ctxHolder.conversation = ctx;
|
|
1787
|
-
|
|
1788
|
-
// WHEN the superseded turn runs to completion
|
|
1789
|
-
await runAgentLoopImpl(ctx, "hi", "msg-1", () => {});
|
|
1783
|
+
} as unknown as Partial<Conversation>);
|
|
1790
1784
|
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1785
|
+
try {
|
|
1786
|
+
// WHEN the orchestrator runs the turn
|
|
1787
|
+
await runAgentLoopImpl(ctx, "hi", "msg-1", (msg) => events.push(msg));
|
|
1788
|
+
|
|
1789
|
+
// THEN the watchdog forces the turn to its finally: processing clears,
|
|
1790
|
+
// the abort controller is torn down, the queue drains, and the user
|
|
1791
|
+
// sees a cancellation (not an error).
|
|
1792
|
+
expect(ctx.isProcessing()).toBe(false);
|
|
1793
|
+
expect(ctx.abortController).toBeNull();
|
|
1794
|
+
expect(drainReason).toBe("loop_complete");
|
|
1795
|
+
expect(
|
|
1796
|
+
events.find((e) => e.type === "generation_cancelled"),
|
|
1797
|
+
).toBeDefined();
|
|
1798
|
+
expect(
|
|
1799
|
+
events.find((e) => e.type === "conversation_error"),
|
|
1800
|
+
).toBeUndefined();
|
|
1801
|
+
} finally {
|
|
1802
|
+
// Let the abandoned run() reject and unwind, then flush microtasks.
|
|
1803
|
+
releaseHang(
|
|
1804
|
+
new DOMException("The operation was aborted", "AbortError"),
|
|
1805
|
+
);
|
|
1806
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1807
|
+
}
|
|
1801
1808
|
});
|
|
1802
1809
|
});
|
|
1803
1810
|
|