@vellumai/assistant 0.11.5 → 0.11.6-staging.1
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/AGENTS.md +5 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +70 -0
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +16 -1
- package/node_modules/@vellumai/gateway-client/src/index.ts +6 -2
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +121 -61
- package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/openapi.yaml +421 -15
- package/package.json +1 -1
- package/scripts/sync-web-search-catalog.ts +6 -0
- package/src/__tests__/app-pin-store.test.ts +149 -0
- package/src/__tests__/channel-availability-routes.test.ts +23 -1
- package/src/__tests__/channel-readiness-discord.test.ts +231 -0
- package/src/__tests__/channel-readiness-service.test.ts +126 -0
- package/src/__tests__/channel-readiness-slack-remote.test.ts +141 -0
- package/src/__tests__/channel-reply-delivery.test.ts +4 -4
- package/src/__tests__/client-os-metadata-persistence.test.ts +23 -10
- package/src/__tests__/conversation-delete-watch-timeline.test.ts +231 -0
- package/src/__tests__/conversation-error.test.ts +17 -0
- package/src/__tests__/conversation-seed-composer.test.ts +8 -0
- package/src/__tests__/conversation-slash-commands.test.ts +8 -0
- package/src/__tests__/disk-pressure-policy.test.ts +6 -0
- package/src/__tests__/gemini-provider.test.ts +138 -0
- package/src/__tests__/history-repair.test.ts +105 -3
- package/src/__tests__/identity-routes.test.ts +1 -0
- package/src/__tests__/llm-catalog-parity.test.ts +45 -0
- package/src/__tests__/migration-import-from-path.test.ts +349 -0
- package/src/__tests__/notification-telegram-adapter.test.ts +102 -0
- package/src/__tests__/oauth-commands-routes.test.ts +89 -0
- package/src/__tests__/oauth-provider-profiles.test.ts +7 -6
- package/src/__tests__/openai-provider.test.ts +18 -0
- package/src/__tests__/openai-responses-provider.test.ts +18 -0
- package/src/__tests__/platform-callback-registration.test.ts +184 -0
- package/src/__tests__/plugin-api-store-credential.test.ts +71 -3
- package/src/__tests__/pricing.test.ts +2 -2
- package/src/__tests__/public-ingress-urls.test.ts +36 -0
- package/src/__tests__/resolve-trust-class.test.ts +0 -48
- package/src/__tests__/sanitize-config-for-transfer.test.ts +28 -0
- package/src/__tests__/secret-routes-platform-proxy.test.ts +49 -0
- package/src/__tests__/settings-routes.test.ts +85 -3
- package/src/__tests__/web-search-catalog-parity.test.ts +8 -0
- package/src/agent/history-repair/history-repair.ts +45 -14
- package/src/agent/loop.ts +4 -1
- package/src/api/constants/profile-config-validation.ts +60 -0
- package/src/api/events/tool-result.ts +6 -1
- package/src/api/events/watch-retro-completed.ts +52 -0
- package/src/api/index.ts +11 -0
- package/src/apps/app-pin-reconciler.ts +92 -0
- package/src/apps/app-pin-store.ts +125 -0
- package/src/channels/gateway-channel-socket-health.ts +32 -0
- package/src/channels/gateway-discord-admission.ts +32 -0
- package/src/channels/types.ts +20 -0
- package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -1
- package/src/cli/commands/__tests__/inference-profiles.test.ts +16 -4
- package/src/cli/commands/__tests__/inference-providers.test.ts +67 -2
- package/src/cli/commands/channels/__tests__/channels.test.ts +85 -0
- package/src/cli/commands/channels/index.ts +45 -31
- package/src/cli/commands/inference-profiles.ts +56 -3
- package/src/cli/commands/inference-providers.ts +28 -2
- package/src/cli/commands/oauth/index.help.ts +7 -1
- package/src/cli/commands/oauth/request.test.ts +290 -0
- package/src/cli/commands/oauth/request.ts +57 -41
- package/src/cli/lib/bundled-marketplace.json +14 -1
- package/src/cli/lib/open-browser.test.ts +67 -0
- package/src/cli/lib/open-browser.ts +24 -5
- package/src/config/__tests__/profile-materialization.test.ts +26 -0
- package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +6 -0
- package/src/config/bundled-skills/schedule/SKILL.md +1 -1
- package/src/config/feature-flag-registry.json +17 -1
- package/src/config/profile-materialization.ts +29 -0
- package/src/config/sanitize-for-transfer.ts +16 -0
- package/src/config/schemas/llm.ts +7 -0
- package/src/config/schemas/services.ts +6 -0
- package/src/context/outbound-sanitize.ts +6 -0
- package/src/daemon/__tests__/lifecycle-watch-timeline-sweep.test.ts +98 -0
- package/src/daemon/conversation-error.ts +24 -2
- package/src/daemon/conversation-slash.ts +6 -15
- package/src/daemon/daemon-control.ts +1 -0
- package/src/daemon/disk-pressure-policy.ts +7 -1
- package/src/daemon/handlers/__tests__/config-ingress-tunnel-records.test.ts +208 -0
- package/src/daemon/handlers/config-ingress.ts +115 -5
- package/src/daemon/lifecycle.ts +26 -0
- package/src/daemon/message-types/web-activity.ts +3 -2
- package/src/daemon/trust-context.ts +0 -37
- package/src/inbound/__tests__/tunnel-probe.test.ts +448 -0
- package/src/inbound/platform-callback-registration.ts +28 -2
- package/src/inbound/public-ingress-urls.ts +12 -0
- package/src/inbound/tunnel-probe.ts +261 -0
- package/src/live-voice/__tests__/live-voice-connection.test.ts +25 -0
- package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +8 -2
- package/src/live-voice/__tests__/live-voice-session-manager.test.ts +212 -10
- package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +5 -2
- package/src/live-voice/live-voice-connection.ts +46 -8
- package/src/live-voice/live-voice-manager.ts +25 -0
- package/src/live-voice/live-voice-session-manager.ts +318 -2
- package/src/live-voice/live-voice-session.ts +52 -2
- package/src/messaging/providers/__tests__/transport-dispatch.test.ts +126 -68
- package/src/messaging/providers/channel-transport.ts +64 -47
- package/src/messaging/providers/discord/send.test.ts +46 -1
- package/src/messaging/providers/discord/send.ts +51 -0
- package/src/messaging/providers/discord/transport.ts +26 -3
- package/src/messaging/providers/index.ts +22 -47
- package/src/messaging/providers/slack/send.test.ts +83 -26
- package/src/messaging/providers/slack/send.ts +120 -51
- package/src/messaging/providers/slack/stream-tasks.test.ts +26 -0
- package/src/messaging/providers/slack/stream-tasks.ts +39 -0
- package/src/messaging/providers/slack/transport.ts +24 -22
- package/src/messaging/providers/telegram-bot/send.test.ts +109 -12
- package/src/messaging/providers/telegram-bot/send.ts +43 -0
- package/src/messaging/providers/telegram-bot/transport.ts +25 -8
- package/src/notifications/__tests__/assistant-reply-producer.test.ts +30 -7
- package/src/notifications/adapters/telegram.ts +48 -1
- package/src/notifications/assistant-reply-producer.ts +7 -7
- package/src/notifications/conversation-seed-composer.ts +7 -2
- package/src/oauth/byo-connection.test.ts +63 -0
- package/src/oauth/byo-connection.ts +16 -15
- package/src/oauth/connection.test.ts +111 -0
- package/src/oauth/connection.ts +142 -1
- package/src/oauth/platform-connection.test.ts +34 -0
- package/src/oauth/platform-connection.ts +28 -5
- package/src/oauth/seed-providers.ts +15 -1
- package/src/permissions/types.ts +3 -1
- package/src/persistence/conversation-crud.ts +46 -0
- package/src/persistence/conversation-types.ts +11 -9
- package/src/persistence/db-async-query.ts +2 -1
- package/src/persistence/db-maintenance.ts +15 -0
- package/src/persistence/embeddings/qdrant-manager.ts +1 -0
- package/src/persistence/migrations/367-create-watch-timeline-entries.ts +46 -0
- package/src/persistence/migrations/368-watch-timeline-screenshot-blob.ts +33 -0
- package/src/persistence/migrations/369-create-app-pins.ts +37 -0
- package/src/persistence/migrations/__tests__/367-create-watch-timeline-entries.test.ts +98 -0
- package/src/persistence/migrations/__tests__/368-watch-timeline-screenshot-blob.test.ts +98 -0
- package/src/persistence/schema/index.ts +1 -0
- package/src/persistence/schema/infrastructure.ts +17 -0
- package/src/persistence/schema/watch.ts +29 -0
- package/src/persistence/steps.ts +6 -0
- package/src/plugins/mtime-cache.ts +11 -0
- package/src/providers/__tests__/retry-network-error.test.ts +84 -0
- package/src/providers/connection-resolution.ts +23 -1
- package/src/providers/content-blocks.ts +9 -0
- package/src/providers/fetch-provider-catalog.ts +19 -0
- package/src/providers/gemini/client.ts +13 -5
- package/src/providers/inference/__tests__/endpoint-probe.test.ts +92 -0
- package/src/providers/inference/__tests__/profile-config-validation.test.ts +39 -0
- package/src/providers/inference/__tests__/profile-probe-classify.test.ts +66 -0
- package/src/providers/inference/adapter-factory.ts +0 -9
- package/src/providers/inference/credential-rotation.ts +61 -0
- package/src/providers/inference/endpoint-probe.ts +115 -0
- package/src/providers/inference/profile-probe.ts +256 -0
- package/src/providers/model-catalog.ts +170 -125
- package/src/providers/openai/__tests__/api-error-normalization.test.ts +17 -1
- package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +42 -60
- package/src/providers/openai/__tests__/connection-error-wrap.test.ts +44 -0
- package/src/providers/openai/__tests__/orphan-tool-result-guard.test.ts +34 -2
- package/src/providers/openai/api-error-normalization.ts +16 -2
- package/src/providers/openai/chat-completions-provider.ts +75 -29
- package/src/providers/openai/responses-provider.ts +5 -2
- package/src/providers/openrouter/client.ts +0 -1
- package/src/providers/provider-send-message.ts +11 -0
- package/src/providers/retry.ts +6 -0
- package/src/providers/search-provider-catalog.ts +20 -0
- package/src/providers/vercel-ai-gateway/client.ts +0 -1
- package/src/runtime/AGENTS.md +1 -0
- package/src/runtime/__tests__/desktop-presence.test.ts +27 -4
- package/src/runtime/__tests__/host-observe.test.ts +302 -0
- package/src/runtime/channel-readiness-service.ts +214 -14
- package/src/runtime/channel-readiness-types.ts +49 -2
- package/src/runtime/channel-reply-delivery.ts +2 -2
- package/src/runtime/desktop-presence.ts +24 -21
- package/src/runtime/host-observe.ts +246 -0
- package/src/runtime/http-server.ts +181 -1
- package/src/runtime/migrations/__tests__/staged-import-path.test.ts +104 -0
- package/src/runtime/migrations/staged-import-path.ts +116 -0
- package/src/runtime/routes/__tests__/app-pin-routes.test.ts +383 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +80 -0
- package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +118 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +20 -0
- package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +508 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +35 -56
- package/src/runtime/routes/__tests__/watch-routes-guardian-cache.test.ts +139 -0
- package/src/runtime/routes/__tests__/watch-routes.test.ts +598 -0
- package/src/runtime/routes/app-management-routes.ts +140 -29
- package/src/runtime/routes/channel-availability-routes.ts +1 -0
- package/src/runtime/routes/channel-readiness-routes.ts +14 -2
- package/src/runtime/routes/conversation-query-routes.ts +10 -0
- package/src/runtime/routes/guardian-approval-interception.ts +24 -33
- package/src/runtime/routes/host-cu-routes.ts +18 -0
- package/src/runtime/routes/identity-routes.ts +2 -0
- package/src/runtime/routes/inbound-message-handler.ts +10 -7
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +166 -308
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +158 -335
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/inference-profiles-routes.ts +232 -31
- package/src/runtime/routes/inference-provider-connection-routes.ts +24 -4
- package/src/runtime/routes/ingress-status-routes.ts +180 -0
- package/src/runtime/routes/live-voice-routes.test.ts +40 -1
- package/src/runtime/routes/live-voice-routes.ts +34 -0
- package/src/runtime/routes/migration-routes.ts +218 -10
- package/src/runtime/routes/oauth-commands-routes.ts +23 -16
- package/src/runtime/routes/plugins-routes.ts +12 -28
- package/src/runtime/routes/question-routes.ts +6 -0
- package/src/runtime/routes/secret-routes.ts +7 -27
- package/src/runtime/routes/settings-routes.ts +9 -6
- package/src/runtime/routes/watch-routes.ts +807 -0
- package/src/runtime/slack-reply-session.test.ts +230 -121
- package/src/runtime/slack-reply-session.ts +113 -81
- package/src/runtime/{slack-task-progress.test.ts → task-progress.test.ts} +1 -28
- package/src/runtime/{slack-task-progress.ts → task-progress.ts} +30 -51
- package/src/security/__tests__/untrusted-content.test.ts +42 -0
- package/src/security/untrusted-content.ts +28 -9
- package/src/telemetry/__tests__/live-voice-funnel.test.ts +108 -0
- package/src/telemetry/live-voice-funnel.ts +75 -8
- package/src/tools/credentials/store.ts +18 -6
- package/src/tools/network/__tests__/firecrawl-compat.test.ts +77 -0
- package/src/tools/network/__tests__/web-fetch-fastcrw.test.ts +169 -0
- package/src/tools/network/__tests__/web-search.test.ts +97 -2
- package/src/tools/network/firecrawl-compat.ts +90 -0
- package/src/tools/network/web-fetch.ts +142 -62
- package/src/tools/network/web-search.ts +141 -55
- package/src/tools/types.ts +2 -1
- package/src/util/oauth-request-body.test.ts +74 -0
- package/src/util/oauth-request-body.ts +60 -0
- package/src/util/worker-process.ts +1 -0
- package/src/watch/__tests__/watch-retro.test.ts +665 -0
- package/src/watch/__tests__/watch-session-manager.test.ts +566 -0
- package/src/watch/__tests__/watch-timeline.test.ts +670 -0
- package/src/watch/watch-retro.ts +480 -0
- package/src/watch/watch-session-manager.ts +575 -0
- package/src/watch/watch-timeline.ts +848 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reachability probe for the public URL a tunnel exposes.
|
|
3
|
+
*
|
|
4
|
+
* A tunnel's public URL is recorded in the workspace config, but a recorded
|
|
5
|
+
* URL says nothing about whether anything still answers on it: the tunnel
|
|
6
|
+
* process can die, the nginx edge can stay up while the gateway behind it is
|
|
7
|
+
* down, and a URL can end up fronting a different assistant entirely. The
|
|
8
|
+
* probe asks the edge two questions at once, and the two answers carry very
|
|
9
|
+
* different weight:
|
|
10
|
+
*
|
|
11
|
+
* - `GET /healthz` is the liveness verdict. Every ingress shape answers it:
|
|
12
|
+
* the nginx edge proxies it to the gateway, and a tunnel pointed straight
|
|
13
|
+
* at the gateway port serves it from the gateway itself. Anything but a
|
|
14
|
+
* 2xx means the URL is not usable at all, which is `unreachable`.
|
|
15
|
+
* - `GET /assistant/__config` decides whether that live edge is one a device
|
|
16
|
+
* can pair against. Only the nginx remote-web SPA edge serves it
|
|
17
|
+
* (`buildRemoteWebIngressLocations` in `cli/src/lib/nginx-ingress.ts`),
|
|
18
|
+
* and that is the same edge serving `/assistant/pair`, so an edge that
|
|
19
|
+
* answers the config request with anything but that edge's own config is
|
|
20
|
+
* an edge whose pair URL would 404. That is `unpairable`: alive, and no
|
|
21
|
+
* use to a card whose whole job is pairing. A tunnel pointed straight at
|
|
22
|
+
* the gateway, a bring-your-own HTTPS front that only proxies the gateway,
|
|
23
|
+
* and a catch-all that answers every path with some JSON all land there.
|
|
24
|
+
* A config request that gets no answer at all, because it lost the shared
|
|
25
|
+
* deadline or the connection dropped under it, proves nothing either way,
|
|
26
|
+
* and the live `/healthz` stands.
|
|
27
|
+
*
|
|
28
|
+
* The served config also carries the assistant's id, which is what separates
|
|
29
|
+
* `healthy` from `foreign`.
|
|
30
|
+
*
|
|
31
|
+
* Neither path is denylisted, so both are publicly reachable.
|
|
32
|
+
*
|
|
33
|
+
* The module takes a URL and an expected id and returns a verdict: it reads no
|
|
34
|
+
* config and knows nothing about workspace paths, which is what lets callers
|
|
35
|
+
* probe an arbitrary URL and lets tests run without network access.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import {
|
|
39
|
+
normalizeHttpPublicBaseUrlWithoutTrailingSlash,
|
|
40
|
+
trimmedNonEmptyString,
|
|
41
|
+
} from "@vellumai/service-contracts/ingress";
|
|
42
|
+
|
|
43
|
+
import { isPlainObject } from "../util/object.js";
|
|
44
|
+
import { truncate } from "../util/truncate.js";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Verdict for one probe.
|
|
48
|
+
*
|
|
49
|
+
* `unpairable` means the edge answered the config request with something that
|
|
50
|
+
* is not the pairing edge's config, so it is alive without fronting the
|
|
51
|
+
* pairing app.
|
|
52
|
+
* `foreign` means the edge positively identified itself as a different
|
|
53
|
+
* assistant. Any case where either id is unknown is `healthy`: a config
|
|
54
|
+
* served by a CLI that predates the id, or a URL recorded before the id was
|
|
55
|
+
* saved, is version skew, not someone else's assistant.
|
|
56
|
+
*/
|
|
57
|
+
export type TunnelProbeResult =
|
|
58
|
+
| { kind: "healthy"; assistantId?: string; assistantName?: string }
|
|
59
|
+
| { kind: "unreachable"; detail: string }
|
|
60
|
+
| { kind: "unpairable"; detail?: string }
|
|
61
|
+
| { kind: "foreign"; assistantId?: string; assistantName?: string };
|
|
62
|
+
|
|
63
|
+
const DEFAULT_TIMEOUT_MS = 4_000;
|
|
64
|
+
|
|
65
|
+
/** Placeholder swapped in for the probed URL so `detail` stays URL-free. */
|
|
66
|
+
const REDACTED_URL = "<url>";
|
|
67
|
+
|
|
68
|
+
/** `detail` is rendered inline in a settings card, so it has to stay short. */
|
|
69
|
+
const MAX_DETAIL_LENGTH = 120;
|
|
70
|
+
|
|
71
|
+
/** Bound on the `cause` walk, so a self-referential chain cannot spin. */
|
|
72
|
+
const MAX_CAUSE_DEPTH = 5;
|
|
73
|
+
|
|
74
|
+
/** Wrappers fetch layers raise when the real reason sits one `cause` down. */
|
|
75
|
+
const GENERIC_FETCH_MESSAGES = new Set(["fetch failed", "failed to fetch"]);
|
|
76
|
+
|
|
77
|
+
/** Reported when the config path answers with a body that is not a config. */
|
|
78
|
+
const UNREADABLE_CONFIG_DETAIL = "no assistant config served";
|
|
79
|
+
|
|
80
|
+
/** Reported when a served body parses but is not the pairing edge's config. */
|
|
81
|
+
const UNMARKED_CONFIG_DETAIL = "not a pairing edge config";
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Stamped into the served config by the nginx pairing edge and nothing else
|
|
85
|
+
* (`remoteWebIngressConfig` in `cli/src/lib/nginx-ingress.ts`), which makes it
|
|
86
|
+
* the marker that separates that edge from any other JSON a live URL serves.
|
|
87
|
+
*/
|
|
88
|
+
const PAIRING_EDGE_MODE = "remote-gateway";
|
|
89
|
+
|
|
90
|
+
export async function probeTunnel(args: {
|
|
91
|
+
publicBaseUrl: string;
|
|
92
|
+
/** Id recorded when this URL was saved; omit to skip the identity check. */
|
|
93
|
+
expectedAssistantId?: string;
|
|
94
|
+
timeoutMs?: number;
|
|
95
|
+
fetchImpl?: typeof fetch;
|
|
96
|
+
}): Promise<TunnelProbeResult> {
|
|
97
|
+
if (args.publicBaseUrl.trim().length === 0) {
|
|
98
|
+
return { kind: "unreachable", detail: "no public base URL" };
|
|
99
|
+
}
|
|
100
|
+
// The same validation the config writers apply, so a value they would have
|
|
101
|
+
// rejected is named as malformed rather than handed to `fetch` and reported
|
|
102
|
+
// through whatever string that layer happens to produce.
|
|
103
|
+
const base = normalizeHttpPublicBaseUrlWithoutTrailingSlash(
|
|
104
|
+
args.publicBaseUrl,
|
|
105
|
+
);
|
|
106
|
+
if (base === undefined) {
|
|
107
|
+
return { kind: "unreachable", detail: "not an http(s) URL" };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const fetchImpl = args.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
111
|
+
// One deadline covers both requests: only an answered config request can
|
|
112
|
+
// demote a live edge, so a slow edge that answers `/healthz` and loses the
|
|
113
|
+
// config request to the same deadline keeps its liveness verdict.
|
|
114
|
+
const signal = AbortSignal.timeout(args.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
115
|
+
const [health, config] = await Promise.allSettled([
|
|
116
|
+
fetchImpl(`${base}/healthz`, { signal }),
|
|
117
|
+
fetchImpl(`${base}/assistant/__config`, { signal }),
|
|
118
|
+
]);
|
|
119
|
+
const configResponse =
|
|
120
|
+
config.status === "fulfilled" ? config.value : undefined;
|
|
121
|
+
|
|
122
|
+
if (health.status === "rejected") {
|
|
123
|
+
await discardBody(configResponse);
|
|
124
|
+
return unreachable(describeError(health.reason), base);
|
|
125
|
+
}
|
|
126
|
+
// Liveness is the status line; the health body is never read.
|
|
127
|
+
await discardBody(health.value);
|
|
128
|
+
if (!health.value.ok) {
|
|
129
|
+
await discardBody(configResponse);
|
|
130
|
+
return unreachable(`HTTP ${health.value.status}`, base);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const served = await readServedConfig(configResponse);
|
|
134
|
+
if (served.kind === "notServed") {
|
|
135
|
+
return { kind: "unpairable", detail: served.detail };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const identity = served.kind === "served" ? served.config : {};
|
|
139
|
+
const expected = trimmedNonEmptyString(args.expectedAssistantId);
|
|
140
|
+
const isForeign =
|
|
141
|
+
expected !== undefined &&
|
|
142
|
+
identity.assistantId !== undefined &&
|
|
143
|
+
identity.assistantId !== expected;
|
|
144
|
+
|
|
145
|
+
return { kind: isForeign ? "foreign" : "healthy", ...identity };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Fetch layers vary in how much of the request they echo into an error
|
|
150
|
+
* message, and the caller already knows which URL it asked about.
|
|
151
|
+
*/
|
|
152
|
+
function unreachable(detail: string, base: string): TunnelProbeResult {
|
|
153
|
+
return {
|
|
154
|
+
kind: "unreachable",
|
|
155
|
+
detail: truncate(detail.replaceAll(base, REDACTED_URL), MAX_DETAIL_LENGTH),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* An unread fetch body pins its socket until GC under Bun and undici, and the
|
|
161
|
+
* probe runs on every settings mount, resume, and manual refresh.
|
|
162
|
+
*/
|
|
163
|
+
async function discardBody(response: Response | undefined): Promise<void> {
|
|
164
|
+
try {
|
|
165
|
+
await response?.body?.cancel();
|
|
166
|
+
} catch {
|
|
167
|
+
/* best effort */
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isTimeoutError(error: unknown): boolean {
|
|
172
|
+
return (
|
|
173
|
+
isPlainObject(error) &&
|
|
174
|
+
(error.name === "TimeoutError" || error.name === "AbortError")
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function isGenericFetchMessage(message: string): boolean {
|
|
179
|
+
return GENERIC_FETCH_MESSAGES.has(message.toLowerCase());
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* A dead tunnel surfaces as a generic wrapper ("fetch failed") whose `cause`
|
|
184
|
+
* carries the reason worth reading (`ECONNREFUSED`, `ENOTFOUND`), so walk the
|
|
185
|
+
* chain for a code, then for the first message that says something.
|
|
186
|
+
*/
|
|
187
|
+
function describeError(error: unknown): string {
|
|
188
|
+
let message: string | undefined;
|
|
189
|
+
let current: unknown = error;
|
|
190
|
+
|
|
191
|
+
for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth += 1) {
|
|
192
|
+
if (!isPlainObject(current)) {
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
if (isTimeoutError(current)) {
|
|
196
|
+
return "timeout";
|
|
197
|
+
}
|
|
198
|
+
const code = trimmedNonEmptyString(current.code);
|
|
199
|
+
if (code !== undefined) {
|
|
200
|
+
return code;
|
|
201
|
+
}
|
|
202
|
+
// A wrapper only holds the slot until a deeper level fills it.
|
|
203
|
+
if (message === undefined || isGenericFetchMessage(message)) {
|
|
204
|
+
message = trimmedNonEmptyString(current.message) ?? message;
|
|
205
|
+
}
|
|
206
|
+
current = current.cause;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return message ?? String(error);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
interface ServedEdgeConfig {
|
|
213
|
+
assistantId?: string;
|
|
214
|
+
assistantName?: string;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* What the config request settled: the identity the pairing edge reports, a
|
|
219
|
+
* positive "whatever is here does not serve the pairing app", or nothing.
|
|
220
|
+
*/
|
|
221
|
+
type ServedConfigReading =
|
|
222
|
+
| { kind: "unanswered" }
|
|
223
|
+
| { kind: "notServed"; detail: string }
|
|
224
|
+
| { kind: "served"; config: ServedEdgeConfig };
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* A 2xx JSON object carrying the pairing edge's marker is that edge
|
|
228
|
+
* identifying itself, any other answer is an edge that does not serve the
|
|
229
|
+
* pairing app, and no answer is no evidence.
|
|
230
|
+
*/
|
|
231
|
+
async function readServedConfig(
|
|
232
|
+
response: Response | undefined,
|
|
233
|
+
): Promise<ServedConfigReading> {
|
|
234
|
+
if (response === undefined) {
|
|
235
|
+
return { kind: "unanswered" };
|
|
236
|
+
}
|
|
237
|
+
if (!response.ok) {
|
|
238
|
+
await discardBody(response);
|
|
239
|
+
return { kind: "notServed", detail: `HTTP ${response.status}` };
|
|
240
|
+
}
|
|
241
|
+
let detail = UNREADABLE_CONFIG_DETAIL;
|
|
242
|
+
try {
|
|
243
|
+
const body: unknown = await response.json();
|
|
244
|
+
if (isPlainObject(body)) {
|
|
245
|
+
if (body.mode === PAIRING_EDGE_MODE) {
|
|
246
|
+
return {
|
|
247
|
+
kind: "served",
|
|
248
|
+
config: {
|
|
249
|
+
assistantId: trimmedNonEmptyString(body.assistantId),
|
|
250
|
+
assistantName: trimmedNonEmptyString(body.assistantName),
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
detail = UNMARKED_CONFIG_DETAIL;
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
/* an unparseable body is an edge answering with something else */
|
|
258
|
+
}
|
|
259
|
+
await discardBody(response);
|
|
260
|
+
return { kind: "notServed", detail };
|
|
261
|
+
}
|
|
@@ -115,6 +115,31 @@ describe("createLiveVoiceConnection", () => {
|
|
|
115
115
|
]);
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
+
test("drops a pending start when its transport closes mid-wait", async () => {
|
|
119
|
+
const { sessions } = installFakeManager((_session, ctx) =>
|
|
120
|
+
ctx.sessionId === "session-1"
|
|
121
|
+
? { close: mock(() => new Promise<void>(() => {})) }
|
|
122
|
+
: {},
|
|
123
|
+
);
|
|
124
|
+
const first = createConnection();
|
|
125
|
+
const second = createConnection();
|
|
126
|
+
|
|
127
|
+
await first.connection.handleMessage(START_MESSAGE);
|
|
128
|
+
// The first transport goes away and its session begins a teardown that
|
|
129
|
+
// never finishes, so the next start has to wait for the slot.
|
|
130
|
+
first.connection.release();
|
|
131
|
+
|
|
132
|
+
const pending = second.connection.handleMessage(START_MESSAGE);
|
|
133
|
+
await Promise.resolve();
|
|
134
|
+
second.connection.release();
|
|
135
|
+
await pending;
|
|
136
|
+
|
|
137
|
+
// No session was built for the socket that is already gone.
|
|
138
|
+
expect(sessions).toHaveLength(1);
|
|
139
|
+
expect(second.connection.sessionId).toBeUndefined();
|
|
140
|
+
expect(second.frames).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
|
|
118
143
|
test("assigns monotonically increasing sequence numbers", async () => {
|
|
119
144
|
installFakeManager();
|
|
120
145
|
const { connection, frames } = createConnection();
|
|
@@ -410,7 +410,10 @@ describe("LiveVoiceSession Flux end-of-turn", () => {
|
|
|
410
410
|
await waitFor(() => turnCalls.length === 1);
|
|
411
411
|
|
|
412
412
|
const metricsFrame = await waitForTurnMetrics(frames);
|
|
413
|
-
|
|
413
|
+
// Date.now() vs setTimeout can undershoot the sleep by a millisecond on
|
|
414
|
+
// a loaded runner. The assertion is that the speech-stop anchor saw the
|
|
415
|
+
// pause, not that the timer is exact.
|
|
416
|
+
expect(metricsFrame.endpointCommitLatencyMs).toBeGreaterThanOrEqual(240);
|
|
414
417
|
|
|
415
418
|
await session.close("client_end");
|
|
416
419
|
});
|
|
@@ -435,7 +438,10 @@ describe("LiveVoiceSession Flux end-of-turn", () => {
|
|
|
435
438
|
// the two arms one population rather than two.
|
|
436
439
|
const metricsFrame = await waitForTurnMetrics(frames);
|
|
437
440
|
expect(metricsFrame.endpointDecisionSource).toBeUndefined();
|
|
438
|
-
|
|
441
|
+
// The silence timer is 40 ms. CI clock jitter can report 39. The
|
|
442
|
+
// assertion is that the front-door path recorded a silence-scale
|
|
443
|
+
// latency, not that setTimeout fired on the exact millisecond.
|
|
444
|
+
expect(metricsFrame.endpointCommitLatencyMs).toBeGreaterThanOrEqual(30);
|
|
439
445
|
|
|
440
446
|
await session.close("client_end");
|
|
441
447
|
});
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type LiveVoiceSessionFactoryContext,
|
|
7
7
|
LiveVoiceSessionManager,
|
|
8
8
|
LiveVoiceSessionStartupError,
|
|
9
|
+
type LiveVoiceSlotEvent,
|
|
9
10
|
} from "../live-voice-session-manager.js";
|
|
10
11
|
import type {
|
|
11
12
|
LiveVoiceClientFrame,
|
|
@@ -328,7 +329,7 @@ describe("LiveVoiceSessionManager", () => {
|
|
|
328
329
|
expect(manager.activeSessionId).toBe("session-1");
|
|
329
330
|
});
|
|
330
331
|
|
|
331
|
-
test("
|
|
332
|
+
test("makes a start that arrives mid-close wait for the slot", async () => {
|
|
332
333
|
const sessions: TestSession[] = [];
|
|
333
334
|
let resolveClose: (() => void) | undefined;
|
|
334
335
|
const manager = new LiveVoiceSessionManager({
|
|
@@ -349,28 +350,28 @@ describe("LiveVoiceSessionManager", () => {
|
|
|
349
350
|
});
|
|
350
351
|
const first = createSink();
|
|
351
352
|
const second = createSink();
|
|
352
|
-
const third = createSink();
|
|
353
353
|
|
|
354
354
|
await manager.startSession(START_FRAME, first.sink);
|
|
355
355
|
const releasePromise = manager.releaseSession("session-1", "client_end");
|
|
356
|
-
|
|
356
|
+
// The reconnect: a fresh transport asking for a session while the previous
|
|
357
|
+
// one is still unwinding. It must not be told the dying session is active.
|
|
358
|
+
const concurrent = manager.startSession(START_FRAME, second.sink);
|
|
357
359
|
const concurrentDispatch = await manager.handleClientFrame("session-1", {
|
|
358
360
|
type: "interrupt",
|
|
359
361
|
});
|
|
360
362
|
|
|
361
|
-
expect(concurrent).toEqual({
|
|
362
|
-
status: "busy",
|
|
363
|
-
activeSessionId: "session-1",
|
|
364
|
-
frame: { type: "busy", seq: 1, activeSessionId: "session-1" },
|
|
365
|
-
});
|
|
366
363
|
expect(concurrentDispatch).toEqual({ status: "not_found" });
|
|
364
|
+
// The lock still holds: no second session exists alongside the closing one.
|
|
367
365
|
expect(sessions).toHaveLength(1);
|
|
366
|
+
expect(second.frames).toEqual([]);
|
|
368
367
|
|
|
369
368
|
resolveClose?.();
|
|
370
369
|
await releasePromise;
|
|
371
370
|
|
|
372
|
-
|
|
373
|
-
|
|
371
|
+
expect(await concurrent).toEqual({
|
|
372
|
+
status: "accepted",
|
|
373
|
+
sessionId: "session-2",
|
|
374
|
+
});
|
|
374
375
|
expect(manager.activeSessionId).toBe("session-2");
|
|
375
376
|
});
|
|
376
377
|
|
|
@@ -393,3 +394,204 @@ describe("LiveVoiceSessionManager", () => {
|
|
|
393
394
|
}
|
|
394
395
|
});
|
|
395
396
|
});
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Closure assurance (LUM-3440): the slot must come back on its own from every
|
|
400
|
+
* way a session can stop being usable, because the client whose transport
|
|
401
|
+
* died cannot ask for it back and a user who force-quits the app does not get
|
|
402
|
+
* a second chance either.
|
|
403
|
+
*/
|
|
404
|
+
describe("LiveVoiceSessionManager slot reclamation", () => {
|
|
405
|
+
const SERVER_VAD_START = {
|
|
406
|
+
...START_FRAME,
|
|
407
|
+
turnDetection: "server_vad",
|
|
408
|
+
} as const satisfies LiveVoiceClientStartFrame;
|
|
409
|
+
|
|
410
|
+
const sleep = (ms: number) =>
|
|
411
|
+
new Promise((resolve) => setTimeout(resolve, ms));
|
|
412
|
+
|
|
413
|
+
test("frees the slot when a session's close never finishes", async () => {
|
|
414
|
+
const events: LiveVoiceSlotEvent[] = [];
|
|
415
|
+
const sessions: TestSession[] = [];
|
|
416
|
+
const manager = new LiveVoiceSessionManager({
|
|
417
|
+
createSessionId: mock(() => `session-${sessions.length + 1}`),
|
|
418
|
+
closeTimeoutMs: 25,
|
|
419
|
+
createSession: () => {
|
|
420
|
+
const session = createTestSession({
|
|
421
|
+
// A teardown that hangs: the shape of a close awaiting a
|
|
422
|
+
// continuation delivery or a provider that never answers.
|
|
423
|
+
close: mock(() => new Promise<void>(() => {})),
|
|
424
|
+
});
|
|
425
|
+
sessions.push(session);
|
|
426
|
+
return session;
|
|
427
|
+
},
|
|
428
|
+
onSlotEvent: (event) => events.push(event),
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
await manager.startSession(START_FRAME, createSink().sink);
|
|
432
|
+
await manager.releaseSession("session-1", "websocket_close");
|
|
433
|
+
|
|
434
|
+
expect(manager.activeSessionId).toBeNull();
|
|
435
|
+
expect(events).toEqual([
|
|
436
|
+
{
|
|
437
|
+
kind: "close_timed_out",
|
|
438
|
+
sessionId: "session-1",
|
|
439
|
+
reason: "websocket_close",
|
|
440
|
+
timeoutMs: 25,
|
|
441
|
+
},
|
|
442
|
+
]);
|
|
443
|
+
|
|
444
|
+
// The whole point: the next session starts instead of being told the
|
|
445
|
+
// wedged one is still active.
|
|
446
|
+
const next = await manager.startSession(START_FRAME, createSink().sink);
|
|
447
|
+
expect(next).toEqual({ status: "accepted", sessionId: "session-2" });
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("releases a server_vad session whose client goes silent", async () => {
|
|
451
|
+
const events: LiveVoiceSlotEvent[] = [];
|
|
452
|
+
const session = createTestSession();
|
|
453
|
+
const manager = new LiveVoiceSessionManager({
|
|
454
|
+
createSessionId: () => "session-1",
|
|
455
|
+
clientSilenceTimeoutMs: 30,
|
|
456
|
+
createSession: () => session,
|
|
457
|
+
onSlotEvent: (event) => events.push(event),
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
await manager.startSession(SERVER_VAD_START, createSink().sink);
|
|
461
|
+
expect(manager.activeSessionId).toBe("session-1");
|
|
462
|
+
|
|
463
|
+
await sleep(90);
|
|
464
|
+
|
|
465
|
+
expect(manager.activeSessionId).toBeNull();
|
|
466
|
+
expect(session.closeReasons).toEqual(["client_timeout"]);
|
|
467
|
+
expect(events).toEqual([
|
|
468
|
+
{
|
|
469
|
+
kind: "client_silence_timeout",
|
|
470
|
+
sessionId: "session-1",
|
|
471
|
+
timeoutMs: 30,
|
|
472
|
+
},
|
|
473
|
+
]);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("hangs up the transport it reclaimed the slot from", async () => {
|
|
477
|
+
const session = createTestSession();
|
|
478
|
+
const manager = new LiveVoiceSessionManager({
|
|
479
|
+
createSessionId: () => "session-1",
|
|
480
|
+
clientSilenceTimeoutMs: 30,
|
|
481
|
+
createSession: () => session,
|
|
482
|
+
});
|
|
483
|
+
const closeTransport = mock(() => {});
|
|
484
|
+
const { sink } = createSink();
|
|
485
|
+
|
|
486
|
+
await manager.startSession(SERVER_VAD_START, { ...sink, closeTransport });
|
|
487
|
+
await sleep(90);
|
|
488
|
+
|
|
489
|
+
// Reclaiming the slot alone would leave the client streaming into a
|
|
490
|
+
// session that no longer exists, and every socket out to it open.
|
|
491
|
+
expect(closeTransport).toHaveBeenCalledTimes(1);
|
|
492
|
+
expect(session.closeReasons).toEqual(["client_timeout"]);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test("inbound audio pushes the silence deadline out", async () => {
|
|
496
|
+
const session = createTestSession();
|
|
497
|
+
const manager = new LiveVoiceSessionManager({
|
|
498
|
+
createSessionId: () => "session-1",
|
|
499
|
+
clientSilenceTimeoutMs: 60,
|
|
500
|
+
createSession: () => session,
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
await manager.startSession(SERVER_VAD_START, createSink().sink);
|
|
504
|
+
await sleep(40);
|
|
505
|
+
await manager.handleBinaryAudio("session-1", new Uint8Array([1, 2, 3]));
|
|
506
|
+
// Past the original deadline: only the refresh keeps this session alive.
|
|
507
|
+
await sleep(40);
|
|
508
|
+
|
|
509
|
+
expect(manager.activeSessionId).toBe("session-1");
|
|
510
|
+
expect(session.closeReasons).toEqual([]);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("leaves a manual session alone, its silence is not evidence", async () => {
|
|
514
|
+
const session = createTestSession();
|
|
515
|
+
const manager = new LiveVoiceSessionManager({
|
|
516
|
+
createSessionId: () => "session-1",
|
|
517
|
+
clientSilenceTimeoutMs: 20,
|
|
518
|
+
createSession: () => session,
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
await manager.startSession(
|
|
522
|
+
{ ...START_FRAME, turnDetection: "manual" },
|
|
523
|
+
createSink().sink,
|
|
524
|
+
);
|
|
525
|
+
await sleep(70);
|
|
526
|
+
|
|
527
|
+
expect(manager.activeSessionId).toBe("session-1");
|
|
528
|
+
expect(session.closeReasons).toEqual([]);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("abandons a start whose transport went away while it waited", async () => {
|
|
532
|
+
const sessions: TestSession[] = [];
|
|
533
|
+
const manager = new LiveVoiceSessionManager({
|
|
534
|
+
createSessionId: mock(() => `session-${sessions.length + 1}`),
|
|
535
|
+
createSession: () => {
|
|
536
|
+
const session = createTestSession({
|
|
537
|
+
close: mock(() => new Promise<void>(() => {})),
|
|
538
|
+
});
|
|
539
|
+
sessions.push(session);
|
|
540
|
+
return session;
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
const second = createSink();
|
|
544
|
+
|
|
545
|
+
await manager.startSession(START_FRAME, createSink().sink);
|
|
546
|
+
void manager.releaseSession("session-1", "transport_closed");
|
|
547
|
+
|
|
548
|
+
const abort = new AbortController();
|
|
549
|
+
const pending = manager.startSession(START_FRAME, second.sink, {
|
|
550
|
+
signal: abort.signal,
|
|
551
|
+
});
|
|
552
|
+
await sleep(0);
|
|
553
|
+
// The socket that asked gave up while the outgoing slot was still
|
|
554
|
+
// unwinding. Building its session anyway would hand the slot to nobody.
|
|
555
|
+
abort.abort();
|
|
556
|
+
|
|
557
|
+
expect(await pending).toEqual({ status: "aborted" });
|
|
558
|
+
expect(sessions).toHaveLength(1);
|
|
559
|
+
expect(second.frames).toEqual([]);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("hangs up the transport when the slot is force-ended", async () => {
|
|
563
|
+
const session = createTestSession();
|
|
564
|
+
const manager = new LiveVoiceSessionManager({
|
|
565
|
+
createSessionId: () => "session-1",
|
|
566
|
+
createSession: () => session,
|
|
567
|
+
});
|
|
568
|
+
const closeTransport = mock(() => {});
|
|
569
|
+
const { sink } = createSink();
|
|
570
|
+
|
|
571
|
+
await manager.startSession(START_FRAME, { ...sink, closeTransport });
|
|
572
|
+
await manager.endActiveSession();
|
|
573
|
+
|
|
574
|
+
// Otherwise the client whose session was ended from elsewhere keeps
|
|
575
|
+
// showing an active call until it happens to send another frame.
|
|
576
|
+
expect(closeTransport).toHaveBeenCalledTimes(1);
|
|
577
|
+
expect(session.closeReasons).toEqual(["forced_end"]);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
test("ends the active session on request, whoever holds it", async () => {
|
|
581
|
+
const session = createTestSession();
|
|
582
|
+
const manager = new LiveVoiceSessionManager({
|
|
583
|
+
createSessionId: () => "session-1",
|
|
584
|
+
createSession: () => session,
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
expect(await manager.endActiveSession()).toEqual({ released: false });
|
|
588
|
+
|
|
589
|
+
await manager.startSession(START_FRAME, createSink().sink);
|
|
590
|
+
expect(await manager.endActiveSession()).toEqual({
|
|
591
|
+
released: true,
|
|
592
|
+
sessionId: "session-1",
|
|
593
|
+
});
|
|
594
|
+
expect(session.closeReasons).toEqual(["forced_end"]);
|
|
595
|
+
expect(manager.activeSessionId).toBeNull();
|
|
596
|
+
});
|
|
597
|
+
});
|
|
@@ -147,9 +147,12 @@ describe("live-voice session telemetry", () => {
|
|
|
147
147
|
|
|
148
148
|
await session.close("client_end");
|
|
149
149
|
|
|
150
|
+
// These sessions reach `ready` and are closed without ever sending audio,
|
|
151
|
+
// so they are silent by construction and the reason says which layer
|
|
152
|
+
// stopped short. See `telemetry/live-voice-funnel.ts`.
|
|
150
153
|
expect(recordLiveVoiceSessionEnded).toHaveBeenCalledWith({
|
|
151
154
|
sessionId: "session-123",
|
|
152
|
-
screen: "ended_client_end",
|
|
155
|
+
screen: "ended_client_end:silent_no_audio",
|
|
153
156
|
outcome: "completed",
|
|
154
157
|
});
|
|
155
158
|
});
|
|
@@ -164,7 +167,7 @@ describe("live-voice session telemetry", () => {
|
|
|
164
167
|
// the reason on `screen` is what distinguishes a drop from a hangup.
|
|
165
168
|
expect(recordLiveVoiceSessionEnded).toHaveBeenCalledWith({
|
|
166
169
|
sessionId: "session-123",
|
|
167
|
-
screen: "ended_websocket_close",
|
|
170
|
+
screen: "ended_websocket_close:silent_no_audio",
|
|
168
171
|
outcome: "completed",
|
|
169
172
|
});
|
|
170
173
|
});
|